Quick Draw Doodle Recognition: ako sa spriateliť s R, C++ a neurónovými sieťami

Quick Draw Doodle Recognition: ako sa spriateliť s R, C++ a neurónovými sieťami

Čau Habr!

Minulý rok na jeseň usporiadal Kaggle súťaž na klasifikáciu ručne kreslených obrázkov Quick Draw Doodle Recognition, do ktorej sa okrem iného zapojil tím R-scientists: Artem Klevcovová, Manažér Philippa и Andrej Ogurcov. Súťaž nebudeme podrobne popisovať, tá už prebehla v r nedávna publikácia.

Tentoraz to s medailérstvom nevyšlo, ale nazbieralo sa veľa cenných skúseností, preto by som rád komunite porozprával o množstve najzaujímavejších a najužitočnejších vecí na Kagle a pri každodennej práci. Medzi diskutovanými témami: ťažký život bez OpenCV, analýza JSON (tieto príklady skúmajú integráciu kódu C++ do skriptov alebo balíkov v jazyku R pomocou Rcpp), parametrizácia skriptov a dockerizácia finálneho riešenia. Celý kód zo správy vo forme vhodnej na vykonanie je dostupný v úložiská.

Obsah:

  1. Efektívne načítajte dáta z CSV do MonetDB
  2. Príprava dávok
  3. Iterátory na vykladanie dávok z databázy
  4. Výber architektúry modelu
  5. Parametrizácia skriptu
  6. Dockerizácia skriptov
  7. Používanie viacerých GPU v službe Google Cloud
  8. namiesto záveru

1. Efektívne načítajte dáta z CSV do databázy MonetDB

Údaje v tejto súťaži nie sú poskytované vo forme hotových obrázkov, ale vo forme 340 CSV súborov (jeden súbor pre každú triedu) obsahujúcich JSON so súradnicami bodov. Spojením týchto bodov čiarami získame výsledný obrázok s rozmermi 256x256 pixelov. Pri každom zázname je tiež štítok, ktorý uvádza, či bol obrázok správne rozpoznaný klasifikátorom použitým v čase zberu súboru údajov, dvojpísmenový kód krajiny pobytu autora obrázka, jedinečný identifikátor, časová pečiatka a názov triedy, ktorý sa zhoduje s názvom súboru. Zjednodušená verzia pôvodných dát váži v archíve 7.4 GB a po rozbalení približne 20 GB, plné dáta po rozbalení zaberajú 240 GB. Organizátori zabezpečili, aby obe verzie reprodukovali rovnaké kresby, čo znamená, že plná verzia bola nadbytočná. V každom prípade bolo ukladanie 50 miliónov obrázkov v grafických súboroch alebo vo forme polí okamžite považované za nerentabilné a rozhodli sme sa zlúčiť všetky CSV súbory z archívu train_simplified.zip do databázy s následným generovaním obrázkov požadovanej veľkosti „za behu“ pre každú dávku.

Ako DBMS bol zvolený osvedčený systém MonetDB, konkrétne implementácia pre R ako balík MonetDBLite. Balík obsahuje zabudovanú verziu databázového servera a umožňuje vám vyzdvihnúť server priamo z relácie R a pracovať s ním tam. Vytvorenie databázy a pripojenie k nej sa vykonáva jedným príkazom:

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

Budeme musieť vytvoriť dve tabuľky: jednu pre všetky údaje, druhú pre servisné informácie o stiahnutých súboroch (užitočné, ak sa niečo pokazí a proces sa musí obnoviť po stiahnutí niekoľkých súborov):

Vytváranie tabuliek

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

Najrýchlejším spôsobom načítania údajov do databázy bolo priame skopírovanie CSV súborov pomocou príkazu SQL COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTKde tablename - názov tabuľky a path - cesta k súboru. Pri práci s archívom sa zistilo, že vstavaná implementácia unzip v R nefunguje korektne s množstvom súborov z archívu, preto sme použili systém unzip (pomocou parametra getOption("unzip")).

Funkcia pre zápis do databázy

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

Ak potrebujete tabuľku pred zápisom do databázy transformovať, stačí zadať argument preprocess funkcia, ktorá bude transformovať dáta.

Kód pre postupné načítanie údajov do databázy:

Zápis údajov do databázy

# Список файлов для записи
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 načítania údajov sa môže líšiť v závislosti od rýchlostných charakteristík použitého disku. V našom prípade trvá čítanie a zápis v rámci jedného SSD alebo z flash disku (zdrojového súboru) na SSD (DB) menej ako 10 minút.

Vytvorenie stĺpca s celočíselným označením triedy a stĺpcom indexu trvá ešte niekoľko sekúnd (ORDERED INDEX) s číslami riadkov, podľa ktorých sa budú pri vytváraní dávok vzorkovať pozorovania:

Vytváranie ďalších stĺpcov a indexu

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

Na vyriešenie problému vytvárania dávky za behu sme potrebovali dosiahnuť maximálnu rýchlosť extrahovania náhodných riadkov z tabuľky doodles. Na to sme použili 3 triky. Prvým bolo zníženie rozmerov typu, ktorý uchováva ID pozorovania. V pôvodnom súbore údajov je typ potrebný na uloženie ID bigint, ale počet pozorovaní umožňuje prispôsobiť ich identifikátory, ktoré sa rovnajú poradovému číslu, do typu int. Vyhľadávanie je v tomto prípade oveľa rýchlejšie. Druhým trikom bolo použitie ORDERED INDEX — k tomuto rozhodnutiu sme dospeli empiricky, prešli sme všetky dostupné možnosti. Treťou bolo použitie parametrizovaných dotazov. Podstatou metódy je vykonať príkaz raz PREPARE s následným použitím pripraveného výrazu pri vytváraní hromady dopytov rovnakého typu, ale v skutočnosti je tu výhoda v porovnaní s jednoduchým SELECT sa ukázalo byť v rozmedzí štatistickej chyby.

Proces nahrávania údajov spotrebuje nie viac ako 450 MB pamäte RAM. To znamená, že opísaný prístup vám umožňuje presúvať súbory údajov s hmotnosťou desiatok gigabajtov na takmer akomkoľvek cenovom hardvéri vrátane niektorých zariadení s jednou doskou, čo je celkom fajn.

Zostáva len zmerať rýchlosť získavania (náhodných) údajov a vyhodnotiť škálovanie pri odbere vzoriek rôznych veľkostí:

Databázový benchmark

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: ako sa spriateliť s R, C++ a neurónovými sieťami

2. Príprava dávok

Celý proces prípravy dávky pozostáva z nasledujúcich krokov:

  1. Analýza niekoľkých JSON obsahujúcich vektory reťazcov so súradnicami bodov.
  2. Kreslenie farebných čiar na základe súradníc bodov na obrázku požadovanej veľkosti (napríklad 256×256 alebo 128×128).
  3. Konverzia výsledných obrázkov na tenzor.

V rámci konkurencie medzi jadrami Pythonu bol problém vyriešený primárne pomocou OpenCV. Jeden z najjednoduchších a najzrejmejších analógov v R by vyzeral takto:

Implementácia konverzie JSON na 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)
}

Kreslenie sa vykonáva pomocou štandardných nástrojov R a ukladá sa do dočasného súboru PNG uloženého v pamäti RAM (v systéme Linux sa dočasné adresáre R nachádzajú v adresári /tmp, namontovaný v RAM). Tento súbor sa potom načíta ako trojrozmerné pole s číslami v rozsahu od 0 do 1. Je to dôležité, pretože konvenčnejší BMP by sa načítal do nespracovaného poľa s hexadecimálnymi farebnými kódmi.

Otestujme výsledok:

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: ako sa spriateliť s R, C++ a neurónovými sieťami

Samotná dávka sa vytvorí nasledovne:

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

Táto implementácia sa nám zdala neoptimálna, keďže tvorba veľkých dávok trvá neslušne dlho a rozhodli sme sa využiť skúsenosti našich kolegov s využitím výkonnej knižnice OpenCV. V tom čase neexistoval žiadny hotový balík pre R (teraz neexistuje), takže minimálna implementácia požadovanej funkcionality bola napísaná v C++ s integráciou do kódu R pomocou Rcpp.

Na vyriešenie problému boli použité nasledujúce balíky a knižnice:

  1. OpenCV pre prácu s obrázkami a kreslenie čiar. Použité predinštalované systémové knižnice a hlavičkové súbory, ako aj dynamické prepojenie.

  2. xtensor pre prácu s viacrozmernými poľami a tenzormi. Použili sme hlavičkové súbory zahrnuté v balíku R s rovnakým názvom. Knižnica vám umožňuje pracovať s multidimenzionálnymi poliami, a to ako v poradí väčšom riadku, tak väčšom poradí stĺpcov.

  3. ndjson na analýzu JSON. Táto knižnica sa používa v xtensor automaticky, ak je prítomný v projekte.

  4. RcppThread na organizovanie viacvláknového spracovania vektora z JSON. Použité hlavičkové súbory poskytované týmto balíkom. Od obľúbenejších RcppParallel Balíček má okrem iného zabudovaný mechanizmus prerušenia slučky.

Je potrebné poznamenať, že xtensor sa ukázalo ako dar z nebies: okrem toho, že má rozsiahlu funkčnosť a vysoký výkon, jeho vývojári sa ukázali byť celkom pohotoví a odpovedali na otázky rýchlo a podrobne. S ich pomocou bolo možné implementovať transformácie matíc OpenCV do xtensorových tenzorov, ako aj spôsob, ako spojiť 3-rozmerné tenzory obrazu do 4-rozmerného tenzoru správneho rozmeru (samotná dávka).

Materiály na učenie sa Rcpp, xtensor a 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

Na kompiláciu súborov, ktoré používajú systémové súbory a dynamické prepojenie s knižnicami nainštalovanými v systéme, sme použili mechanizmus pluginov implementovaný v balíku Rcpp. Na automatické vyhľadávanie ciest a príznakov sme použili populárnu linuxovú pomôcku pkg-config.

Implementácia doplnku Rcpp na používanie kniž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)
  ))
})

V dôsledku činnosti doplnku budú počas procesu kompilácie nahradené nasledujúce hodnoty:

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"

Implementačný kód pre analýzu JSON a generovanie dávky na prenos do modelu je uvedený pod spojlerom. Najprv pridajte lokálny adresár projektu na vyhľadávanie hlavičkových súborov (potrebné pre ndjson):

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

Implementácia konverzie JSON na 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;
}

Tento kód by mal byť umiestnený v súbore src/cv_xt.cpp a skompilovať pomocou príkazu Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); potrebné aj pre prácu nlohmann/json.hpp z Úložisko. Kód je rozdelený do niekoľkých funkcií:

  • to_xt — šablónovaná funkcia na transformáciu obrazovej matice (cv::Mat) na tenzor xt::xtensor;

  • parse_json — funkcia analyzuje reťazec JSON, extrahuje súradnice bodov a zbalí ich do vektora;

  • ocv_draw_lines — z výsledného vektora bodov nakreslí viacfarebné čiary;

  • process — spája vyššie uvedené funkcie a pridáva aj možnosť škálovať výsledný obrázok;

  • cpp_process_json_str - obal nad funkciou process, ktorá exportuje výsledok do R-objektu (viacrozmerné pole);

  • cpp_process_json_vector - obal nad funkciou cpp_process_json_str, ktorý vám umožňuje spracovať reťazcový vektor vo viacvláknovom režime.

Na kreslenie viacfarebných čiar bol použitý farebný model HSV s následnou konverziou do RGB. Otestujme výsledok:

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

Quick Draw Doodle Recognition: ako sa spriateliť s R, C++ a neurónovými sieťami
Porovnanie rýchlosti implementácií v R a 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: ako sa spriateliť s R, C++ a neurónovými sieťami

Ako vidíte, zvýšenie rýchlosti sa ukázalo ako veľmi výrazné a paralelizáciou R kódu nie je možné dobehnúť kód C++.

3. Iterátory na vykladanie dávok z databázy

R má zaslúženú povesť pre spracovanie dát, ktoré sa zmestia do RAM, zatiaľ čo Python sa vyznačuje skôr iteračným spracovaním dát, čo umožňuje jednoducho a prirodzene implementovať mimojadrové výpočty (výpočty s využitím externej pamäte). Klasickým a pre nás relevantným príkladom v kontexte opísaného problému sú hlboké neurónové siete trénované metódou gradientového zostupu s aproximáciou gradientu v každom kroku pomocou malej časti pozorovaní, prípadne mini-dávky.

Rámce pre hlboké učenie napísané v Pythone majú špeciálne triedy, ktoré implementujú iterátory založené na údajoch: tabuľky, obrázky v priečinkoch, binárne formáty atď. Môžete použiť hotové možnosti alebo si napísať vlastné pre špecifické úlohy. V R môžeme využiť všetky funkcie knižnice Python KERAS s rôznymi backendmi pomocou balíka s rovnakým názvom, ktorý zase funguje na vrchu balíka sieťový. To posledné si zaslúži samostatný dlhý článok; nielenže vám umožňuje spúšťať kód Python z R, ale tiež vám umožňuje prenášať objekty medzi reláciami R a Python, pričom automaticky vykonáva všetky potrebné konverzie typov.

Pomocou MonetDBLite sme sa zbavili potreby ukladať všetky dáta do RAM, všetku prácu “neurónovej siete” vykoná pôvodný kód v Pythone, len musíme nad dátami napísať iterátor, keďže nie je nič pripravené pre takúto situáciu v R alebo Pythone. Sú naň kladené v podstate len dve požiadavky: musí vracať dávky v nekonečnej slučke a medzi iteráciami ukladať svoj stav (to druhé v R je implementované najjednoduchším spôsobom pomocou uzáverov). Predtým bolo potrebné explicitne previesť polia R na numpy polia v iterátore, ale aktuálna verzia balíka KERAS robí to sama.

Iterátor pre trénovacie a overovacie údaje dopadol takto:

Iterátor pre trénovanie a overovanie údajov

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

Funkcia berie ako vstup premennú s pripojením k databáze, počty použitých riadkov, počet tried, veľkosť dávky, mierku (scale = 1 zodpovedá vykresľovaniu obrázkov 256 x 256 pixelov, scale = 0.5 — 128 x 128 pixelov, farebný indikátor (color = FALSE pri použití určuje vykreslenie v odtieňoch sivej color = TRUE každý ťah je nakreslený novou farbou) a indikátor predbežného spracovania pre siete vopred natrénované na imagenet. Ten je potrebný na škálovanie hodnôt pixelov z intervalu [0, 1] do intervalu [-1, 1], ktorý bol použitý pri trénovaní dodaného KERAS modelov.

Externá funkcia obsahuje kontrolu typu argumentu, tabuľku data.table s náhodne zmiešanými číslami riadkov z samples_index a čísla šarží, počítadlo a maximálny počet šarží, ako aj SQL výraz na stiahnutie údajov z databázy. Okrem toho sme definovali rýchly analóg funkcie vo vnútri keras::to_categorical(). Takmer všetky údaje sme použili na tréning, pol percenta sme nechali na validáciu, takže veľkosť epochy bola obmedzená parametrom steps_per_epoch pri zavolaní keras::fit_generator()a stav if (i > max_i) fungovalo iba pre overovací iterátor.

V internej funkcii sa načítavajú indexy riadkov pre ďalšiu dávku, záznamy sa uvoľňujú z databázy so zvyšujúcim sa počítadlom dávok, analýza JSON (funkcia cpp_process_json_vector(), napísaný v C++) a vytváranie polí zodpovedajúcich obrázkom. Potom sa vytvoria jednoúčelové vektory s menovkami tried, polia s hodnotami pixelov a menovky sa skombinujú do zoznamu, čo je návratová hodnota. Na urýchlenie práce sme využili tvorbu indexov v tabuľkách data.table a modifikácia cez odkaz - bez týchto balíkových „čipov“ údajová tabuľka Je pomerne ťažké predstaviť si efektívnu prácu s akýmkoľvek významným množstvom údajov v R.

Výsledky meraní rýchlosti na notebooku Core i5 sú nasledovné:

Benchmark iterátora

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: ako sa spriateliť s R, C++ a neurónovými sieťami

Ak máte dostatočné množstvo pamäte RAM, môžete výrazne urýchliť prevádzku databázy jej prenosom do rovnakej pamäte RAM (na našu úlohu stačí 32 GB). V Linuxe je oddiel štandardne pripojený /dev/shm, pričom zaberá až polovicu kapacity RAM. Úpravou môžete zvýrazniť viac /etc/fstabzískať záznam páči sa mi to tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Nezabudnite reštartovať a skontrolovať výsledok spustením príkazu df -h.

Iterátor testovacích údajov vyzerá oveľa jednoduchšie, pretože testovací súbor údajov sa úplne zmestí do pamäte RAM:

Iterátor pre testovacie dáta

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. Výber architektúry modelu

Prvá použitá architektúra bola mobilná sieť v1, o ktorých vlastnostiach sa diskutuje v toto správu. Je súčasťou štandardnej výbavy KERAS a preto je k dispozícii v rovnomennom balíku pre R. Pri pokuse o jeho použitie s jednokanálovými obrázkami sa však ukázala zvláštna vec: vstupný tenzor musí mať vždy rozmer (batch, height, width, 3), to znamená, že počet kanálov nemožno zmeniť. V Pythone takéto obmedzenie nie je, takže sme sa ponáhľali a napísali vlastnú implementáciu tejto architektúry podľa pôvodného článku (bez výpadku, ktorý je vo verzii keras):

Architektúra 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)
}

Nevýhody tohto prístupu sú zrejmé. Chcem otestovať veľa modelov, ale naopak, nechcem každú architektúru prepisovať ručne. Ukrátili sme sa aj o možnosť využiť váhy modelov predtrénovaných na imagenet. Ako obvykle, pomohlo preštudovanie dokumentácie. Funkcia get_config() umožňuje získať popis modelu vo forme vhodnej na úpravu (base_model_conf$layers - bežný zoznam R) a funkciu from_config() vykoná spätnú konverziu na objekt modelu:

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)

Teraz nie je ťažké napísať univerzálnu funkciu na získanie ktorejkoľvek z dodaných KERAS modely s alebo bez závažia cvičené na imagenet:

Funkcia na načítanie hotových architektúr

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 použití jednokanálových obrázkov sa nepoužívajú žiadne vopred pripravené závažia. Toto by sa dalo opraviť: pomocou funkcie get_weights() získajte váhy modelov vo forme zoznamu polí R, zmeňte rozmer prvého prvku tohto zoznamu (použitím jedného farebného kanála alebo spriemerovaním všetkých troch) a potom načítajte váhy späť do modelu pomocou funkcie set_weights(). Túto funkcionalitu sme nikdy nepridali, pretože v tejto fáze už bolo jasné, že je produktívnejšie pracovať s farebnými obrázkami.

Väčšinu experimentov sme uskutočnili pomocou mobilnej siete verzie 1 a 2, ako aj siete resnet34. Modernejšie architektúry, ako napríklad SE-ResNeXt, si v tejto súťaži viedli dobre. Žiaľ, nemali sme k dispozícii hotové implementácie a nenapísali sme ani vlastné (ale určite napíšeme).

5. Parametrizácia skriptov

Pre pohodlie bol celý kód na spustenie školenia navrhnutý ako jeden skript, parametrizovaný pomocou docpt takto:

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)

balíček docpt predstavuje realizáciu http://docopt.org/ pre R. S jeho pomocou sa skripty spúšťajú jednoduchými príkazmi ako Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db alebo ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, ak súbor train_nn.R je spustiteľný (tento príkaz spustí trénovanie modelu resnet50 na trojfarebných obrázkoch s rozmermi 128x128 pixelov musí byť databáza umiestnená v priečinku /home/andrey/doodle_db). Do zoznamu môžete pridať rýchlosť učenia, typ optimalizátora a akékoľvek ďalšie prispôsobiteľné parametre. V procese prípravy publikácie sa ukázalo, že architektúra mobilenet_v2 z aktuálnej verzie KERAS v použití R nemôže kvôli zmenám nezohľadneným v balíku R čakáme, kým to opravia.

Tento prístup umožnil výrazne urýchliť experimenty s rôznymi modelmi v porovnaní s tradičnejším spúšťaním skriptov v RStudio (balíček berieme ako možnú alternatívu tfruns). Hlavnou výhodou je však možnosť ľahko spravovať spúšťanie skriptov v Dockeri alebo jednoducho na serveri bez inštalácie RStudio.

6. Dockerizácia skriptov

Docker sme použili na zabezpečenie prenosnosti prostredia na trénovanie modelov medzi členmi tímu a na rýchle nasadenie v cloude. S týmto nástrojom, ktorý je pre R programátora pomerne nezvyčajný, sa môžete začať zoznamovať toto séria publikácií resp video kurz.

Docker vám umožňuje vytvárať vlastné obrázky od začiatku a používať iné obrázky ako základ pre vytváranie vlastných. Pri analýze dostupných možností sme dospeli k záveru, že inštalácia ovládačov NVIDIA, CUDA+cuDNN a knižníc Pythonu je pomerne objemná časť obrazu a rozhodli sme sa vziať za základ oficiálny obraz tensorflow/tensorflow:1.12.0-gpu, pričom tam pridáte potrebné balíky R.

Konečný súbor docker vyzeral takto:

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

Pre pohodlie boli použité balíčky vložené do premenných; väčšina napísaných skriptov sa počas montáže skopíruje do kontajnerov. Tiež sme zmenili príkazový shell na /bin/bash pre jednoduché používanie obsahu /etc/os-release. Tým sa predišlo potrebe špecifikovať verziu OS v kóde.

Okrem toho bol napísaný malý bash skript, ktorý vám umožňuje spustiť kontajner s rôznymi príkazmi. Mohli by to byť napríklad skripty na trénovanie neurónových sietí, ktoré boli predtým umiestnené vo vnútri kontajnera, alebo príkazový shell na ladenie a monitorovanie prevádzky kontajnera:

Skript na spustenie kontajnera

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

Ak je tento bash skript spustený bez parametrov, skript sa zavolá vo vnútri kontajnera train_nn.R s predvolenými hodnotami; ak je prvý pozičný argument "bash", kontajner sa spustí interaktívne s príkazovým shellom. Vo všetkých ostatných prípadoch sú hodnoty pozičných argumentov nahradené: CMD="Rscript /app/train_nn.R $@".

Za zmienku stojí, že adresáre so zdrojovými údajmi a databázou, ako aj adresár na ukladanie natrénovaných modelov, sú namontované vo vnútri kontajnera z hostiteľského systému, čo umožňuje prístup k výsledkom skriptov bez zbytočných manipulácií.

7. Používanie viacerých GPU v službe Google Cloud

Jednou z vlastností súťaže boli veľmi zašumené dáta (viď titulný obrázok, požičané z @Leigh.plt z ODS slack). Veľké dávky pomáhajú bojovať proti tomu a po experimentoch na PC s 1 GPU sme sa rozhodli zvládnuť tréningové modely na niekoľkých GPU v cloude. Použili ste GoogleCloud (dobrý sprievodca základmi) vďaka veľkému výberu dostupných konfigurácií, rozumným cenám a bonusu 300 USD. Z chamtivosti som si objednal inštanciu 4xV100 s SSD a tonou RAM a to bola veľká chyba. Takýto stroj rýchlo požiera peniaze, bez osvedčeného potrubia môžete experimentovať na mizine. Na vzdelávacie účely je lepšie vziať K80. Veľké množstvo operačnej pamäte ale prišlo vhod – cloudový SSD nezaujal výkonom, a tak sa databáza preniesla na dev/shm.

Najväčší záujem je o fragment kódu zodpovedný za používanie viacerých GPU. Najprv sa model vytvorí na CPU pomocou kontextového manažéra, rovnako ako v Pythone:

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

Potom sa nekompilovaný (to je dôležitý) model skopíruje do daného počtu dostupných GPU a až potom sa skompiluje:

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

Klasická technika zmrazenia všetkých vrstiev okrem poslednej, natrénovanie poslednej vrstvy, rozmrazenie a pretrénovanie celého modelu pre niekoľko GPU sa nedalo implementovať.

Tréning bol monitorovaný bez použitia. tensorboard, pričom sa obmedzujeme na zaznamenávanie protokolov a ukladanie modelov s informatívnymi názvami po každej epoche:

Spätné volania

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

Niekoľko problémov, s ktorými sme sa stretli, sa ešte nepodarilo prekonať:

  • в KERAS neexistuje žiadna pripravená funkcia na automatické vyhľadávanie optimálnej rýchlosti učenia (analóg lr_finder v knižnici rýchlo.ai); S určitým úsilím je možné preniesť implementácie tretích strán do R, napr. toto;
  • v dôsledku predchádzajúceho bodu nebolo možné zvoliť správnu rýchlosť tréningu pri použití viacerých GPU;
  • je nedostatok moderných architektúr neurónových sietí, najmä tých, ktoré sú vopred pripravené na imagenet;
  • politika žiadneho cyklu a diskriminačné miery učenia (kosínové žíhanie bolo na našu žiadosť implementovaná, Vďaka skeydan).

Aké užitočné veci sme sa naučili z tejto súťaže:

  • Na hardvéri s relatívne nízkou spotrebou môžete bez bolesti pracovať so slušnými (mnohokrát väčšími ako RAM) objemami dát. Plastový sáčok údajová tabuľka šetrí pamäť vďaka úprave tabuliek na mieste, čím nedochádza k ich kopírovaniu a pri správnom použití jeho schopnosti takmer vždy vykazujú najvyššiu rýchlosť spomedzi všetkých nástrojov, ktoré poznáme pre skriptovacie jazyky. Ukladanie údajov do databázy vám v mnohých prípadoch umožňuje vôbec nemyslieť na potrebu vtesnať celý súbor údajov do pamäte RAM.
  • Pomalé funkcie v R je možné nahradiť rýchlymi v C++ pomocou balíka Rcpp. Ak okrem použitia RcppThread alebo RcppParallel, získame multiplatformové implementácie s viacerými vláknami, takže nie je potrebné paralelizovať kód na úrovni R.
  • Balíček Rcpp možno použiť bez serióznych znalostí C++, je uvedené požadované minimum tu. Hlavičkové súbory pre množstvo skvelých C-knižníc ako xtensor dostupné na CRAN, to znamená, že sa vytvára infraštruktúra na implementáciu projektov, ktoré integrujú hotový vysokovýkonný kód C++ do R. Ďalšou výhodou je zvýraznenie syntaxe a statický analyzátor kódu C++ v RStudio.
  • docpt umožňuje spúšťať samostatné skripty s parametrami. To je vhodné pre použitie na vzdialenom serveri, vrátane. pod dokerom. V RStudio je nepohodlné vykonávať mnoho hodín experimentov s trénovaním neurónových sietí a inštalácia IDE na samotný server nie je vždy opodstatnená.
  • Docker zaisťuje prenosnosť kódu a reprodukovateľnosť výsledkov medzi vývojármi s rôznymi verziami OS a knižníc, ako aj jednoduchosť spúšťania na serveroch. Celý tréningový kanál môžete spustiť jediným príkazom.
  • Google Cloud je cenovo dostupný spôsob, ako experimentovať s drahým hardvérom, no konfigurácie musíte vyberať opatrne.
  • Meranie rýchlosti jednotlivých fragmentov kódu je veľmi užitočné najmä pri kombinácii R a C++ a s balíkom lavice - tiež veľmi ľahké.

Celkovo bola táto skúsenosť veľmi obohacujúca a naďalej pracujeme na vyriešení niektorých nastolených problémov.

Zdroj: hab.com

Pridať komentár