Quick Draw Doodle Recognition: kako se spoprijateljiti z R, C++ in nevronskimi mrežami

Quick Draw Doodle Recognition: kako se spoprijateljiti z R, C++ in nevronskimi mrežami

Pozdravljeni, Habr!

Lansko jesen je Kaggle gostil tekmovanje za razvrščanje ročno narisanih slik Quick Draw Doodle Recognition, v katerem je med drugim sodelovala ekipa R-znanstvenikov: Artem Klevcova, Philippa Manager и Andrej Ogurtsov. Tekmovanja ne bomo podrobneje opisovali, to je bilo že narejeno v nedavna objava.

Tokrat se s kmetovanjem medalj ni izšlo, je pa bilo pridobljenih veliko dragocenih izkušenj, zato bi rad skupnosti povedal o številnih najbolj zanimivih in uporabnih stvareh na Kagleju in v vsakdanjem delu. Med obravnavanimi temami: težko življenje brez OpenCV, razčlenjevanje JSON (ti primeri preučujejo integracijo kode C++ v skripte ali pakete v R z uporabo Rcpp), parametrizacijo skriptov in dokerizacijo končne rešitve. Vsa koda iz sporočila v obliki, primerni za izvedbo, je na voljo v repozitorije.

Vsebina:

  1. Učinkovito naložite podatke iz CSV v MonetDB
  2. Priprava serij
  3. Iteratorji za razkladanje paketov iz baze podatkov
  4. Izbira arhitekture modela
  5. Parametriranje skripta
  6. Dokerizacija skriptov
  7. Uporaba več grafičnih procesorjev v Google Cloudu
  8. Namesto zaključka

1. Učinkovito naložite podatke iz CSV v bazo podatkov MonetDB

Podatki v tem tekmovanju niso na voljo v obliki že pripravljenih slik, temveč v obliki 340 datotek CSV (ena datoteka za vsak razred), ki vsebujejo JSON s koordinatami točk. Če te točke povežemo s črtami, dobimo končno sliko velikosti 256x256 pikslov. Za vsak zapis je tudi oznaka, ki označuje, ali je klasifikator, ki je bil uporabljen v času zbiranja podatkovnega niza, pravilno prepoznal sliko, dvočrkovno kodo države stalnega prebivališča avtorja slike, enolični identifikator, časovni žig in ime razreda, ki se ujema z imenom datoteke. Poenostavljena različica originalnih podatkov tehta 7.4 GB v arhivu in približno 20 GB po razpakiranju, polni podatki po razpakiranju zavzamejo 240 GB. Organizatorji so zagotovili, da sta obe različici reproducirali iste risbe, kar pomeni, da je bila polna različica odveč. Vsekakor je bilo shranjevanje 50 milijonov slik v grafičnih datotekah ali v obliki nizov takoj ocenjeno kot nerentabilno, zato smo se odločili združiti vse datoteke CSV iz arhiva train_simplified.zip v bazo podatkov z naknadnim generiranjem slik zahtevane velikosti "sproti" za vsako serijo.

Za DBMS je bil izbran dobro preverjen sistem MonetDB, in sicer implementacija za R kot paket MonetDBLite. Paket vključuje vdelano različico strežnika baze podatkov in vam omogoča, da prevzamete strežnik neposredno iz seje R in tam delate z njim. Ustvarjanje baze podatkov in povezovanje z njo izvedemo z enim ukazom:

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

Ustvariti bomo morali dve tabeli: eno za vse podatke, drugo za storitvene informacije o prenesenih datotekah (uporabno, če gre kaj narobe in je treba postopek nadaljevati po prenosu več datotek):

Izdelava tabel

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

Najhitrejši način za nalaganje podatkov v podatkovno bazo je bilo neposredno kopiranje datotek CSV z ukazom SQL COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTČe tablename - ime tabele in path - pot do datoteke. Med delom z arhivom je bilo ugotovljeno, da vgrajena izvedba unzip v R ne deluje pravilno s številnimi datotekami iz arhiva, zato smo uporabili sistem unzip (z uporabo parametra getOption("unzip")).

Funkcija za pisanje v podatkovno bazo

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

Če morate tabelo preoblikovati, preden jo zapišete v bazo podatkov, je dovolj, da posredujete argument preprocess funkcijo, ki bo preoblikovala podatke.

Koda za zaporedno nalaganje podatkov v bazo:

Zapisovanje podatkov v podatkovno bazo

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

Čas nalaganja podatkov se lahko razlikuje glede na hitrostne značilnosti uporabljenega pogona. V našem primeru branje in pisanje znotraj enega SSD-ja ali z bliskovnega pogona (izvorna datoteka) na SSD (DB) traja manj kot 10 minut.

Ustvarjanje stolpca z oznako razreda celega števila in indeksnim stolpcem traja še nekaj sekund (ORDERED INDEX) s številkami vrstic, po katerih bodo opazovanja vzorčena pri ustvarjanju paketov:

Ustvarjanje dodatnih stolpcev in kazala

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 bi rešili problem sprotnega ustvarjanja paketa, smo morali doseči največjo hitrost ekstrahiranja naključnih vrstic iz tabele doodles. Za to smo uporabili 3 trike. Prvi je bil zmanjšanje dimenzionalnosti tipa, ki hrani ID opazovanja. V izvirnem nizu podatkov je vrsta, potrebna za shranjevanje ID-ja bigint, vendar število opazovanj omogoča, da se njihovi identifikatorji, enaki redni številki, prilagodijo tipu int. Iskanje je v tem primeru veliko hitrejše. Drugi trik je bil uporaba ORDERED INDEX — do te odločitve smo prišli empirično, po pregledu vseh razpoložljivih možnosti. Tretja je bila uporaba parametriziranih poizvedb. Bistvo metode je enkratna izvršitev ukaza PREPARE z naknadno uporabo pripravljenega izraza pri ustvarjanju množice poizvedb istega tipa, vendar je dejansko prednost v primerjavi s preprostim SELECT se je izkazalo, da je v območju statistične napake.

Postopek nalaganja podatkov ne porabi več kot 450 MB RAM-a. To pomeni, da vam opisani pristop omogoča premikanje naborov podatkov, ki tehtajo desetine gigabajtov, na skoraj kateri koli proračunski strojni opremi, vključno z nekaterimi napravami z eno ploščo, kar je zelo kul.

Vse, kar ostane, je merjenje hitrosti pridobivanja (naključnih) podatkov in ovrednotenje skaliranja pri vzorčenju paketov različnih velikosti:

Primerjalna baza podatkov

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 spoprijateljiti z R, C++ in nevronskimi mrežami

2. Priprava serij

Celoten postopek priprave serije je sestavljen iz naslednjih korakov:

  1. Razčlenjevanje več datotek JSON, ki vsebujejo vektorje nizov s koordinatami točk.
  2. Risanje barvnih črt na podlagi koordinat točk na sliki zahtevane velikosti (na primer 256×256 ali 128×128).
  3. Pretvarjanje nastalih slik v tenzor.

V okviru tekmovanja med jedri Python je bila težava rešena predvsem z uporabo OpenCV. Eden najpreprostejših in najbolj očitnih analogov v R bi bil videti takole:

Implementacija pretvorbe JSON v Tensor v 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)
}

Risanje se izvaja s standardnimi orodji R in se shrani v začasni PNG, shranjen v RAM-u (v sistemu Linux se začasni imeniki R nahajajo v imeniku /tmp, nameščen v RAM). Ta datoteka se nato prebere kot tridimenzionalna matrika s številkami v razponu od 0 do 1. To je pomembno, ker bi se bolj običajen BMP prebral v neobdelano matriko s šestnajstiškimi barvnimi kodami.

Preizkusimo 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 spoprijateljiti z R, C++ in nevronskimi mrežami

Sama serija bo oblikovana na naslednji 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

Ta izvedba se nam je zdela neoptimalna, saj oblikovanje velikih serij traja nespodobno dolgo, zato smo se odločili izkoristiti izkušnje naših kolegov z uporabo zmogljive knjižnice OpenCV. Takrat še ni bilo pripravljenega paketa za R (zdaj ga ni), zato je bila minimalna implementacija zahtevane funkcionalnosti napisana v C++ z integracijo v kodo R z uporabo Rcpp.

Za rešitev težave so bili uporabljeni naslednji paketi in knjižnice:

  1. OpenCV za delo s slikami in risanje črt. Uporabljene vnaprej nameščene sistemske knjižnice in datoteke glave ter dinamično povezovanje.

  2. xtenzor za delo z večdimenzionalnimi nizi in tenzorji. Uporabili smo datoteke glave, vključene v istoimenski paket R. Knjižnica vam omogoča delo z večdimenzionalnimi nizi, tako v glavnem vrstnem redu kot v stolpcu.

  3. ndjson za razčlenjevanje JSON. Ta knjižnica se uporablja v xtenzor samodejno, če je prisoten v projektu.

  4. RcppThread za organiziranje večnitne obdelave vektorja iz JSON. Uporabljene so bile datoteke glave, ki jih ponuja ta paket. Od bolj priljubljenih RcppParallel Paket ima med drugim vgrajen mehanizem za prekinitev zanke.

Opozoriti je treba, da je xtenzor izkazalo se je za božji dar: poleg dejstva, da ima obsežno funkcionalnost in visoko zmogljivost, so se njegovi razvijalci izkazali za precej odzivne in so na vprašanja odgovarjali hitro in podrobno. Z njihovo pomočjo je bilo mogoče implementirati transformacije matrik OpenCV v tenzorje xtenzorjev, kakor tudi način združevanja 3-dimenzionalnih slikovnih tenzorjev v 4-dimenzionalni tenzor pravilne dimenzije (sam paket).

Gradiva za učenje Rcpp, xtensor in 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 prevajanje datotek, ki uporabljajo sistemske datoteke in dinamično povezovanje s knjižnicami, nameščenimi v sistemu, smo uporabili mehanizem vtičnikov, implementiran v paketu Rcpp. Za samodejno iskanje poti in zastavic smo uporabili priljubljen pripomoček za Linux pkg-config.

Izvedba vtičnika Rcpp za uporabo knjižnice 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)
  ))
})

Kot rezultat delovanja vtičnika bodo med postopkom prevajanja zamenjane naslednje vrednosti:

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"

Izvedbena koda za razčlenjevanje JSON in generiranje paketa za prenos v model je podana pod spojlerjem. Najprej dodajte lokalni imenik projekta za iskanje datotek glave (potrebno za ndjson):

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

Implementacija pretvorbe JSON v tenzor v 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;
}

To kodo je treba postaviti v datoteko src/cv_xt.cpp in prevedite z ukazom Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); potrebno tudi za delo nlohmann/json.hpp z dne repozitorija. Koda je razdeljena na več funkcij:

  • to_xt — funkcija predloge za preoblikovanje slikovne matrike (cv::Mat) na tenzor xt::xtensor;

  • parse_json — funkcija razčleni niz JSON, izvleče koordinate točk in jih zapakira v vektor;

  • ocv_draw_lines — iz nastalega vektorja točk nariše večbarvne črte;

  • process — združuje zgornje funkcije in dodaja tudi možnost spreminjanja velikosti dobljene slike;

  • cpp_process_json_str - ovoj nad funkcijo process, ki izvozi rezultat v R-objekt (večdimenzionalni niz);

  • cpp_process_json_vector - ovoj nad funkcijo cpp_process_json_str, ki vam omogoča obdelavo vektorja niza v večnitnem načinu.

Za risanje večbarvnih črt je bil uporabljen barvni model HSV, ki mu je sledila pretvorba v RGB. Preizkusimo 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 spoprijateljiti z R, C++ in nevronskimi mrežami
Primerjava hitrosti implementacij v R in 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 spoprijateljiti z R, C++ in nevronskimi mrežami

Kot lahko vidite, se je povečanje hitrosti izkazalo za zelo pomembno in ni mogoče dohiteti kode C++ s paralelizacijo kode R.

3. Iteratorji za razkladanje paketov iz podatkovne baze

R ima zaslužen sloves pri obdelavi podatkov, ki se prilegajo RAM-u, medtem ko je za Python bolj značilna iterativna obdelava podatkov, ki vam omogoča preprosto in naravno izvajanje izračunov zunaj jedra (izračuni z uporabo zunanjega pomnilnika). Klasičen in za nas relevanten primer v kontekstu opisanega problema so globoke nevronske mreže, trenirane po metodi gradientnega spuščanja z aproksimacijo gradienta na vsakem koraku z uporabo majhnega deleža opazovanj ali mini serije.

Ogrodja za globoko učenje, napisana v Pythonu, imajo posebne razrede, ki izvajajo iteratorje na podlagi podatkov: tabele, slike v mapah, binarne oblike itd. Uporabite lahko že pripravljene možnosti ali napišete svoje za določene naloge. V R lahko izkoristimo vse funkcije knjižnice Python keras s svojimi različnimi zaledji uporablja istoimenski paket, ki pa deluje na vrhu paketa mrežasti. Slednje si zasluži poseben dolg članek; ne omogoča samo izvajanja kode Python iz R, ampak vam omogoča tudi prenos predmetov med sejami R in Python, pri čemer samodejno izvede vse potrebne pretvorbe tipov.

Znebili smo se potrebe po shranjevanju vseh podatkov v RAM-u z uporabo MonetDBLite, vse delo "nevronske mreže" bo opravila izvirna koda v Pythonu, le iterator moramo napisati čez podatke, saj ni nič pripravljenega za takšno situacijo v R ali Pythonu. Zanj sta v bistvu samo dve zahtevi: vračati mora pakete v neskončni zanki in shraniti svoje stanje med iteracijami (slednje je v R implementirano na najpreprostejši način z zapiranjem). Prej je bilo treba izrecno pretvoriti nize R v nize numpy znotraj iteratorja, vendar trenutna različica paketa keras to naredi sama.

Izkazalo se je, da je iterator za podatke o usposabljanju in validaciji naslednji:

Iterator za podatke o usposabljanju in validaciji

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 sprejme kot vhod spremenljivko s povezavo do baze podatkov, število uporabljenih vrstic, število razredov, velikost serije, lestvico (scale = 1 ustreza upodabljanju slik 256x256 slikovnih pik, scale = 0.5 — 128x128 slikovnih pik), barvni indikator (color = FALSE določa upodabljanje v sivinah, kadar se uporablja color = TRUE vsaka poteza je narisana v novi barvi) in indikator predobdelave za omrežja, ki so vnaprej naučena na imagenet. Slednji je potreben za skaliranje vrednosti pikslov iz intervala [0, 1] v interval [-1, 1], ki je bil uporabljen pri usposabljanju dobavljenega keras modeli.

Zunanja funkcija vsebuje preverjanje vrste argumentov, tabelo data.table z naključno mešanimi številkami vrstic od samples_index in številke paketov, števec in največje število paketov ter izraz SQL za razkladanje podatkov iz baze podatkov. Poleg tega smo definirali hiter analog funkcije znotraj keras::to_categorical(). Za usposabljanje smo uporabili skoraj vse podatke, pol odstotka smo pustili za validacijo, zato je bila velikost epohe omejena s parametrom steps_per_epoch ob klicu keras::fit_generator(), in stanje if (i > max_i) deloval samo za validacijski iterator.

V notranji funkciji se indeksi vrstic pridobijo za naslednji paket, zapisi se razložijo iz baze podatkov z naraščajočim števcem paketov, razčlenjevanje JSON (funkcija cpp_process_json_vector(), napisano v C++) in ustvarjanje nizov, ki ustrezajo slikam. Nato se ustvarijo enkratni vektorji z oznakami razreda, nizi z vrednostmi slikovnih pik in oznakami se združijo v seznam, ki je povratna vrednost. Za pospešitev dela smo uporabili ustvarjanje indeksov v tabelah data.table in spreminjanje preko povezave - brez teh paketnih “čipov” podatki.tabela Težko si je predstavljati učinkovito delo s katero koli veliko količino podatkov v R.

Rezultati meritev hitrosti na prenosniku Core i5 so naslednji:

Primerjalno merilo iteratorja

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 spoprijateljiti z R, C++ in nevronskimi mrežami

Če imate zadostno količino RAM-a, lahko resno pospešite delovanje baze s prenosom v ta isti RAM (32 GB je dovolj za našo nalogo). V Linuxu je particija privzeto nameščena /dev/shm, ki zasedejo do polovice kapacitete RAM-a. Več lahko poudarite z urejanjem /etc/fstabda dobim zapis všeč tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Ne pozabite znova zagnati in preveriti rezultat z zagonom ukaza df -h.

Iterator za testne podatke je videti veliko enostavnejši, saj se testni nabor podatkov v celoti prilega RAM-u:

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. Izbira arhitekture modela

Prva uporabljena arhitektura je bila mobilenet v1, katere značilnosti so obravnavane v to sporočilo. Standardno je vključen keras in je v skladu s tem na voljo v istoimenskem paketu za R. Toda pri poskusu uporabe z enokanalnimi slikami se je izkazalo čudno: vhodni tenzor mora vedno imeti dimenzijo (batch, height, width, 3), to pomeni, da števila kanalov ni mogoče spremeniti. V Pythonu te omejitve ni, zato smo pohiteli in napisali lastno implementacijo te arhitekture po izvirnem članku (brez izpada, ki je v različici keras):

Arhitektura Mobilenet v1

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

Slabosti tega pristopa so očitne. Želim preizkusiti veliko modelov, a ravno nasprotno, ne želim ročno prepisati vsake arhitekture. Prav tako smo bili prikrajšani za možnost uporabe uteži modelov, predhodno usposobljenih na imagenetu. Kot običajno je pomagalo preučevanje dokumentacije. funkcija get_config() vam omogoča, da dobite opis modela v obliki, primerni za urejanje (base_model_conf$layers - običajni seznam R) in funkcijo from_config() izvede obratno pretvorbo v 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)

Zdaj ni težko napisati univerzalne funkcije za pridobitev katerega koli od dobavljenega keras modeli z ali brez uteži, trenirani na imagenetu:

Funkcija za nalaganje že pripravljenih arhitektur

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 uporabi enokanalnih slik se ne uporabljajo vnaprej pripravljene uteži. To je mogoče popraviti: z uporabo funkcije get_weights() pridobite uteži modela v obliki seznama nizov R, spremenite dimenzijo prvega elementa tega seznama (tako da vzamete en barvni kanal ali povprečite vse tri) in nato naložite uteži nazaj v model s funkcijo set_weights(). Te funkcionalnosti nismo nikoli dodali, ker je bilo na tej stopnji že jasno, da je bolj produktivno delati z barvnimi slikami.

Večino poskusov smo izvedli z uporabo mobilenet različic 1 in 2 ter resnet34. Sodobnejše arhitekture, kot je SE-ResNeXt, so se v tem tekmovanju dobro izkazale. Na žalost nismo imeli na razpolago že pripravljenih izvedb, svoje pa nismo napisali (vendar jo bomo zagotovo napisali).

5. Parametriranje skriptov

Zaradi priročnosti je bila vsa koda za začetek usposabljanja zasnovana kot en sam skript, parametriran z uporabo dokpt kot sledi:

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 izvedbo http://docopt.org/ za R. Z njegovo pomočjo se skripti zaženejo s preprostimi ukazi, kot je Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db ali ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, če datoteka train_nn.R je izvršljiv (ta ukaz bo začel usposabljati model resnet50 na tribarvnih slikah velikosti 128 x 128 pik mora biti baza podatkov v mapi /home/andrey/doodle_db). Na seznam lahko dodate hitrost učenja, vrsto optimizatorja in vse druge prilagodljive parametre. V procesu priprave publikacije se je izkazalo, da je arhitektura mobilenet_v2 iz trenutne različice keras v uporabi R ne morem zaradi neupoštevanih sprememb v R paketu čakamo da popravijo.

Ta pristop je omogočil znatno pospešitev poskusov z različnimi modeli v primerjavi z bolj tradicionalnim zagonom skriptov v RStudiu (paket označujemo kot možno alternativo tfruns). Toda glavna prednost je zmožnost enostavnega upravljanja zagona skriptov v Dockerju ali preprosto na strežniku, ne da bi za to namestili RStudio.

6. Dockerizacija skriptov

Docker smo uporabili za zagotovitev prenosljivosti okolja za usposabljanje modelov med člani ekipe in za hitro uvajanje v oblaku. S tem orodjem, ki je razmeroma neobičajno za programerja R, se lahko začnete seznanjati z to serije publikacij oz video tečaj.

Docker vam omogoča ustvarjanje lastnih slik iz nič in uporabo drugih slik kot osnovo za ustvarjanje lastnih. Pri analizi razpoložljivih možnosti smo prišli do zaključka, da je namestitev gonilnikov NVIDIA, CUDA+cuDNN in knjižnic Python precej obsežen del slike, zato smo se odločili, da za osnovo vzamemo uradno sliko. tensorflow/tensorflow:1.12.0-gpuin tam dodal potrebne pakete R.

Končna datoteka docker je bila videti takole:

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

Zaradi udobja so bili uporabljeni paketi postavljeni v spremenljivke; večina napisanih skriptov se kopira v vsebnike med sestavljanjem. Spremenili smo tudi ukazno lupino v /bin/bash za lažjo uporabo vsebine /etc/os-release. S tem se izognete potrebi po navedbi različice OS v kodi.

Poleg tega je bil napisan majhen bash skript, ki vam omogoča zagon vsebnika z različnimi ukazi. To so lahko na primer skripti za usposabljanje nevronskih mrež, ki so bile predhodno nameščene v vsebniku, ali ukazna lupina za odpravljanje napak in spremljanje delovanja vsebnika:

Skript za zagon vsebnika

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

Če se ta skript bash izvaja brez parametrov, bo skript poklican znotraj vsebnika train_nn.R s privzetimi vrednostmi; če je prvi pozicijski argument "bash", se bo vsebnik interaktivno zagnal z ukazno lupino. V vseh drugih primerih se vrednosti pozicijskih argumentov nadomestijo: CMD="Rscript /app/train_nn.R $@".

Omeniti velja, da so imeniki z izvornimi podatki in bazo podatkov ter imenik za shranjevanje usposobljenih modelov nameščeni znotraj vsebnika iz gostiteljskega sistema, kar vam omogoča dostop do rezultatov skriptov brez nepotrebnih manipulacij.

7. Uporaba več grafičnih procesorjev v Google Cloudu

Ena od značilnosti tekmovanja so bili zelo šumni podatki (glej naslovno sliko, izposojeno od @Leigh.plt pri ODS slack). Velike serije pomagajo pri boju proti temu in po poskusih na osebnem računalniku z 1 grafično procesorsko enoto smo se odločili obvladati modele usposabljanja na več grafičnih procesorjih v oblaku. Uporabljeno GoogleCloud (dober vodnik po osnovah) zaradi velike izbire razpoložljivih konfiguracij, ugodnih cen in 300 $ bonusa. Iz pohlepa sem naročil primerek 4xV100 s SSD in tono RAM-a in to je bila velika napaka. Tak stroj hitro požre denar, z eksperimentiranjem brez preizkušenega cevovoda lahko propadete. Za izobraževalne namene je bolje vzeti K80. Toda velika količina RAM-a je prišla še kako prav - oblačni SSD ni navdušil s svojo zmogljivostjo, zato so bazo podatkov prenesli na dev/shm.

Najbolj zanimiv je fragment kode, odgovoren za uporabo več grafičnih procesorjev. Najprej je model ustvarjen v CPU z uporabo upravitelja konteksta, tako kot v 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
  )
})

Nato se neprevedeni (to je pomembno) model kopira v dano število razpoložljivih GPU-jev in šele nato se prevede:

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čne tehnike zamrznitve vseh plasti razen zadnje, usposabljanje zadnje plasti, odmrznitev in ponovno usposabljanje celotnega modela za več grafičnih procesorjev ni bilo mogoče izvesti.

Usposabljanje je bilo spremljano brez uporabe. tenzorska plošča, pri čemer se omejimo na snemanje dnevnikov in shranjevanje modelov z informativnimi imeni po vsaki epohi:

Povratni klici

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

Številne težave, s katerimi smo se srečali, še niso bile premagane:

  • в keras ni pripravljene funkcije za samodejno iskanje optimalne hitrosti učenja (analogno lr_finder v knjižnici hitro.ai); Z nekaj truda je mogoče v R prenesti izvedbe tretjih oseb, na primer to;
  • kot posledica prejšnje točke ni bilo mogoče izbrati pravilne hitrosti vadbe pri uporabi več grafičnih procesorjev;
  • primanjkuje sodobnih arhitektur nevronskih mrež, zlasti tistih, ki so vnaprej usposobljene za imagenet;
  • politika brez enega cikla in diskriminativne stopnje učenja (kosinusno žarjenje je bilo na našo zahtevo izvajati, hvala skeydan).

Kaj koristnega smo se naučili iz tega tekmovanja:

  • Na strojni opremi z razmeroma nizko porabo energije lahko brez težav delate s spodobnimi (večkrat večjimi od RAM-a) količinami podatkov. Plastična vrečka podatki.tabela prihrani pomnilnik zaradi spreminjanja tabel na mestu, s čimer se izognemo njihovemu kopiranju, ob pravilni uporabi pa njegove zmogljivosti skoraj vedno izkazujejo najvišjo hitrost med vsemi znanimi orodji za skriptne jezike. Shranjevanje podatkov v bazo podatkov vam v mnogih primerih omogoča, da sploh ne razmišljate o potrebi po stiskanju celotnega nabora podatkov v RAM.
  • Počasne funkcije v R je mogoče zamenjati s hitrimi v C++ s pomočjo paketa Rcpp. Če poleg uporabe RcppThread ali RcppParallel, dobimo medplatformske večnitne izvedbe, tako da ni potrebe po paraleliziranju kode na ravni R.
  • Paket Rcpp se lahko uporablja brez resnega znanja C++, zahtevani minimum je opisan tukaj. Datoteke glave za številne odlične knjižnice C, kot je xtenzor na voljo na CRAN, torej se oblikuje infrastruktura za izvajanje projektov, ki integrirajo že pripravljeno visoko zmogljivo kodo C++ v R. Dodatno udobje je označevanje sintakse in statični analizator kode C++ v RStudiu.
  • dokpt vam omogoča zagon samostojnih skriptov s parametri. To je priročno za uporabo na oddaljenem strežniku, vklj. pod dokerjem. V RStudio je neprijetno izvajati veliko ur poskusov z usposabljanjem nevronskih mrež, namestitev IDE na sam strežnik pa ni vedno upravičena.
  • Docker zagotavlja prenosljivost kode in ponovljivost rezultatov med razvijalci z različnimi različicami operacijskega sistema in knjižnicami ter enostavnost izvajanja na strežnikih. Celoten cevovod usposabljanja lahko zaženete s samo enim ukazom.
  • Google Cloud je proračunu prijazen način za eksperimentiranje z drago strojno opremo, vendar morate skrbno izbrati konfiguracije.
  • Merjenje hitrosti posameznih fragmentov kode je zelo uporabno, zlasti pri kombinaciji R in C++ ter s paketom klop - tudi zelo enostavno.

Na splošno je bila ta izkušnja zelo koristna in še naprej si prizadevamo rešiti nekatere postavljene težave.

Vir: www.habr.com

Dodaj komentar