Quick Draw Doodle Recognition: jak se spřátelit s R, C++ a neuronovými sítěmi

Quick Draw Doodle Recognition: jak se spřátelit s R, C++ a neuronovými sítěmi

Čau Habr!

Loni na podzim uspořádal Kaggle soutěž o klasifikaci ručně kreslených obrázků Quick Draw Doodle Recognition, které se mimo jiné zúčastnil tým R-scientists: Artem Klevtsová, Manažer Philippa и Andrej Ogurcov. Soutěž nebudeme podrobně popisovat, ta již byla provedena v nedávné publikace.

Tentokrát to s medailérstvím nevyšlo, ale získalo se mnoho cenných zkušeností, a tak bych rád komunitě řekl o řadě nejzajímavějších a nejužitečnějších věcí na Kagle a při každodenní práci. Mezi diskutovanými tématy: těžký život bez OpenCV, analýza JSON (tyto příklady zkoumají integraci kódu C++ do skriptů nebo balíčků v R pomocí Rcpp), parametrizace skriptů a dockerizace výsledného řešení. Veškerý kód ze zprávy ve formě vhodné k provedení je k dispozici v úložišť.

Obsah:

  1. Efektivně načtěte data z CSV do MonetDB
  2. Příprava dávek
  3. Iterátory pro vykládání dávek z databáze
  4. Výběr architektury modelu
  5. Parametrizace skriptu
  6. Dockerizace skriptů
  7. Používání více GPU ve službě Google Cloud
  8. Místo závěru

1. Efektivně načtěte data z CSV do databáze MonetDB

Data v této soutěži nejsou poskytována ve formě hotových obrázků, ale ve formě 340 CSV souborů (jeden soubor pro každou třídu) obsahujících JSON se souřadnicemi bodů. Spojením těchto bodů čarami získáme výsledný obrázek o rozměrech 256x256 pixelů. U každého záznamu je také štítek udávající, zda byl snímek správně rozpoznán klasifikátorem použitým v době sběru datové sady, dvoupísmenný kód země bydliště autora snímku, jedinečný identifikátor, časové razítko a název třídy, který odpovídá názvu souboru. Zjednodušená verze původních dat váží v archivu 7.4 GB a po rozbalení přibližně 20 GB, plná data po rozbalení zabírají 240 GB. Organizátoři zajistili, aby obě verze reprodukovaly stejné výkresy, což znamená, že plná verze byla nadbytečná. V každém případě bylo ukládání 50 milionů obrázků v grafických souborech nebo ve formě polí okamžitě považováno za nerentabilní a rozhodli jsme se sloučit všechny soubory CSV z archivu train_simplified.zip do databáze s následným generováním snímků požadované velikosti „za běhu“ pro každou šarži.

Jako DBMS byl zvolen osvědčený systém MonetDB, konkrétně implementace pro R jako balíček MonetDBLite. Balíček obsahuje vestavěnou verzi databázového serveru a umožňuje vám vyzvednout server přímo z relace R a pracovat s ním tam. Vytvoření databáze a připojení k ní se provádí jedním příkazem:

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

Budeme muset vytvořit dvě tabulky: jednu pro všechna data, druhou pro servisní informace o stažených souborech (užitečné, pokud se něco pokazí a proces musí být obnoven po stažení několika souborů):

Vytvoření tabulky

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

Nejrychlejším způsobem, jak načíst data do databáze, bylo přímé zkopírování souborů CSV pomocí příkazu SQL COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTKde tablename - název tabulky a path - cesta k souboru. Při práci s archivem bylo zjištěno, že vestavěná implementace unzip v R nefunguje správně s řadou souborů z archivu, proto jsme použili systém unzip (pomocí parametru getOption("unzip")).

Funkce pro zápis do databáze

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

Pokud potřebujete tabulku před zápisem do databáze transformovat, stačí předat argument preprocess funkce, která transformuje data.

Kód pro postupné načítání dat do databáze:

Zápis dat do databáze

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

Doba načítání dat se může lišit v závislosti na rychlostních charakteristikách použitého disku. V našem případě trvá čtení a zápis v rámci jednoho SSD nebo z flash disku (zdrojového souboru) na SSD (DB) méně než 10 minut.

Vytvoření sloupce s celočíselným štítkem třídy a sloupcem indexu trvá ještě několik sekund (ORDERED INDEX) s čísly řádků, podle kterých budou při vytváření dávek vzorkována pozorování:

Vytváření dalších sloupců a rejstříku

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

Abychom vyřešili problém vytváření dávky za chodu, potřebovali jsme dosáhnout maximální rychlosti extrahování náhodných řádků z tabulky doodles. K tomu jsme použili 3 triky. Prvním bylo snížení rozměrů typu, který ukládá ID pozorování. V původní sadě dat je typ požadovaný k uložení ID bigint, ale počet pozorování umožňuje vměstnat jejich identifikátory rovnající se pořadovému číslu do typu int. Hledání je v tomto případě mnohem rychlejší. Druhým trikem bylo použití ORDERED INDEX — k tomuto rozhodnutí jsme dospěli empiricky, když jsme prošli všemi dostupnými možnosti. Třetí bylo použití parametrizovaných dotazů. Podstatou metody je provést příkaz jednou PREPARE s následným použitím připraveného výrazu při vytváření hromady dotazů stejného typu, ale ve skutečnosti je zde výhoda ve srovnání s jednoduchým SELECT se ukázalo být v rozmezí statistické chyby.

Proces nahrávání dat nespotřebovává více než 450 MB paměti RAM. To znamená, že popsaný přístup vám umožňuje přesouvat datové sady vážící desítky gigabajtů na téměř jakýkoli levný hardware, včetně některých jednodeskových zařízení, což je docela cool.

Zbývá pouze změřit rychlost načítání (náhodných) dat a vyhodnotit škálování při vzorkování dávek různých velikostí:

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: jak se spřátelit s R, C++ a neuronovými sítěmi

2. Příprava dávek

Celý proces přípravy šarže se skládá z následujících kroků:

  1. Analýza několika JSON obsahujících vektory řetězců se souřadnicemi bodů.
  2. Kreslení barevných čar na základě souřadnic bodů na obrázku požadované velikosti (například 256×256 nebo 128×128).
  3. Převod výsledných obrázků do tenzoru.

V rámci konkurence mezi jádry Pythonu byl problém řešen primárně pomocí OpenCV. Jeden z nejjednodušších a nejzřejmějších analogů v R by vypadal takto:

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

Kreslení se provádí pomocí standardních nástrojů R a ukládá se do dočasného PNG uloženého v paměti RAM (v Linuxu jsou dočasné adresáře R umístěny v adresáři /tmp, nainstalovaný v paměti RAM). Tento soubor je poté načten jako trojrozměrné pole s čísly od 0 do 1. To je důležité, protože konvenčnější BMP by bylo načteno do nezpracovaného pole s hexadecimálními barevnými kódy.

Pojďme otestovat výsledek:

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: jak se spřátelit s R, C++ a neuronovými sítěmi

Samotná dávka bude vytvořena následovně:

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

Tato implementace se nám zdála neoptimální, protože tvorba velkých dávek trvá neslušně dlouho a rozhodli jsme se využít zkušeností našich kolegů pomocí výkonné knihovny OpenCV. V té době neexistoval žádný hotový balíček pro R (nyní žádný), takže byla napsána minimální implementace požadované funkcionality v C++ s integrací do kódu R pomocí Rcpp.

K vyřešení problému byly použity následující balíčky a knihovny:

  1. OpenCV pro práci s obrázky a kreslení čar. Použité předinstalované systémové knihovny a hlavičkové soubory, stejně jako dynamické propojení.

  2. xtensor pro práci s vícerozměrnými poli a tenzory. Použili jsme hlavičkové soubory obsažené ve stejnojmenném balíčku R. Knihovna umožňuje pracovat s vícerozměrnými poli, a to jak v hlavním pořadí, tak v hlavním pořadí sloupců.

  3. ndjson pro analýzu JSON. Tato knihovna se používá v xtensor automaticky, pokud je v projektu přítomen.

  4. RcppThread pro organizaci vícevláknového zpracování vektoru z JSON. Byly použity hlavičkové soubory poskytované tímto balíčkem. Od populárnějších RcppParallel Balíček má mimo jiné vestavěný mechanismus přerušení smyčky.

Je třeba poznamenat, že xtensor se ukázalo jako dar z nebes: kromě toho, že má rozsáhlou funkčnost a vysoký výkon, ukázalo se, že jeho vývojáři jsou docela pohotoví a na otázky odpovídali rychle a podrobně. S jejich pomocí bylo možné implementovat transformace matic OpenCV do xtensorových tenzorů a také způsob, jak spojit 3-rozměrné obrazové tenzory do 4-rozměrného tenzoru správného rozměru (samotná dávka).

Materiály pro výuku 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

Ke kompilaci souborů, které používají systémové soubory a dynamické propojení s knihovnami nainstalovanými v systému, jsme použili mechanismus plugin implementovaný v balíčku Rcpp. K automatickému nalezení cest a příznaků jsme použili populární linuxový nástroj pkg-config.

Implementace pluginu Rcpp pro použití knihovny 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 pluginu budou během procesu kompilace nahrazeny následující 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 pro parsování JSON a generování dávky pro přenos do modelu je uveden pod spoilerem. Nejprve přidejte místní adresář projektu pro vyhledávání souborů záhlaví (potřebné pro ndjson):

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

Implementace převodu 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 měl být umístěn v souboru src/cv_xt.cpp a zkompilujte pomocí příkazu Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); potřeba i pro práci nlohmann/json.hpp z úložiště. Kód je rozdělen do několika funkcí:

  • to_xt — šablonovaná funkce pro transformaci obrazové matice (cv::Mat) na tenzor xt::xtensor;

  • parse_json — funkce analyzuje řetězec JSON, extrahuje souřadnice bodů a sbalí je do vektoru;

  • ocv_draw_lines — z výsledného vektoru bodů kreslí vícebarevné čáry;

  • process — kombinuje výše uvedené funkce a přidává také možnost měnit měřítko výsledného obrázku;

  • cpp_process_json_str - obal přes funkci process, která exportuje výsledek do R-objektu (vícerozměrné pole);

  • cpp_process_json_vector - obal přes funkci cpp_process_json_str, který umožňuje zpracovat řetězcový vektor ve vícevláknovém režimu.

Pro kreslení vícebarevných čar byl použit barevný model HSV s následným převodem do RGB. Otestujeme výsledek:

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

Quick Draw Doodle Recognition: jak se spřátelit s R, C++ a neuronovými sítěmi
Porovnání rychlosti implementací 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: jak se spřátelit s R, C++ a neuronovými sítěmi

Jak vidíte, zvýšení rychlosti se ukázalo jako velmi výrazné a paralelizací R kódu není možné dohnat kód C++.

3. Iterátory pro vykládání dávek z databáze

R má zaslouženou reputaci pro zpracování dat, která se vejdou do RAM, zatímco Python se vyznačuje spíše iterativním zpracováním dat, což umožňuje snadno a přirozeně implementovat výpočty mimo jádro (výpočty pomocí externí paměti). Klasickým a pro nás relevantním příkladem v kontextu popsaného problému jsou hluboké neuronové sítě trénované metodou gradientního sestupu s aproximací gradientu v každém kroku pomocí malé části pozorování neboli minidávky.

Rámce pro hluboké učení napsané v Pythonu mají speciální třídy, které implementují iterátory založené na datech: tabulky, obrázky ve složkách, binární formáty atd. Můžete použít hotové možnosti nebo si napsat vlastní pro konkrétní úkoly. V R můžeme využít všech funkcí knihovny Python keras s různými backendy využívajícími stejnojmenný balíček, který zase funguje nad balíčkem síťovat. Poslední jmenovaný si zaslouží samostatný dlouhý článek; nejenže vám umožňuje spouštět kód Pythonu z R, ale také vám umožňuje přenášet objekty mezi relacemi R a Python a automaticky provádět všechny potřebné převody typů.

Zbavili jsme se nutnosti ukládat všechna data do RAM pomocí MonetDBLite, veškerou práci „neuronové sítě“ provede původní kód v Pythonu, jen musíme přes data napsat iterátor, protože není nic připraveno pro takovou situaci v R nebo Pythonu. Jsou na něj v podstatě jen dva požadavky: musí vracet dávky v nekonečné smyčce a mezi iteracemi ukládat svůj stav (to druhé v R je implementováno nejjednodušším způsobem pomocí uzávěrů). Dříve bylo nutné explicitně převést pole R na numpy pole uvnitř iterátoru, ale aktuální verze balíčku keras dělá to sama.

Iterátor pro trénovací a ověřovací data dopadl takto:

Iterátor pro trénovací a validační data

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

Funkce bere jako vstup proměnnou s připojením k databázi, počty použitých řádků, počet tříd, velikost dávky, měřítko (scale = 1 odpovídá vykreslování obrázků 256x256 pixelů, scale = 0.5 — 128x128 pixelů, barevný indikátor (color = FALSE při použití určuje vykreslování ve stupních šedi color = TRUE každý tah je vykreslen novou barvou) a indikátor předběžného zpracování pro sítě předem natrénované na imagenet. Posledně jmenovaný je potřebný pro škálování hodnot pixelů z intervalu [0, 1] do intervalu [-1, 1], který byl použit při trénování dodaného keras modely.

Externí funkce obsahuje kontrolu typu argumentu, tabulku data.table s náhodně smíšenými čísly řádků z samples_index a čísla dávek, počítadlo a maximální počet dávek a také SQL výraz pro vyjmutí dat z databáze. Navíc jsme definovali rychlou analogii funkce uvnitř keras::to_categorical(). Téměř všechna data jsme použili pro trénink, půl procenta jsme nechali na validaci, takže velikost epochy byla omezena parametrem steps_per_epoch při zavolání keras::fit_generator()a podmínka if (i > max_i) fungovalo pouze pro iterátor ověření.

V interní funkci se načtou indexy řádků pro další dávku, záznamy se uvolní z databáze se zvýšením počítadla dávky, analýza JSON (funkce cpp_process_json_vector(), napsaný v C++) a vytváření polí odpovídajících obrázkům. Poté se vytvoří jednorázové vektory s popisky tříd, pole s hodnotami pixelů a popisky se spojí do seznamu, což je návratová hodnota. Pro urychlení práce jsme využili vytváření indexů v tabulkách data.table a úprava přes odkaz - bez těchto balíčků „čipů“ datová tabulka Je poměrně obtížné si představit efektivní práci s jakýmkoliv významným množstvím dat v R.

Výsledky měření rychlosti na notebooku Core i5 jsou následující:

Benchmark iterátoru

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: jak se spřátelit s R, C++ a neuronovými sítěmi

Pokud máte dostatečné množství paměti RAM, můžete vážně urychlit provoz databáze jejím přenesením do stejné paměti RAM (32 GB je pro náš úkol dostačující). V Linuxu je oddíl standardně připojen /dev/shm, zabírající až polovinu kapacity RAM. Úpravou můžete zvýraznit více /etc/fstabzískat záznam jako tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Nezapomeňte restartovat a zkontrolovat výsledek spuštěním příkazu df -h.

Iterátor testovacích dat vypadá mnohem jednodušeji, protože testovací datová sada se zcela vejde do paměti RAM:

Iterátor pro testovací data

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ýběr architektury modelu

První použitá architektura byla mobilní síť v1, jehož vlastnosti jsou diskutovány v tohle zpráva. Je součástí standardu keras a je tedy k dispozici ve stejnojmenném balíčku pro R. Ale při pokusu o použití s ​​jednokanálovými obrázky se ukázala zvláštní věc: vstupní tenzor musí mít vždy rozměr (batch, height, width, 3), to znamená, že počet kanálů nelze změnit. V Pythonu žádné takové omezení není, a tak jsme si pospíchali a napsali vlastní implementaci této architektury podle původního článku (bez výpadku, který je ve verzi keras):

Architektura 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 tohoto přístupu jsou zřejmé. Chci otestovat spoustu modelů, ale naopak nechci každou architekturu přepisovat ručně. Byli jsme také ochuzeni o možnost využít závaží modelů předtrénovaných na imagenetu. Jako obvykle pomohlo prostudování dokumentace. Funkce get_config() umožňuje získat popis modelu ve formě vhodné pro úpravy (base_model_conf$layers - běžný seznam R) a funkce from_config() provede zpětnou konverzi 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)

Nyní není těžké napsat univerzální funkci pro získání některé z dodaných keras modely se závažím nebo bez něj cvičené na imagenet:

Funkce pro načítání hotových architektur

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

Při použití jednokanálových obrázků se nepoužívají žádné předem připravené závaží. To by se dalo opravit: pomocí funkce get_weights() získejte váhy modelu ve formě seznamu polí R, změňte rozměr prvního prvku tohoto seznamu (používáním jednoho barevného kanálu nebo zprůměrováním všech tří) a poté načtěte váhy zpět do modelu pomocí funkce set_weights(). Tuto funkci jsme nikdy nepřidali, protože v této fázi již bylo jasné, že je produktivnější pracovat s barevnými obrázky.

Většinu experimentů jsme provedli pomocí mobilenet verze 1 a 2 a také resnet34. Modernější architektury jako SE-ResNeXt si v této soutěži vedly dobře. Bohužel jsme neměli k dispozici hotové implementace a nenapsali jsme vlastní (ale určitě napíšeme).

5. Parametrizace skriptů

Pro pohodlí byl veškerý kód pro zahájení školení navržen jako jediný skript, parametrizovaný pomocí 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 představuje realizaci http://docopt.org/ for R. S jeho pomocí se spouštějí skripty jednoduchými příkazy jako Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db nebo ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, pokud soubor train_nn.R je spustitelný (tento příkaz spustí trénování modelu resnet50 na tříbarevných obrázcích o rozměrech 128x128 pixelů musí být databáze umístěna ve složce /home/andrey/doodle_db). Do seznamu můžete přidat rychlost učení, typ optimalizátoru a jakékoli další přizpůsobitelné parametry. V procesu přípravy publikace se ukázalo, že architektura mobilenet_v2 z aktuální verze keras v R použití nesmí kvůli změnám nezohledněným v balíčku R čekáme, až to opraví.

Tento přístup umožnil výrazně urychlit experimenty s různými modely ve srovnání s tradičnějším spouštěním skriptů v RStudio (balíček bereme jako možnou alternativu tfruns). Ale hlavní výhodou je možnost snadno spravovat spouštění skriptů v Dockeru nebo jednoduše na serveru, aniž byste kvůli tomu instalovali RStudio.

6. Dockerizace skriptů

Docker jsme použili k zajištění přenositelnosti prostředí pro trénink modelů mezi členy týmu a pro rychlé nasazení v cloudu. S tímto pro R programátora poměrně neobvyklým nástrojem se můžete začít seznamovat tohle série publikací popř video kurz.

Docker vám umožňuje vytvářet vlastní obrázky od začátku a používat jiné obrázky jako základ pro vytváření vlastních obrázků. Při analýze dostupných možností jsme došli k závěru, že instalace ovladačů NVIDIA, CUDA+cuDNN a knihoven Pythonu je poměrně objemná část obrazu a rozhodli jsme se vzít jako základ oficiální obraz tensorflow/tensorflow:1.12.0-gpu, přidáním potřebných R balíčků tam.

Výsledný soubor dockeru vypadal 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

Pro usnadnění byly použité balíčky vloženy do proměnných; většina psaných skriptů je zkopírována do kontejnerů během montáže. Změnili jsme také příkazový shell na /bin/bash pro snadné použití obsahu /etc/os-release. Tím se zabránilo nutnosti specifikovat verzi OS v kódu.

Navíc byl napsán malý bash skript, který umožňuje spouštět kontejner s různými příkazy. Mohou to být například skripty pro trénování neuronových sítí, které byly dříve umístěny uvnitř kontejneru, nebo příkazový shell pro ladění a monitorování provozu kontejneru:

Skript pro spuštění kontejneru

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

Pokud je tento bash skript spuštěn bez parametrů, bude skript volán uvnitř kontejneru train_nn.R s výchozími hodnotami; pokud je prvním pozičním argumentem "bash", pak se kontejner spustí interaktivně s příkazovým shellem. Ve všech ostatních případech jsou nahrazeny hodnoty pozičních argumentů: CMD="Rscript /app/train_nn.R $@".

Za zmínku stojí, že adresáře se zdrojovými daty a databází, stejně jako adresář pro ukládání trénovaných modelů, jsou připojeny do kontejneru z hostitelského systému, což umožňuje přístup k výsledkům skriptů bez zbytečných manipulací.

7. Používání více GPU na Google Cloud

Jedním z rysů soutěže byla velmi hlučná data (viz titulní obrázek, vypůjčeno od @Leigh.plt z ODS slack). Velké dávky tomu pomáhají bojovat a po experimentech na PC s 1 GPU jsme se rozhodli zvládnout tréninkové modely na několika GPU v cloudu. Použitý GoogleCloud (dobrý průvodce základy) díky velkému výběru dostupných konfigurací, rozumným cenám a bonusu 300 USD. Z chamtivosti jsem si objednal instanci 4xV100 s SSD a tunou RAM a to byla velká chyba. Takový stroj rychle žere peníze, bez osvědčeného potrubí můžete experimentovat na mizině. Pro vzdělávací účely je lepší vzít K80. Velké množství paměti RAM ale přišlo vhod – cloudové SSD svým výkonem nezaujalo, a tak se databáze přenesla na dev/shm.

Největší zájem je o fragment kódu zodpovědný za použití více GPU. Nejprve je model vytvořen na CPU pomocí kontextového manažera, stejně jako 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
  )
})

Poté je nezkompilovaný (to je důležitý) model zkopírován do daného počtu dostupných GPU a teprve poté je zkompilován:

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

Klasickou techniku ​​zmrazení všech vrstev kromě poslední, natrénování poslední vrstvy, rozmrazení a přetrénování celého modelu pro několik GPU nebylo možné implementovat.

Školení bylo sledováno bez použití. tensorboard, omezující se na zaznamenávání protokolů a ukládání modelů s informativními názvy po každé epoše:

Zpětná volání

# Шаблон имени файла лога
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. Místo závěru

Řada problémů, se kterými jsme se setkali, nebyla dosud vyřešena:

  • в keras neexistuje žádná připravená funkce pro automatické vyhledávání optimální rychlosti učení (analog lr_finder v knihovně rychle.ai); S určitým úsilím je možné přenést implementace třetích stran do R, např. tento;
  • v důsledku předchozího bodu nebylo možné zvolit správnou rychlost tréninku při použití několika GPU;
  • je nedostatek moderních architektur neuronových sítí, zejména těch, které jsou předem připraveny na imagenet;
  • politika žádného cyklu a diskriminační míry učení (kosinové žíhání bylo na naši žádost implementováno, dík skeydan).

Co užitečného jsme se díky této soutěži naučili:

  • Na hardwaru s relativně nízkou spotřebou můžete bez bolesti pracovat se slušnými (mnohonásobně většími RAM) objemy dat. Igelitová taška datová tabulka šetří paměť díky úpravě tabulek na místě, která zamezuje jejich kopírování, a při správném použití jeho schopnosti téměř vždy vykazují nejvyšší rychlost ze všech nástrojů známých pro skriptovací jazyky. Ukládání dat do databáze umožňuje v mnoha případech vůbec nemyslet na nutnost vtěsnat celý datový soubor do RAM.
  • Pomalé funkce v R lze pomocí balíčku nahradit rychlými v C++ Rcpp. Pokud navíc k použití RcppThread nebo RcppParallel, získáme multiplatformní implementace s více vlákny, takže není potřeba paralelizovat kód na úrovni R.
  • balík Rcpp lze použít bez vážných znalostí C++, je uvedeno požadované minimum zde. Soubory záhlaví pro řadu skvělých knihoven C, jako je xtensor k dispozici na CRAN, to znamená, že se vytváří infrastruktura pro implementaci projektů, které integrují hotový vysoce výkonný kód C++ do R. Další výhodou je zvýraznění syntaxe a statický analyzátor kódu C++ v RStudio.
  • docpt umožňuje spouštět samostatné skripty s parametry. To je vhodné pro použití na vzdáleném serveru, vč. pod dockerem. V RStudio je nepohodlné provádět mnoho hodin experimentů s trénováním neuronových sítí a instalace IDE na samotný server není vždy oprávněná.
  • Docker zajišťuje přenositelnost kódu a reprodukovatelnost výsledků mezi vývojáři s různými verzemi operačního systému a knihoven, stejně jako snadné spouštění na serverech. Celý tréninkový kanál můžete spustit jediným příkazem.
  • Google Cloud je cenově dostupný způsob, jak experimentovat s drahým hardwarem, ale musíte pečlivě vybírat konfigurace.
  • Měření rychlosti jednotlivých fragmentů kódu je velmi užitečné zejména při kombinaci R a C++ a s balíčkem lavice - také velmi snadné.

Celkově byla tato zkušenost velmi obohacující a nadále pracujeme na vyřešení některých vznesených problémů.

Zdroj: www.habr.com

Přidat komentář