Quick Draw Doodle Recognition: hoe je vrienden kunt maken met R, C++ en neurale netwerken

Quick Draw Doodle Recognition: hoe je vrienden kunt maken met R, C++ en neurale netwerken

Hé Habr!

Afgelopen najaar organiseerde Kaggle een wedstrijd om handgetekende afbeeldingen te classificeren, Quick Draw Doodle Recognition, waaraan onder meer een team van R-wetenschappers deelnam: Artem Klevtsova, Filippa Manager и Andrej Ogurtsov. We zullen de competitie niet in detail beschrijven; dat is al gedaan in recente publicatie.

Deze keer lukte het niet met medaillelandbouw, maar er is veel waardevolle ervaring opgedaan, dus ik zou de gemeenschap graag willen vertellen over een aantal van de meest interessante en nuttige dingen op Kagle en in het dagelijks werk. Onder de besproken onderwerpen: moeilijk leven zonder OpenCV, JSON-parsing (deze voorbeelden onderzoeken de integratie van C++-code in scripts of pakketten in R met behulp van Rcpp), parametrisering van scripts en dockerisatie van de uiteindelijke oplossing. Alle code uit het bericht is beschikbaar in een vorm die geschikt is voor uitvoering opslagplaatsen.

Inhoud:

  1. Laad gegevens efficiënt vanuit CSV in MonetDB
  2. Partijen voorbereiden
  3. Iterators voor het ontladen van batches uit de database
  4. Een modelarchitectuur selecteren
  5. Parametrering van scripts
  6. Dockerisatie van scripts
  7. Meerdere GPU's gebruiken in Google Cloud
  8. In plaats Output

1. Laad gegevens uit CSV efficiënt in de MonetDB-database

De gegevens in deze wedstrijd worden niet aangeleverd in de vorm van kant-en-klare afbeeldingen, maar in de vorm van 340 CSV-bestanden (één bestand voor elke klasse) met daarin JSON's met puntcoördinaten. Door deze punten met lijnen te verbinden, krijgen we een uiteindelijk beeld van 256x256 pixels. Ook is er voor elk record een label dat aangeeft of de afbeelding correct werd herkend door de classificatie die werd gebruikt op het moment dat de dataset werd verzameld, een tweelettercode van het land waar de auteur van de afbeelding woont, een unieke identificatie, een tijdstempel en een klassenaam die overeenkomt met de bestandsnaam. Een vereenvoudigde versie van de originele gegevens weegt 7.4 GB in het archief en ongeveer 20 GB na het uitpakken, de volledige gegevens na het uitpakken nemen 240 GB in beslag. De organisatoren zorgden ervoor dat beide versies dezelfde tekeningen reproduceerden, waardoor de volledige versie overbodig was. Hoe dan ook, het opslaan van 50 miljoen afbeeldingen in grafische bestanden of in de vorm van arrays werd onmiddellijk als onrendabel beschouwd en we besloten alle CSV-bestanden uit het archief samen te voegen train_simplified.zip in de database, waarna voor elke batch “on the fly” afbeeldingen van de vereiste grootte worden gegenereerd.

Er werd gekozen voor een beproefd systeem: het DBMS MonetDB, namelijk een implementatie voor R als pakket MonetDBLite. Het pakket bevat een ingebouwde versie van de databaseserver en stelt u in staat de server rechtstreeks uit een R-sessie op te halen en daar te werken. Het aanmaken van een database en het verbinden ervan gebeurt met één commando:

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

We zullen twee tabellen moeten maken: één voor alle gegevens, de andere voor service-informatie over gedownloade bestanden (handig als er iets misgaat en het proces moet worden hervat na het downloaden van meerdere bestanden):

Tabellen maken

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

De snelste manier om gegevens in de database te laden was door CSV-bestanden rechtstreeks te kopiëren met behulp van de SQL-opdracht COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTWaar tablename - tabelnaam en path - het pad naar het bestand. Tijdens het werken met het archief werd ontdekt dat de ingebouwde implementatie unzip in R werkt niet correct met een aantal bestanden uit het archief, daarom hebben we het systeem gebruikt unzip (met behulp van de parameter getOption("unzip")).

Functie voor het schrijven naar de database

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

Als u de tabel moet transformeren voordat u deze naar de database schrijft, volstaat het om het argument door te geven preprocess functie die de gegevens zal transformeren.

Code voor het sequentieel laden van gegevens in de database:

Gegevens naar de database schrijven

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

De laadtijd van gegevens kan variëren, afhankelijk van de snelheidskenmerken van de gebruikte schijf. In ons geval duurt het lezen en schrijven binnen één SSD of vanaf een flashdrive (bronbestand) naar een SSD (DB) nog geen 10 minuten.

Het duurt nog een paar seconden om een ​​kolom te maken met een integer-klasselabel en een indexkolom (ORDERED INDEX) met regelnummers waarmee waarnemingen worden bemonsterd bij het maken van batches:

Extra kolommen en index maken

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

Om het probleem van het direct aanmaken van een batch op te lossen, moesten we de maximale snelheid bereiken bij het extraheren van willekeurige rijen uit de tabel doodles. Hiervoor gebruikten we 3 trucs. De eerste was het verminderen van de dimensionaliteit van het type dat de observatie-ID opslaat. In de oorspronkelijke dataset is het type vereist om de ID op te slaan bigint, maar het aantal waarnemingen maakt het mogelijk om hun identificatiegegevens, gelijk aan het rangtelwoord, in het type te passen int. In dit geval gaat het zoeken veel sneller. De tweede truc was om te gebruiken ORDERED INDEX – we zijn empirisch tot deze beslissing gekomen, nadat we alle beschikbare informatie hadden doorgenomen opties. De derde was het gebruik van geparametriseerde queries. De essentie van de methode is om de opdracht één keer uit te voeren PREPARE met daaropvolgend gebruik van een voorbereide expressie bij het maken van een aantal query's van hetzelfde type, maar in feite is er een voordeel in vergelijking met een eenvoudige SELECT bleek binnen het bereik van de statistische fout te liggen.

Het proces van het uploaden van gegevens verbruikt niet meer dan 450 MB RAM. Dat wil zeggen dat de beschreven aanpak je in staat stelt datasets van tientallen gigabytes te verplaatsen op vrijwel elke budgethardware, inclusief enkele single-board-apparaten, wat best gaaf is.

Het enige dat overblijft is het meten van de snelheid van het ophalen van (willekeurige) gegevens en het evalueren van de schaalvergroting bij het bemonsteren van batches van verschillende groottes:

Databasebenchmark

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: hoe je vrienden kunt maken met R, C++ en neurale netwerken

2. Partijen voorbereiden

Het gehele batchvoorbereidingsproces bestaat uit de volgende stappen:

  1. Het parseren van verschillende JSON's die vectoren van tekenreeksen met coördinaten van punten bevatten.
  2. Gekleurde lijnen tekenen op basis van de coördinaten van punten op een afbeelding van de gewenste grootte (bijvoorbeeld 256×256 of 128×128).
  3. Het omzetten van de resulterende afbeeldingen in een tensor.

Als onderdeel van de concurrentie tussen Python-kernels werd het probleem voornamelijk opgelost met behulp van OpenCV. Een van de eenvoudigste en meest voor de hand liggende analogen in R zou er als volgt uitzien:

Implementatie van JSON naar Tensor-conversie in 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)
}

Het tekenen wordt uitgevoerd met behulp van standaard R-tools en opgeslagen in een tijdelijke PNG die is opgeslagen in RAM (op Linux bevinden tijdelijke R-mappen zich in de map /tmp, gemonteerd in RAM). Dit bestand wordt vervolgens gelezen als een driedimensionale array met getallen variërend van 0 tot 1. Dit is belangrijk omdat een meer conventionele BMP zou worden ingelezen in een onbewerkte array met hexadecimale kleurcodes.

Laten we het resultaat testen:

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: hoe je vrienden kunt maken met R, C++ en neurale netwerken

De batch zelf zal als volgt worden gevormd:

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

Deze implementatie leek ons ​​niet optimaal, omdat het vormen van grote batches onfatsoenlijk lang duurt, en we besloten te profiteren van de ervaring van onze collega's door een krachtige bibliotheek te gebruiken OpenCV. Destijds was er nog geen kant-en-klaar pakket voor R (nu is er geen), dus werd een minimale implementatie van de vereiste functionaliteit geschreven in C++ met integratie in R-code met behulp van Rcpp.

Om het probleem op te lossen zijn de volgende pakketten en bibliotheken gebruikt:

  1. OpenCV voor het werken met afbeeldingen en het tekenen van lijnen. Gebruikte vooraf geïnstalleerde systeembibliotheken en headerbestanden, evenals dynamische koppelingen.

  2. xtensor voor het werken met multidimensionale arrays en tensoren. We gebruikten headerbestanden uit het gelijknamige R-pakket. Met de bibliotheek kunt u werken met multidimensionale arrays, zowel in rij- als kolomhoofdvolgorde.

  3. ndjzoon voor het parseren van JSON. Deze bibliotheek wordt gebruikt in xtensor automatisch als deze aanwezig is in het project.

  4. RcppThread voor het organiseren van multi-threaded verwerking van een vector uit JSON. Gebruikt de headerbestanden die door dit pakket worden geleverd. Van populairder RcppParallel Het pakket heeft onder meer een ingebouwd lusonderbrekingsmechanisme.

Opgemerkt dat xtensor bleek een uitkomst: naast het feit dat het uitgebreide functionaliteit en hoge prestaties heeft, bleken de ontwikkelaars behoorlijk responsief te zijn en vragen snel en gedetailleerd te beantwoorden. Met hun hulp was het mogelijk om transformaties van OpenCV-matrices in xtensor-tensoren te implementeren, evenals een manier om 3-dimensionale beeldtensoren te combineren tot een 4-dimensionale tensor van de juiste dimensie (de batch zelf).

Materialen voor het leren van Rcpp, xtensor en 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

Om bestanden te compileren die gebruik maken van systeembestanden en dynamische koppelingen met bibliotheken die op het systeem zijn geïnstalleerd, hebben we het plug-inmechanisme gebruikt dat in het pakket is geïmplementeerd Rcpp. Om automatisch paden en vlaggen te vinden, hebben we een populair Linux-hulpprogramma gebruikt pkg-config.

Implementatie van de Rcpp plugin voor het gebruik van de OpenCV bibliotheek

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

Als gevolg van de werking van de plug-in worden de volgende waarden vervangen tijdens het compilatieproces:

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"

De implementatiecode voor het parseren van JSON en het genereren van een batch voor verzending naar het model vindt u onder de spoiler. Voeg eerst een lokale projectmap toe om naar headerbestanden te zoeken (nodig voor ndjson):

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

Implementatie van JSON naar tensorconversie in 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;
}

Deze code moet in het bestand worden geplaatst src/cv_xt.cpp en compileer met het commando Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); ook nodig voor werk nlohmann/json.hpp van opslagplaats. De code is verdeeld in verschillende functies:

  • to_xt — een sjabloonfunctie voor het transformeren van een beeldmatrix (cv::Mat) naar een tensor xt::xtensor;

  • parse_json — de functie ontleedt een JSON-string, extraheert de coördinaten van punten en verpakt ze in een vector;

  • ocv_draw_lines — tekent uit de resulterende vector van punten veelkleurige lijnen;

  • process — combineert de bovenstaande functies en voegt ook de mogelijkheid toe om de resulterende afbeelding te schalen;

  • cpp_process_json_str - wrapper over de functie process, dat het resultaat exporteert naar een R-object (multidimensionale array);

  • cpp_process_json_vector - wrapper over de functie cpp_process_json_str, waarmee u een stringvector in multi-threaded modus kunt verwerken.

Om meerkleurige lijnen te tekenen, werd het HSV-kleurenmodel gebruikt, gevolgd door conversie naar RGB. Laten we het resultaat testen:

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

Quick Draw Doodle Recognition: hoe je vrienden kunt maken met R, C++ en neurale netwerken
Vergelijking van de snelheid van implementaties in R en 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: hoe je vrienden kunt maken met R, C++ en neurale netwerken

Zoals je kunt zien, bleek de snelheidstoename erg aanzienlijk, en het is niet mogelijk om C++-code in te halen door R-code te parallelliseren.

3. Iterators voor het ontladen van batches uit de database

R heeft een welverdiende reputatie voor het verwerken van gegevens die in RAM passen, terwijl Python meer wordt gekenmerkt door iteratieve gegevensverwerking, waardoor u eenvoudig en op natuurlijke wijze out-of-core berekeningen kunt implementeren (berekeningen met extern geheugen). Een klassiek en relevant voorbeeld voor ons in de context van het beschreven probleem zijn diepe neurale netwerken die zijn getraind door de gradiënt-afdalingsmethode, waarbij de gradiënt bij elke stap wordt benaderd met behulp van een klein deel van de waarnemingen, of mini-batch.

Deep learning-frameworks geschreven in Python hebben speciale klassen die iterators implementeren op basis van gegevens: tabellen, afbeeldingen in mappen, binaire formaten, enz. U kunt kant-en-klare opties gebruiken of uw eigen opties schrijven voor specifieke taken. In R kunnen we profiteren van alle functies van de Python-bibliotheek Keras met zijn verschillende backends die gebruik maken van het pakket met dezelfde naam, dat op zijn beurt bovenop het pakket werkt reticulair. Dit laatste verdient een apart lang artikel; Hiermee kunt u niet alleen Python-code vanuit R uitvoeren, maar kunt u ook objecten overbrengen tussen R- en Python-sessies, waarbij automatisch alle noodzakelijke typeconversies worden uitgevoerd.

We hebben de noodzaak om alle gegevens in RAM op te slaan overbodig gemaakt door MonetDBLite te gebruiken, al het werk van het "neurale netwerk" zal worden uitgevoerd door de originele code in Python, we hoeven alleen maar een iterator over de gegevens te schrijven, aangezien er niets klaar is voor een dergelijke situatie in R of Python. Er zijn in wezen slechts twee vereisten voor: het moet batches in een eindeloze lus retourneren en de status ervan tussen iteraties opslaan (dit laatste in R wordt op de eenvoudigste manier geïmplementeerd met behulp van sluitingen). Voorheen was het vereist om R-arrays expliciet om te zetten in numpy-arrays binnen de iterator, maar de huidige versie van het pakket Keras doet het zelf.

De iterator voor trainings- en validatiegegevens bleek als volgt:

Iterator voor trainings- en validatiegegevens

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

De functie neemt als invoer een variabele met een verbinding met de database, het aantal gebruikte regels, het aantal klassen, batchgrootte, schaal (scale = 1 komt overeen met het weergeven van afbeeldingen van 256x256 pixels, scale = 0.5 — 128x128 pixels), kleurindicator (color = FALSE specificeert weergave in grijstinten indien gebruikt color = TRUE elke streek wordt in een nieuwe kleur getekend) en een voorverwerkingsindicator voor netwerken die vooraf zijn getraind op imagenet. Dit laatste is nodig om pixelwaarden te schalen van het interval [0, 1] naar het interval [-1, 1], dat werd gebruikt bij het trainen van de aangeleverde Keras modellen.

De externe functie bevat controle op het argumenttype, een tabel data.table met willekeurig gemengde regelnummers uit samples_index en batchnummers, teller en maximaal aantal batches, evenals een SQL-expressie voor het ontladen van gegevens uit de database. Daarnaast hebben we een snelle analoog van de functie binnenin gedefinieerd keras::to_categorical(). We gebruikten bijna alle gegevens voor training en lieten een half procent over voor validatie, dus de grootte van het tijdperk werd beperkt door de parameter steps_per_epoch wanneer gebeld keras::fit_generator(), en de toestand if (i > max_i) werkte alleen voor de validatie-iterator.

In de interne functie worden rijindexen opgehaald voor de volgende batch, worden records uit de database verwijderd terwijl de batchteller toeneemt, JSON-parsing (functie cpp_process_json_vector(), geschreven in C++) en het maken van arrays die overeenkomen met afbeeldingen. Vervolgens worden one-hot vectoren met klassenlabels gemaakt, arrays met pixelwaarden en labels worden gecombineerd tot een lijst, wat de retourwaarde is. Om het werk te versnellen, hebben we indexen in tabellen gemaakt data.table en wijziging via de link - zonder deze pakket “chips” data tafel Het is vrij moeilijk voor te stellen dat je effectief zou kunnen werken met een aanzienlijke hoeveelheid gegevens in R.

De resultaten van snelheidsmetingen op een Core i5 laptop zijn als volgt:

Iterator-benchmark

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

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

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

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

# Индексы для обучающей выборки
train_ind <- sample(ind, floor(length(ind) * 0.995))
# Индексы для проверочной выборки
val_ind <- ind[-train_ind]
rm(ind)
# Коэффициент масштаба
scale <- 0.5

# Проведение замера
res_bench <- bench::press(
  batch_size = 2^(4:10),
  {
    it1 <- train_generator(
      db_connection = con,
      samples_index = train_ind,
      num_classes = num_classes,
      batch_size = batch_size,
      scale = scale
    )
    bench::mark(
      it1(),
      min_iterations = 50L
    )
  }
)
# Параметры бенчмарка
cols <- c("batch_size", "min", "median", "max", "itr/sec", "total_time", "n_itr")
res_bench[, cols]

#   batch_size      min   median      max `itr/sec` total_time n_itr
#        <dbl> <bch:tm> <bch:tm> <bch:tm>     <dbl>   <bch:tm> <int>
# 1         16     25ms  64.36ms   92.2ms     15.9       3.09s    49
# 2         32   48.4ms 118.13ms 197.24ms     8.17       5.88s    48
# 3         64   69.3ms 117.93ms 181.14ms     8.57       5.83s    50
# 4        128  157.2ms 240.74ms 503.87ms     3.85      12.71s    49
# 5        256  359.3ms 613.52ms 988.73ms     1.54       30.5s    47
# 6        512  884.7ms    1.53s    2.07s     0.674      1.11m    45
# 7       1024     2.7s    3.83s    5.47s     0.261      2.81m    44

ggplot(res_bench, aes(x = factor(batch_size), y = median, group = 1)) +
    geom_point() +
    geom_line() +
    ylab("median time, s") +
    theme_minimal()

DBI::dbDisconnect(con, shutdown = TRUE)

Quick Draw Doodle Recognition: hoe je vrienden kunt maken met R, C++ en neurale netwerken

Als u over voldoende RAM beschikt, kunt u de werking van de database aanzienlijk versnellen door deze naar hetzelfde RAM over te brengen (32 GB is voldoende voor onze taak). In Linux is de partitie standaard aangekoppeld /dev/shm, die tot de helft van de RAM-capaciteit in beslag neemt. U kunt meer markeren door te bewerken /etc/fstabom een ​​record als tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Zorg ervoor dat u opnieuw opstart en controleer het resultaat door de opdracht uit te voeren df -h.

De iterator voor testgegevens ziet er veel eenvoudiger uit, omdat de testgegevensset volledig in RAM past:

Iterator voor testgegevens

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. Selectie van modelarchitectuur

De eerste gebruikte architectuur was mobielnet v1, waarvan de kenmerken worden besproken in deze bericht. Het wordt standaard meegeleverd Keras en is dienovereenkomstig beschikbaar in het gelijknamige pakket voor R. Maar toen ik het probeerde te gebruiken met afbeeldingen met één kanaal, bleek er iets vreemds te zijn: de invoertensor moet altijd de afmeting hebben (batch, height, width, 3), dat wil zeggen dat het aantal kanalen niet kan worden gewijzigd. Een dergelijke beperking bestaat niet in Python, dus haastten we ons en schreven we onze eigen implementatie van deze architectuur, volgens het originele artikel (zonder de uitval die in de Keras-versie zit):

Mobilenet v1-architectuur

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

De nadelen van deze aanpak liggen voor de hand. Ik wil veel modellen testen, maar integendeel, ik wil niet elke architectuur handmatig herschrijven. We kregen ook niet de mogelijkheid om de gewichten te gebruiken van modellen die vooraf waren getraind op Imagenet. Zoals gewoonlijk hielp het bestuderen van de documentatie. Functie get_config() Hiermee kunt u een beschrijving van het model krijgen in een vorm die geschikt is voor bewerking (base_model_conf$layers - een gewone R-lijst), en de functie from_config() voert de omgekeerde conversie naar een modelobject uit:

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)

Nu is het niet moeilijk om een ​​universele functie te schrijven om een ​​van de aangeboden functies te verkrijgen Keras modellen met of zonder gewichten getraind op imagenet:

Functie voor het laden van kant-en-klare architecturen

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

Bij gebruik van enkelkanaalsbeelden worden geen vooraf getrainde gewichten gebruikt. Dit kan worden opgelost: gebruik de functie get_weights() verkrijg de modelgewichten in de vorm van een lijst met R-arrays, wijzig de dimensie van het eerste element van deze lijst (door één kleurkanaal te nemen of alle drie te middelen) en laad vervolgens de gewichten terug in het model met de functie set_weights(). We hebben deze functionaliteit nooit toegevoegd, omdat het in dit stadium al duidelijk was dat het productiever was om met kleurenfoto's te werken.

We hebben de meeste experimenten uitgevoerd met mobilenet-versies 1 en 2, en met resnet34. Modernere architecturen zoals SE-ResNeXt presteerden goed in deze competitie. Helaas hadden we geen kant-en-klare implementaties tot onze beschikking, en we hebben niet onze eigen implementaties geschreven (maar we zullen zeker schrijven).

5. Parametrering van scripts

Voor het gemak is alle code voor het starten van de training ontworpen als één enkel script, geparametriseerd met behulp van docopte следующим обрахом:

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)

Verpakking docopte vertegenwoordigt de uitvoering http://docopt.org/ voor R. Met zijn hulp worden scripts gestart met eenvoudige opdrachten zoals Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db of ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, indien bestand train_nn.R is uitvoerbaar (met deze opdracht wordt het model getraind resnet50 op driekleurenafbeeldingen van 128x128 pixels moet de database zich in de map bevinden /home/andrey/doodle_db). U kunt leersnelheid, optimalisatietype en andere aanpasbare parameters aan de lijst toevoegen. Tijdens het voorbereiden van de publicatie bleek dat de architectuur mobilenet_v2 van de huidige versie Keras bij R-gebruik niet mogen vanwege wijzigingen waarmee geen rekening is gehouden in het R-pakket, wachten we tot ze het probleem oplossen.

Deze aanpak maakte het mogelijk om experimenten met verschillende modellen aanzienlijk te versnellen in vergelijking met de meer traditionele lancering van scripts in RStudio (we beschouwen het pakket als een mogelijk alternatief tfruns). Maar het belangrijkste voordeel is de mogelijkheid om de lancering van scripts eenvoudig in Docker of gewoon op de server te beheren, zonder hiervoor RStudio te installeren.

6. Dockerisatie van scripts

We hebben Docker gebruikt om de draagbaarheid van de omgeving te garanderen voor trainingsmodellen tussen teamleden en voor snelle implementatie in de cloud. Je kunt beginnen met het leren kennen van deze tool, wat relatief ongebruikelijk is voor een R-programmeur dit reeks publicaties of video cursus.

Met Docker kunt u zowel uw eigen afbeeldingen vanaf het begin maken als andere afbeeldingen gebruiken als basis voor het maken van uw eigen afbeeldingen. Bij het analyseren van de beschikbare opties kwamen we tot de conclusie dat het installeren van NVIDIA-, CUDA+cuDNN-stuurprogramma's en Python-bibliotheken een tamelijk omvangrijk onderdeel van de image is, en we besloten de officiële image als basis te nemen tensorflow/tensorflow:1.12.0-gpu, door daar de benodigde R-pakketten toe te voegen.

Het uiteindelijke dockerbestand zag er als volgt uit:

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

Voor het gemak zijn de gebruikte pakketten in variabelen geplaatst; het grootste deel van de geschreven scripts wordt tijdens de montage in de containers gekopieerd. We hebben ook de opdrachtshell gewijzigd in /bin/bash voor gebruiksgemak van de inhoud /etc/os-release. Hierdoor werd de noodzaak vermeden om de versie van het besturingssysteem in de code op te geven.

Daarnaast is er een klein bash-script geschreven waarmee je een container met verschillende opdrachten kunt starten. Dit kunnen bijvoorbeeld scripts zijn voor het trainen van neurale netwerken die eerder in de container zijn geplaatst, of een opdrachtshell voor het debuggen en monitoren van de werking van de container:

Script om de container te starten

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

Als dit bash-script zonder parameters wordt uitgevoerd, wordt het script binnen de container aangeroepen train_nn.R met standaardwaarden; als het eerste positionele argument "bash" is, zal de container interactief starten met een opdrachtshell. In alle andere gevallen worden de waarden van positionele argumenten vervangen: CMD="Rscript /app/train_nn.R $@".

Het is vermeldenswaard dat de mappen met brongegevens en database, evenals de map voor het opslaan van getrainde modellen, in de container van het hostsysteem zijn gemonteerd, waardoor u zonder onnodige manipulaties toegang hebt tot de resultaten van de scripts.

7. Meerdere GPU's gebruiken op Google Cloud

Een van de kenmerken van de competitie waren de zeer luidruchtige gegevens (zie de titelafbeelding, geleend van @Leigh.plt van ODS Slack). Grote batches helpen dit tegen te gaan en na experimenten op een pc met 1 GPU hebben we besloten om trainingsmodellen op meerdere GPU's in de cloud onder de knie te krijgen. Gebruikte GoogleCloud (goede handleiding voor de basis) vanwege de grote keuze aan beschikbare configuraties, redelijke prijzen en een bonus van $ 300. Uit hebzucht bestelde ik een 4xV100-exemplaar met een SSD en heel veel RAM, en dat was een grote vergissing. Zo'n machine vreet snel geld op; je kunt failliet gaan door te experimenteren zonder een bewezen pijplijn. Voor educatieve doeleinden kun je beter de K80 nemen. Maar de grote hoeveelheid RAM kwam goed van pas: de cloud-SSD maakte geen indruk met zijn prestaties, dus werd de database overgebracht dev/shm.

Van het grootste belang is het codefragment dat verantwoordelijk is voor het gebruik van meerdere GPU's. Eerst wordt het model op de CPU gemaakt met behulp van een contextmanager, net als in Python:

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

Vervolgens wordt het niet-gecompileerde (dit is belangrijke) model gekopieerd naar een bepaald aantal beschikbare GPU's, en pas daarna wordt het gecompileerd:

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

De klassieke techniek van het bevriezen van alle lagen behalve de laatste, het trainen van de laatste laag, het ontdooien en opnieuw trainen van het hele model voor meerdere GPU's kon niet worden geïmplementeerd.

De training werd zonder gebruik gevolgd. tensorbord, waarbij we ons beperken tot het vastleggen van logboeken en het opslaan van modellen met informatieve namen na elk tijdperk:

Terugbelgesprekken

# Шаблон имени файла лога
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. In plaats van een conclusie

Een aantal problemen die wij zijn tegengekomen zijn nog niet overwonnen:

  • в Keras er bestaat geen kant-en-klare functie voor het automatisch zoeken naar het optimale leertempo (analoog lr_finder In de bibliotheek snel.ai); Met enige moeite is het mogelijk om implementaties van derden naar R te porten, bijvoorbeeld deze;
  • als gevolg van het vorige punt was het niet mogelijk om de juiste trainingssnelheid te selecteren bij gebruik van meerdere GPU's;
  • er is een gebrek aan moderne neurale netwerkarchitecturen, vooral die die vooraf zijn getraind op imagenet;
  • geen ééncyclusbeleid en discriminerende leerpercentages (cosinus-gloeien was op ons verzoek geïmplementeerd, Met dank skeydan).

Welke nuttige dingen zijn er uit deze wedstrijd geleerd:

  • Op relatief energiezuinige hardware kun je zonder problemen met behoorlijke (vele malen groter dan RAM) gegevensvolumes werken. Plastieken zak data tafel bespaart geheugen door het ter plekke aanpassen van tabellen, waardoor het kopiëren ervan wordt vermeden, en bij correct gebruik demonstreren de mogelijkheden bijna altijd de hoogste snelheid van alle tools die ons bekend zijn voor scripttalen. Door gegevens in een database op te slaan, hoeft u in veel gevallen helemaal niet na te denken over de noodzaak om de hele dataset in het RAM-geheugen te persen.
  • Langzame functies in R kunnen met behulp van het pakket worden vervangen door snelle functies in C++ Rcpp. Als aanvulling op gebruik RcppThread of RcppParallel, krijgen we platformonafhankelijke multi-threaded implementaties, dus het is niet nodig om de code op R-niveau te parallelliseren.
  • Pakket Rcpp kan worden gebruikt zonder serieuze kennis van C++, het vereiste minimum wordt geschetst hier. Headerbestanden voor een aantal coole C-bibliotheken zoals xtensor beschikbaar op CRAN, dat wil zeggen dat er een infrastructuur wordt gevormd voor de implementatie van projecten die kant-en-klare krachtige C++-code integreren in R. Bijkomend gemak is syntaxisaccentuering en een statische C++-code-analysator in RStudio.
  • docopte Hiermee kunt u op zichzelf staande scripts met parameters uitvoeren. Dit is handig voor gebruik op een externe server, incl. onder havenarbeider. In RStudio is het lastig om vele uren aan experimenten uit te voeren met het trainen van neurale netwerken, en het installeren van de IDE op de server zelf is niet altijd gerechtvaardigd.
  • Docker zorgt voor codeportabiliteit en reproduceerbaarheid van resultaten tussen ontwikkelaars met verschillende versies van het besturingssysteem en bibliotheken, evenals gemak van uitvoering op servers. U kunt de volledige trainingspijplijn met slechts één opdracht starten.
  • Google Cloud is een budgetvriendelijke manier om te experimenteren met dure hardware, maar u moet de configuraties zorgvuldig kiezen.
  • Het meten van de snelheid van individuele codefragmenten is erg handig, vooral bij het combineren van R en C++, en met het pakket bank - ook heel gemakkelijk.

Over het algemeen was deze ervaring zeer de moeite waard en we blijven werken aan het oplossen van enkele van de aan de orde gestelde problemen.

Bron: www.habr.com

Voeg een reactie