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

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

Hej Habr!

Prošle jeseni, Kaggle je bio domaćin takmičenja za klasifikaciju ručno nacrtanih slika, Quick Draw Doodle Recognition, u kojem je, između ostalih, učestvovao i tim R-naučnika: Artem Klevtsova, Philippa Manager и Andrej Ogurcov. Konkurs nećemo detaljno opisivati, to je već urađeno nedavna publikacija.

Ovoga puta nije išlo s uzgojem medalja, ali stečeno je mnogo dragocjenog iskustva, pa bih javnosti ispričao nekoliko najzanimljivijih i najkorisnijih stvari na Kagleu i u svakodnevnom radu. Među temama o kojima se razgovaralo: težak život bez OpenCV, JSON raščlanjivanje (ovi primjeri ispituju integraciju C++ koda u skripte ili pakete u R koristeći Rcpp), parametrizacija skripti i dokerizacija konačnog rješenja. Dostupan je sav kod iz poruke u formi pogodnoj za izvršenje spremišta.

Sadržaj:

  1. Efikasno učitajte podatke iz CSV-a u MonetDB
  2. Priprema serija
  3. Iteratori za istovar paketa iz baze podataka
  4. Odabir arhitekture modela
  5. Parameterizacija skripte
  6. Dokerizacija skripti
  7. Korištenje više GPU-a na Google Cloud-u
  8. Umjesto zaključka

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

Podaci u ovom konkursu nisu dati u obliku gotovih slika, već u obliku 340 CSV fajlova (po jedan fajl za svaku klasu) koji sadrže JSON-ove sa koordinatama tačaka. Povezivanjem ovih tačaka linijama dobijamo konačnu sliku dimenzija 256x256 piksela. Takođe za svaki zapis postoji oznaka koja pokazuje da li je sliku ispravno prepoznao klasifikator koji se koristio u trenutku kada je skup podataka prikupljen, dvoslovna šifra zemlje prebivališta autora slike, jedinstveni identifikator, vremenska oznaka i ime klase koje odgovara imenu datoteke. Pojednostavljena verzija originalnih podataka teži 7.4 GB u arhivi i otprilike 20 GB nakon raspakivanja, puni podaci nakon raspakivanja zauzimaju 240 GB. Organizatori su se pobrinuli da obje verzije reproduciraju iste crteže, što znači da je puna verzija suvišna. U svakom slučaju, pohranjivanje 50 miliona slika u grafičke datoteke ili u obliku nizova odmah je smatrano neisplativim, pa 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 izabran je dobro dokazan sistem MonetDB, odnosno implementacija za R kao paket MonetDBLite. Paket uključuje ugrađenu verziju servera baze podataka i omogućava vam da preuzmete server direktno iz R sesije i tamo radite s njim. Kreiranje baze podataka i povezivanje s njom vrši se jednom naredbom:

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

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

Kreiranje tabela

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 za učitavanje podataka u bazu podataka bio je direktno kopiranje CSV datoteka pomoću SQL - komande COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTgde tablename - naziv tabele i path - putanja do datoteke. Prilikom rada sa arhivom otkriveno je da je ugrađena implementacija unzip u R ne radi ispravno sa većim brojem fajlova iz arhive, pa smo koristili sistem unzip (koristeći parametar 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 treba da transformišete tabelu pre nego što je upišete u bazu podataka, dovoljno je da prosledite argument preprocess funkcija koja će transformisati 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 pogona koji se koristi. U našem slučaju, čitanje i pisanje unutar jednog SSD-a ili sa fleš diska (izvornog fajla) na SSD (DB) traje manje od 10 minuta.

Potrebno je još nekoliko sekundi da se kreira kolona s oznakom cjelobrojne klase i stupcem indeksa (ORDERED INDEX) s brojevima reda po kojima će se uzorkovati opažanja prilikom kreiranja serija:

Kreiranje dodatnih kolona 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 serije u hodu, morali smo postići maksimalnu brzinu izdvajanja nasumičnih redova iz tablice doodles. Za ovo smo koristili 3 trika. Prvi je bio da se smanji dimenzionalnost tipa koji pohranjuje ID posmatranja. U originalnom skupu podataka, tip koji je potreban za pohranjivanje ID-a je bigint, ali broj zapažanja omogućava da se njihovi identifikatori, jednaki rednom broju, uklope u tip int. Pretraga je u ovom slučaju mnogo brža. Drugi trik je bio korištenje ORDERED INDEX — do ove odluke smo došli empirijski, nakon što smo prošli kroz sve raspoložive varianty. Treći je bio korištenje parametrizovanih upita. Suština metode je da se naredba izvrši jednom PREPARE uz naknadnu upotrebu pripremljenog izraza pri kreiranju gomile upita istog tipa, ali zapravo postoji prednost u odnosu na jednostavan SELECT pokazalo se da je u opsegu statističke greške.

Proces učitavanja podataka ne troši više od 450 MB RAM-a. Odnosno, opisani pristup vam omogućava da premještate skupove podataka teške desetine gigabajta na gotovo bilo koji proračunski hardver, uključujući i neke uređaje sa jednom pločom, što je prilično cool.

Ostaje samo izmjeriti brzinu preuzimanja (slučajnih) podataka i procijeniti skaliranje prilikom uzorkovanja serija različitih veličina:

Reper 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 sa R, C++ i neuronskim mrežama

2. Priprema serija

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

  1. Parsiranje nekoliko JSON-ova koji sadrže vektore nizova sa koordinatama tačaka.
  2. Crtanje linija u boji na osnovu koordinata tačaka na slici potrebne veličine (na primjer, 256×256 ili 128×128).
  3. Pretvaranje rezultirajućih slika u tenzor.

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

Implementacija konverzije JSON u tenzor 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 pohranjuje u privremeni PNG pohranjen u RAM-u (na Linuxu, privremeni R direktoriji se nalaze u direktoriju /tmp, montiran u RAM). Ovaj fajl se zatim čita kao trodimenzionalni niz sa brojevima u rasponu od 0 do 1. Ovo je važno jer bi konvencionalniji BMP bio pročitan u sirovi niz sa 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 sa 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 implementacija nam se činila neoptimalna, jer formiranje velikih serija traje nepristojno dugo, pa smo odlučili iskoristiti iskustvo naših kolega korištenjem moćne biblioteke OpenCV. U to vrijeme nije postojao gotov paket za R (sada ga nema), pa je minimalna implementacija potrebne funkcionalnosti napisana u C++ uz integraciju u R kod korištenjem Rcpp.

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

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

  2. xtensor za rad sa višedimenzionalnim nizovima i tenzorima. Koristili smo datoteke zaglavlja uključene u istoimeni R paket. Biblioteka vam omogućava da radite sa višedimenzionalnim nizovima, kako u redovima, tako i u glavnom redu kolona.

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

  4. RcppThread za organizovanje višenitne obrade vektora iz JSON-a. Koristio je fajlove zaglavlja koje obezbjeđuje ovaj paket. Od popularnijih RcppParallel Paket, između ostalog, ima ugrađen mehanizam za prekid petlje.

Vredi napomenuti xtensor ispostavilo se kao dar od Boga: pored činjenice da ima opsežnu funkcionalnost i visoke performanse, njegovi programeri su se pokazali prilično osjetljivi i brzo i detaljno su odgovarali na pitanja. Uz njihovu pomoć, bilo je moguće implementirati transformacije OpenCV matrica u xtenzor tenzora, kao i način kombinovanja 3-dimenzionalnih tenzora slike u 4-dimenzionalni tenzor ispravne dimenzije (sama serija).

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 sa bibliotekama instaliranim na sistemu, koristili smo mehanizam dodataka implementiran u paketu Rcpp. Da bismo automatski pronašli putanje i zastavice, koristili smo popularni Linux uslužni program pkg-config.

Implementacija Rcpp dodatka za korištenje OpenCV biblioteke

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 će biti zamijenjene tokom 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 na model je dat ispod spojlera. Prvo dodajte lokalni projektni direktorij za traženje datoteka zaglavlja (potrebno za ndjson):

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

Implementacija konverzije 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 kompajlirajte sa naredbom Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); takođe potreban za rad nlohmann/json.hpp из 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 string, izdvaja koordinate tačaka, pakuje ih u vektor;

  • ocv_draw_lines — iz rezultirajućeg vektora tačaka, crta višebojne linije;

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

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

  • cpp_process_json_vector - omotač preko funkcije cpp_process_json_str, koji vam omogućava da obrađujete vektor niza u višenitnom modu.

Za crtanje višebojnih 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 sa R, C++ i neuronskim mrežama
Poređenje brzine implementacije 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 sa R, C++ i neuronskim mrežama

Kao što vidite, povećanje brzine se pokazalo veoma značajnim i nije moguće sustići C++ kod paralelizacijom R koda.

3. Iteratori za istovar paketa iz baze podataka

R ima zasluženu reputaciju za obradu podataka koji se uklapaju u RAM, dok Python više karakteriše iterativna obrada podataka, što vam omogućava da lako i prirodno implementirate kalkulacije izvan jezgre (kalkulacije pomoću eksterne memorije). Klasičan i relevantan primjer za nas u kontekstu opisanog problema su duboke neuronske mreže obučene metodom gradijentnog spuštanja uz aproksimaciju gradijenta na svakom koraku koristeći mali dio opservacija, ili mini-batch.

Okviri za duboko učenje napisani u Pythonu imaju posebne klase koje implementiraju iteratore na osnovu podataka: tabele, slike u fasciklama, binarni formati, itd. Možete koristiti gotove opcije ili napisati svoje za određene zadatke. U R-u možemo iskoristiti sve karakteristike Python biblioteke keras sa svojim različitim backendovima koji koriste paket istog imena, koji zauzvrat radi na vrhu paketa mrežasti. Ovo posljednje zaslužuje poseban dugi članak; ne samo da vam omogućava da pokrenete Python kod iz R, već vam omogućava i prijenos objekata između R i Python sesija, automatski izvodeći sve potrebne konverzije tipova.

Rešili smo se potrebe za pohranjivanjem svih podataka u RAM pomoću MonetDBLite-a, sav posao na “neuralnoj mreži” će obavljati originalni kod u Pythonu, samo moramo napisati iterator preko podataka, jer ništa nije spremno za takvu situaciju u R ili Pythonu. U suštini postoje samo dva zahtjeva za njega: mora vratiti pakete u beskonačnoj petlji i sačuvati svoje stanje između iteracija (potonje u R se implementira na najjednostavniji način korištenjem zatvaranja). Ranije je bilo potrebno eksplicitno pretvoriti R nizove u numpy nizove unutar iteratora, ali trenutna verzija paketa keras radi sama.

Pokazalo se da je iterator za podatke o obuci i validaciji 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 kao ulaz uzima varijablu s vezom na bazu podataka, brojevima korištenih linija, brojem klasa, veličinom serije, razmjerom (scale = 1 odgovara renderiranju slika od 256x256 piksela, scale = 0.5 — 128x128 piksela), indikator u boji (color = FALSE specificira prikazivanje u sivim tonovima kada se koristi color = TRUE svaki potez je nacrtan u novoj boji) i indikator preprocesiranja za mreže prethodno obučene na imagenet-u. Potonje je potrebno kako bi se vrijednosti piksela skalirali od intervala [0, 1] do intervala [-1, 1], koji je korišten pri obučavanju isporučenog keras modeli.

Eksterna funkcija sadrži provjeru tipa argumenta, tablicu data.table sa nasumično pomiješanim brojevima linija iz samples_index i brojevi serija, brojač i maksimalni broj serija, kao i SQL izraz za istovar podataka iz baze podataka. Dodatno, definirali smo brzi analog funkcije unutra keras::to_categorical(). Koristili smo skoro sve podatke za obuku, ostavljajući pola procenta za validaciju, tako da je veličina epohe bila ograničena parametrom steps_per_epoch kada je pozvan keras::fit_generator(), i stanje if (i > max_i) radio samo za iterator validacije.

U internoj funkciji, indeksi redova se preuzimaju za sljedeću grupu, zapisi se učitavaju iz baze podataka s povećanjem brojača serije, JSON raščlanjivanjem (funkcija cpp_process_json_vector(), napisan u C++) i kreiranje nizova koji odgovaraju slikama. Zatim se kreiraju jednokratni vektori sa oznakama klasa, nizovi sa vrednostima piksela i oznakama se kombinuju u listu, što je povratna vrednost. Da bismo ubrzali rad, koristili smo kreiranje indeksa u tabelama data.table i modifikacija preko linka - bez ovih paketa "čipova" data.table Prilično je teško zamisliti efikasan rad sa bilo kojom značajnom količinom podataka u R.

Rezultati mjerenja brzine na Core i5 laptopu su sljedeći:

Iterator benchmark

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

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

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

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

# Индексы для обучающей выборки
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 sa R, C++ i neuronskim mrežama

Ako imate dovoljnu količinu RAM-a, možete ozbiljno ubrzati rad baze podataka tako što ćete je prebaciti u istu tu RAM memoriju (32 GB je dovoljno za naš zadatak). U Linuxu se particija montira prema zadanim postavkama /dev/shm, koji zauzimaju do polovine kapaciteta RAM-a. Možete istaknuti više uređivanjem /etc/fstabda dobijete lajk tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Obavezno ponovo pokrenite sistem i provjerite rezultat pokretanjem naredbe df -h.

Iterator za testne podatke izgleda mnogo jednostavnije, budući da se testni skup podataka u potpunosti uklapa 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. Izbor arhitekture modela

Prva korištena arhitektura je bila mobilenet v1, čije su karakteristike obrađene u ovo poruka. Uključen je kao standard keras i, shodno tome, dostupan je u istoimenom paketu za R. Ali kada se pokuša koristiti s jednokanalnim slikama, ispostavilo se čudno: ulazni tenzor uvijek mora imati dimenziju (batch, height, width, 3), odnosno broj kanala se ne može mijenjati. U Pythonu nema takvog ograničenja, pa smo požurili i napisali vlastitu implementaciju ove arhitekture, slijedeći originalni članak (bez ispadanja koji 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čigledni. Želim da testiram mnogo modela, ali naprotiv, ne želim da prepisujem svaku arhitekturu ručno. Također smo bili lišeni mogućnosti da koristimo težine modela prethodno obučenih na imagenet-u. Kao i obično, pomoglo je proučavanje dokumentacije. Funkcija get_config() omogućava 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 koje od isporučenih keras modeli sa ili bez utega obučeni na imagenet-u:

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

Kada koristite jednokanalne slike, ne koriste se unaprijed obučeni utezi. Ovo bi se moglo popraviti: korištenjem funkcije get_weights() dobijte težine modela u obliku liste R nizova, promijenite dimenziju prvog elementa ove liste (uzimajući jedan kanal boje ili u prosjeku sva tri), a zatim učitajte 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.

Većinu eksperimenata izveli smo koristeći mobilenet verzije 1 i 2, kao i resnet34. Modernije arhitekture kao što je SE-ResNeXt su se dobro pokazale na ovom takmičenju. Nažalost, nismo imali gotove implementacije na raspolaganju, a nismo ni pisali svoje (ali ćemo svakako napisati).

5. Parameterizacija skripti

Radi praktičnosti, sav kod za početak obuke je dizajniran kao jedna skripta, parametrizirana korištenjem docopt 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 docopt predstavlja implementaciju http://docopt.org/ za R. Uz njegovu pomoć, skripte se pokreću jednostavnim komandama 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 fajl train_nn.R je izvršna (ova komanda će započeti obuku modela resnet50 na slikama u tri boje dimenzija 128x128 piksela, baza podataka se mora nalaziti u folderu /home/andrey/doodle_db). Na listu možete dodati brzinu učenja, tip optimizatora i sve druge prilagodljive parametre. U procesu pripreme publikacije ispostavilo se da je arhitektura mobilenet_v2 od trenutne verzije keras u R upotrebi ne može zbog promjena koje nisu uzete u obzir u R paketu, čekamo da to poprave.

Ovaj pristup je omogućio značajno ubrzanje eksperimenata sa različitim modelima u poređenju sa tradicionalnijim pokretanjem skripti u RStudiu (paket navodimo kao moguću alternativu tfruns). Ali glavna prednost je mogućnost lakog upravljanja pokretanjem skripti u Dockeru ili jednostavno na serveru, bez instaliranja RStudia za to.

6. Dokerizacija skripti

Koristili smo Docker da osiguramo prenosivost okruženja za obuku modela između članova tima i za brzu implementaciju u oblaku. Možete početi da se upoznate sa ovim alatom, koji je relativno neobičan za R programera ovo serija publikacija ili video kurs.

Docker vam omogućava da kreirate vlastite slike od nule i koristite druge slike kao osnovu za kreiranje vlastitih. Analizirajući dostupne opcije, došli smo do zaključka da je instaliranje NVIDIA, CUDA+cuDNN drajvera i Python biblioteka prilično obimni 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čni docker fajl je izgledao 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šćeni paketi su stavljeni u varijable; većina napisanih skripti se kopira unutar kontejnera tokom sklapanja. Također smo promijenili komandnu ljusku u /bin/bash radi lakšeg korišćenja 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ćava da pokrenete kontejner sa raznim komandama. Na primjer, to mogu biti skripte za obuku neuronskih mreža koje su prethodno bile smještene unutar kontejnera, ili komandna ljuska za otklanjanje grešaka i praćenje rada kontejnera:

Skripta za pokretanje kontejnera

#!/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 kontejnera train_nn.R sa zadanim vrijednostima; ako je prvi pozicioni argument "bash", tada će kontejner započeti interaktivno sa komandnom ljuskom. U svim ostalim slučajevima, vrijednosti pozicijskih argumenata se zamjenjuju: CMD="Rscript /app/train_nn.R $@".

Vrijedi napomenuti da se direktoriji sa izvornim podacima i bazom podataka, kao i direktorij za spremanje obučenih modela, montiraju unutar kontejnera iz host sistema, što vam omogućava pristup rezultatima skripti bez nepotrebnih manipulacija.

7. Korištenje više GPU-a na Google Cloud-u

Jedna od karakteristika takmičenja su bili veoma bučni podaci (pogledajte naslovnu sliku, pozajmljenu sa @Leigh.plt iz ODS slack). Velike serije pomažu u borbi protiv ovoga, a nakon eksperimenata na PC-u sa 1 GPU-om, odlučili smo da savladamo modele obuke na nekoliko GPU-a u oblaku. Korišten GoogleCloud (dobar vodič za osnove) zbog velikog izbora dostupnih konfiguracija, razumnih cijena i 300$ bonusa. Iz pohlepe sam naručio 4xV100 instancu sa SSD-om i tonom RAM-a, i to je bila velika greška. Takva mašina brzo pojede novac; možete propasti eksperimentirajući bez dokazanog cevovoda. U obrazovne svrhe, bolje je uzeti K80. No, velika količina RAM-a je dobro 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 kreira 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 zadati broj dostupnih GPU-a, 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, obučavanja posljednjeg sloja, odmrzavanja i ponovnog obučavanja cijelog modela za nekoliko GPU-a nije mogla biti implementirana.

Trening je praćen bez upotrebe. tensorboard, ograničavajući se na snimanje dnevnika i spremanje modela sa informativnim nazivima 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 sa kojima smo se susreli još uvijek nisu riješeni:

  • в keras ne postoji gotova funkcija za automatsko traženje optimalne brzine učenja (analogno lr_finder u biblioteci fast.ai); Uz određeni napor, moguće je prenijeti implementacije treće strane na R, na primjer, ovo;
  • kao posljedica prethodne tačke, nije bilo moguće odabrati ispravnu brzinu treninga kada se koristi nekoliko GPU-ova;
  • postoji nedostatak modernih arhitektura neuronskih mreža, posebno onih prethodno obučenih na imagenet-u;
  • politika bez jednog ciklusa i diskriminativne stope učenja (kosinusno žarenje je bilo na naš zahtjev implementirano, hvala skeydan).

Koje korisne stvari smo naučili sa ovog takmičenja:

  • Na hardveru s relativno malom potrošnjom, možete raditi sa pristojnim (višestruko većim od RAM-a) količinama podataka bez muke. Plasticna kesa data.table štedi memoriju zbog in-place modifikacije tabela, čime se izbjegava njihovo kopiranje, a kada se pravilno koriste, njegove mogućnosti gotovo uvijek pokazuju najveću brzinu među svim nama poznatim alatima za skriptne jezike. Čuvanje podataka u bazi podataka omogućava vam, u mnogim slučajevima, da uopće ne razmišljate o potrebi da se cijeli skup podataka ugura u RAM.
  • Spore funkcije u R mogu se zamijeniti brzim u C++ pomoću paketa Rcpp. Ako pored upotrebe RcppThread ili RcppParallel, dobijamo višeplatformske implementacije s više niti, tako da nema potrebe za paralelizacijom koda na R nivou.
  • Paket Rcpp može se koristiti bez ozbiljnog poznavanja C++-a, naveden je potreban minimum ovdje. Fajlovi zaglavlja za brojne cool C-biblioteke kao što su xtensor dostupno 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 C++ analizator koda u RStudiu.
  • docopt omogućava vam da pokrenete samostalne skripte sa parametrima. Ovo je zgodno za korištenje na udaljenom serveru, uklj. pod docker. U RStudiu je nezgodno provoditi mnogo sati eksperimenata sa obučavanjem neuronskih mreža, a instaliranje IDE-a na samom serveru nije uvijek opravdano.
  • Docker osigurava prenosivost koda i ponovljivost rezultata između programera s različitim verzijama OS-a i biblioteka, kao i lakoću izvršavanja na serverima. Možete pokrenuti cijeli proces obuke sa samo jednom komandom.
  • Google Cloud je jeftin način za eksperimentiranje na skupom hardveru, ali morate pažljivo odabrati konfiguracije.
  • Mjerenje brzine pojedinačnih fragmenata koda je vrlo korisno, posebno kada se kombiniraju R i C++, te sa paketom klupa - takođe vrlo lako.

Sve u svemu, ovo iskustvo je bilo veoma korisno i nastavljamo da radimo na rešavanju nekih od postavljenih pitanja.

izvor: www.habr.com

Dodajte komentar