Quick Draw Doodle Recognition: hvordan bli venner med R, C++ og nevrale nettverk

Quick Draw Doodle Recognition: hvordan bli venner med R, C++ og nevrale nettverk

Hei Habr!

I fjor høst arrangerte Kaggle en konkurranse for å klassifisere håndtegnede bilder, Quick Draw Doodle Recognition, der blant annet et team av R-forskere deltok: Artem Klevtsova, Philippa leder и Andrey Ogurtsov. Vi vil ikke beskrive konkurransen i detalj, det er allerede gjort i nylig utgivelse.

Denne gangen gikk det ikke med medaljeoppdrett, men mye verdifull erfaring ble høstet, så jeg vil gjerne fortelle samfunnet om en rekke av de mest interessante og nyttige tingene på Kagle og i arbeidshverdagen. Blant temaene som diskuteres: vanskelig liv uten OpenCV, JSON-parsing (disse eksemplene undersøker integreringen av C++-kode i skript eller pakker i R ved å bruke Rcpp), parametrisering av skript og dockerisering av den endelige løsningen. All kode fra meldingen i et skjema som er egnet for utførelse er tilgjengelig i depoter.

Innhold:

  1. Last effektivt inn data fra CSV til MonetDB
  2. Forbereder partier
  3. Iteratorer for utlasting av batcher fra databasen
  4. Velge en modellarkitektur
  5. Skriptparameterisering
  6. Dockerisering av skript
  7. Bruk av flere GPUer på Google Cloud
  8. I stedet for en konklusjon

1. Last effektivt data fra CSV inn i MonetDB-databasen

Dataene i denne konkurransen leveres ikke i form av ferdige bilder, men i form av 340 CSV-filer (en fil for hver klasse) som inneholder JSON-er med punktkoordinater. Ved å koble disse punktene med linjer får vi et endelig bilde som måler 256x256 piksler. For hver post er det også en etikett som angir om bildet ble korrekt gjenkjent av klassifikatoren som ble brukt på det tidspunktet datasettet ble samlet inn, en tobokstavskode for bostedslandet til forfatteren av bildet, en unik identifikator, et tidsstempel og et klassenavn som samsvarer med filnavnet. En forenklet versjon av originaldataene veier 7.4 GB i arkivet og cirka 20 GB etter utpakking, full data etter utpakking tar opp 240 GB. Arrangørene sørget for at begge versjonene reproduserte de samme tegningene, noe som betyr at fullversjonen var overflødig. Uansett ble lagring av 50 millioner bilder i grafiske filer eller i form av arrays umiddelbart ansett som ulønnsomt, og vi bestemte oss for å slå sammen alle CSV-filer fra arkivet train_simplified.zip inn i databasen med påfølgende generering av bilder i ønsket størrelse "on the fly" for hver batch.

Et godt utprøvd system ble valgt som DBMS MonetDB, nemlig en implementering for R som en pakke MonetDBLite. Pakken inkluderer en innebygd versjon av databaseserveren og lar deg hente serveren direkte fra en R-sesjon og jobbe med den der. Oppretting av en database og tilkobling til den utføres med én kommando:

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

Vi må lage to tabeller: en for alle data, den andre for tjenesteinformasjon om nedlastede filer (nyttig hvis noe går galt og prosessen må gjenopptas etter nedlasting av flere filer):

Lage tabeller

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

Den raskeste måten å laste data inn i databasen var å kopiere CSV-filer direkte ved hjelp av SQL - kommando COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTDer tablename - tabellnavn og path - banen til filen. Under arbeidet med arkivet ble det oppdaget at den innebygde implementeringen unzip i R fungerer ikke riktig med en rekke filer fra arkivet, så vi brukte systemet unzip (ved å bruke parameteren getOption("unzip")).

Funksjon for å skrive til databasen

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

Hvis du trenger å transformere tabellen før du skriver den til databasen, er det nok å sende inn argumentet preprocess funksjon som vil transformere dataene.

Kode for sekvensiell lasting av data til databasen:

Skrive data til databasen

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

Datainnlastingstiden kan variere avhengig av hastighetsegenskapene til stasjonen som brukes. I vårt tilfelle tar lesing og skriving innenfor én SSD eller fra en flash-stasjon (kildefil) til en SSD (DB) mindre enn 10 minutter.

Det tar noen sekunder til å lage en kolonne med en heltallsklasseetikett og en indekskolonne (ORDERED INDEX) med linjenumre som observasjoner vil bli samplet med når du oppretter batcher:

Opprette flere kolonner og indeks

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

For å løse problemet med å lage en batch i farten, trengte vi å oppnå maksimal hastighet for å trekke ut tilfeldige rader fra tabellen doodles. Til dette brukte vi 3 triks. Den første var å redusere dimensjonaliteten til typen som lagrer observasjons-ID. I det originale datasettet er typen som kreves for å lagre IDen bigint, men antallet observasjoner gjør det mulig å tilpasse deres identifikatorer, lik ordinærtallet, i typen int. Søket er mye raskere i dette tilfellet. Det andre trikset var å bruke ORDERED INDEX — Vi kom til denne avgjørelsen empirisk, etter å ha gått gjennom alle tilgjengelige opsjoner. Den tredje var å bruke parameteriserte spørringer. Essensen av metoden er å utføre kommandoen én gang PREPARE med påfølgende bruk av et forberedt uttrykk når du lager en haug med spørringer av samme type, men det er faktisk en fordel sammenlignet med et enkelt SELECT viste seg å være innenfor rekkevidden til statistiske feil.

Prosessen med å laste opp data bruker ikke mer enn 450 MB RAM. Det vil si at den beskrevne tilnærmingen lar deg flytte datasett som veier titalls gigabyte på nesten hvilken som helst budsjettmaskinvare, inkludert noen enkeltbordsenheter, noe som er ganske kult.

Alt som gjenstår er å måle hastigheten på å hente (tilfeldige) data og evaluere skaleringen ved prøvetaking av batcher av forskjellige størrelser:

Database 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: hvordan bli venner med R, C++ og nevrale nettverk

2. Klargjøring av partier

Hele batchforberedelsesprosessen består av følgende trinn:

  1. Parsing av flere JSON-er som inneholder vektorer av strenger med koordinater av punkter.
  2. Tegn fargede linjer basert på koordinatene til punktene på et bilde med ønsket størrelse (for eksempel 256×256 eller 128×128).
  3. Konvertering av de resulterende bildene til en tensor.

Som en del av konkurransen mellom Python-kjerner ble problemet løst først og fremst ved hjelp av OpenCV. En av de enkleste og mest åpenbare analogene i R vil se slik ut:

Implementering av JSON til Tensor-konvertering i 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)
}

Tegning utføres ved hjelp av standard R-verktøy og lagres i en midlertidig PNG lagret i RAM (på Linux er midlertidige R-kataloger plassert i katalogen /tmp, montert i RAM). Denne filen blir deretter lest som en tredimensjonal matrise med tall fra 0 til 1. Dette er viktig fordi en mer konvensjonell BMP vil bli lest inn i en rå matrise med hex-fargekoder.

La oss teste resultatet:

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: hvordan bli venner med R, C++ og nevrale nettverk

Selve batchen vil bli dannet som følger:

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

Denne implementeringen virket suboptimal for oss, siden dannelsen av store partier tar uanstendig lang tid, og vi bestemte oss for å dra nytte av erfaringen til våre kolleger ved å bruke et kraftig bibliotek OpenCV. På den tiden var det ingen ferdig pakke for R (det er ingen nå), så en minimal implementering av den nødvendige funksjonaliteten ble skrevet i C++ med integrasjon i R-kode ved hjelp av Rcpp.

For å løse problemet ble følgende pakker og biblioteker brukt:

  1. OpenCV for arbeid med bilder og tegning av linjer. Brukte forhåndsinstallerte systembiblioteker og header-filer, samt dynamisk kobling.

  2. xtensor for arbeid med flerdimensjonale arrays og tensorer. Vi brukte header-filer inkludert i R-pakken med samme navn. Biblioteket lar deg jobbe med flerdimensjonale arrays, både i rad-major og kolonne-major rekkefølge.

  3. ndjson for å analysere JSON. Dette biblioteket brukes i xtensor automatisk hvis den er til stede i prosjektet.

  4. RcppThread for organisering av flertrådsbehandling av en vektor fra JSON. Brukte overskriftsfilene levert av denne pakken. Fra mer populær RcppParallell Pakken har blant annet en innebygd sløyfeavbruddsmekanisme.

Det bør bemerkes at xtensor viste seg å være en gave: i tillegg til at den har omfattende funksjonalitet og høy ytelse, viste utviklerne seg å være ganske responsive og svarte på spørsmål raskt og i detalj. Med deres hjelp var det mulig å implementere transformasjoner av OpenCV-matriser til xtensor-tensorer, samt en måte å kombinere 3-dimensjonale bildetensorer til en 4-dimensjonal tensor med riktig dimensjon (selve batchen).

Materialer for å lære Rcpp, xtensor og 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

For å kompilere filer som bruker systemfiler og dynamisk kobling med biblioteker installert på systemet, brukte vi plugin-mekanismen implementert i pakken Rcpp. For å finne stier og flagg automatisk, brukte vi et populært Linux-verktøy pkg-config.

Implementering av Rcpp-plugin for bruk av OpenCV-biblioteket

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

Som et resultat av plugin-funksjonen, vil følgende verdier bli erstattet under kompileringsprosessen:

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"

Implementeringskoden for å analysere JSON og generere en batch for overføring til modellen er gitt under spoileren. Først legger du til en lokal prosjektkatalog for å søke etter overskriftsfiler (nødvendig for ndjson):

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

Implementering av JSON til tensorkonvertering i 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;
}

Denne koden skal plasseres i filen src/cv_xt.cpp og kompiler med kommandoen Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); også nødvendig for arbeid nlohmann/json.hpp av oppbevaringssted. Koden er delt inn i flere funksjoner:

  • to_xt — en malfunksjon for å transformere en bildematrise (cv::Mat) til en tensor xt::xtensor;

  • parse_json — funksjonen analyserer en JSON-streng, trekker ut koordinatene til punktene, pakker dem inn i en vektor;

  • ocv_draw_lines — fra den resulterende vektoren av punkter, tegner flerfargede linjer;

  • process — kombinerer funksjonene ovenfor og legger også til muligheten til å skalere det resulterende bildet;

  • cpp_process_json_str - omslag over funksjonen process, som eksporterer resultatet til et R-objekt (flerdimensjonal array);

  • cpp_process_json_vector - omslag over funksjonen cpp_process_json_str, som lar deg behandle en strengvektor i flertrådsmodus.

For å tegne flerfargede linjer ble HSV-fargemodellen brukt, etterfulgt av konvertering til RGB. La oss teste resultatet:

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

Quick Draw Doodle Recognition: hvordan bli venner med R, C++ og nevrale nettverk
Sammenligning av hastigheten på implementeringer i R og 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: hvordan bli venner med R, C++ og nevrale nettverk

Som du kan se, viste hastighetsøkningen seg å være veldig betydelig, og det er ikke mulig å ta igjen C++-kode ved å parallellisere R-kode.

3. Iteratorer for utlasting av batcher fra databasen

R har et velfortjent rykte for å behandle data som passer i RAM, mens Python er mer preget av iterativ databehandling, slik at du enkelt og naturlig kan implementere out-of-core beregninger (beregninger ved bruk av eksternt minne). Et klassisk og relevant eksempel for oss i sammenheng med det beskrevne problemet er dype nevrale nettverk trent av gradient descent-metoden med tilnærming av gradienten ved hvert trinn ved hjelp av en liten del av observasjoner, eller mini-batch.

Deep learning-rammeverk skrevet i Python har spesielle klasser som implementerer iteratorer basert på data: tabeller, bilder i mapper, binære formater osv. Du kan bruke ferdige alternativer eller skrive dine egne for spesifikke oppgaver. I R kan vi dra nytte av alle funksjonene til Python-biblioteket hard med sine ulike backends ved hjelp av pakken med samme navn, som igjen fungerer på toppen av pakken retikulere. Sistnevnte fortjener en egen lang artikkel; det lar deg ikke bare kjøre Python-kode fra R, men lar deg også overføre objekter mellom R- og Python-økter, og utfører automatisk alle nødvendige typekonverteringer.

Vi ble kvitt behovet for å lagre alle dataene i RAM ved å bruke MonetDBLite, alt arbeidet med "nevrale nettverk" vil bli utført av den originale koden i Python, vi må bare skrive en iterator over dataene, siden det ikke er noe klart for en slik situasjon i enten R eller Python. Det er i hovedsak bare to krav til det: det må returnere batcher i en endeløs sløyfe og lagre tilstanden mellom iterasjoner (sistnevnte i R implementeres på den enkleste måten ved å bruke lukkinger). Tidligere var det nødvendig å eksplisitt konvertere R-matriser til numpy-matriser inne i iteratoren, men den nåværende versjonen av pakken hard gjør det selv.

Iteratoren for trenings- og valideringsdata viste seg å være som følger:

Iterator for trenings- og valideringsdata

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

Funksjonen tar som input en variabel med tilkobling til databasen, antall linjer som brukes, antall klasser, batchstørrelse, skala (scale = 1 tilsvarer gjengivelse av bilder på 256x256 piksler, scale = 0.5 — 128x128 piksler), fargeindikator (color = FALSE angir gjengivelse i gråtoner når den brukes color = TRUE hvert slag tegnes i en ny farge) og en forbehandlingsindikator for nettverk som er forhåndstrent på imagenet. Sistnevnte er nødvendig for å skalere pikselverdier fra intervallet [0, 1] til intervallet [-1, 1], som ble brukt ved opplæring av den medfølgende hard modeller.

Den eksterne funksjonen inneholder argumenttypekontroll, en tabell data.table med tilfeldig blandede linjetall fra samples_index og batchnummer, teller og maksimalt antall batcher, samt et SQL-uttrykk for utlasting av data fra databasen. I tillegg definerte vi en rask analog av funksjonen inne keras::to_categorical(). Vi brukte nesten alle dataene til trening, og la igjen en halv prosent for validering, så epokestørrelsen ble begrenset av parameteren steps_per_epoch når du ringer keras::fit_generator(), og tilstanden if (i > max_i) fungerte bare for valideringsiteratoren.

I den interne funksjonen hentes radindekser for neste batch, poster lastes ut fra databasen med økende batchteller, JSON-parsing (funksjon cpp_process_json_vector(), skrevet i C++) og lage arrays som tilsvarer bilder. Deretter opprettes en-hete vektorer med klasseetiketter, arrays med pikselverdier og etiketter kombineres til en liste, som er returverdien. For å få fart på arbeidet brukte vi opprettelsen av indekser i tabeller data.table og modifikasjon via lenken - uten disse pakke-"brikkene" data bord Det er ganske vanskelig å forestille seg å jobbe effektivt med noen betydelig mengde data i R.

Resultatene av hastighetsmålinger på en Core i5 bærbar PC er som følger:

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: hvordan bli venner med R, C++ og nevrale nettverk

Hvis du har en tilstrekkelig mengde RAM, kan du seriøst fremskynde driften av databasen ved å overføre den til samme RAM (32 GB er nok for vår oppgave). I Linux er partisjonen montert som standard /dev/shm, opptar opptil halvparten av RAM-kapasiteten. Du kan fremheve flere ved å redigere /etc/fstabå få en plate som tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Sørg for å starte på nytt og sjekk resultatet ved å kjøre kommandoen df -h.

Iteratoren for testdata ser mye enklere ut, siden testdatasettet passer helt inn i RAM:

Iterator for testdata

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. Valg av modellarkitektur

Den første arkitekturen som ble brukt var mobilnett v1, hvis funksjoner er omtalt i dette beskjed. Den er inkludert som standard hard og er følgelig tilgjengelig i pakken med samme navn for R. Men når du prøver å bruke den med enkanalsbilder, viste det seg en merkelig ting: inngangstensoren må alltid ha dimensjonen (batch, height, width, 3), det vil si at antall kanaler ikke kan endres. Det er ingen slik begrensning i Python, så vi skyndte oss og skrev vår egen implementering av denne arkitekturen, etter den originale artikkelen (uten frafall som er i keras-versjonen):

Mobilenet v1-arkitektur

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

Ulempene med denne tilnærmingen er åpenbare. Jeg vil teste mange modeller, men tvert imot, jeg vil ikke omskrive hver arkitektur manuelt. Vi ble også fratatt muligheten til å bruke vektene til modeller som er forhåndstrent på imagenet. Som vanlig hjalp det å studere dokumentasjonen. Funksjon get_config() lar deg få en beskrivelse av modellen i en form som er egnet for redigering (base_model_conf$layers - en vanlig R-liste), og funksjonen from_config() utfører omvendt konvertering til et modellobjekt:

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)

Nå er det ikke vanskelig å skrive en universell funksjon for å få tak i noe av det som følger med hard modeller med eller uten vekter trent på imagenet:

Funksjon for lasting av ferdige arkitekturer

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

Ved bruk av enkanalsbilder brukes ingen forhåndstrente vekter. Dette kan fikses: ved å bruke funksjonen get_weights() få modellvektene i form av en liste over R-matriser, endre dimensjonen til det første elementet i denne listen (ved å ta en fargekanal eller gjennomsnitt alle tre), og last deretter vektene tilbake i modellen med funksjonen set_weights(). Vi har aldri lagt til denne funksjonaliteten, for på dette stadiet var det allerede klart at det var mer produktivt å jobbe med fargebilder.

Vi utførte de fleste eksperimentene med mobilnettversjon 1 og 2, samt resnet34. Mer moderne arkitekturer som SE-ResNeXt presterte godt i denne konkurransen. Dessverre hadde vi ikke ferdige implementeringer til rådighet, og vi skrev ikke våre egne (men vi kommer definitivt til å skrive).

5. Parametrisering av skript

For enkelhets skyld ble all kode for å starte trening utformet som et enkelt skript, parametrisert ved hjelp av docpt som følger:

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)

pakke docpt representerer gjennomføringen http://docopt.org/ for R. Med dens hjelp blir skript lansert med enkle kommandoer som Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db eller ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, hvis filen train_nn.R er kjørbar (denne kommandoen vil begynne å trene modellen resnet50 på trefargebilder som måler 128x128 piksler, må databasen ligge i mappen /home/andrey/doodle_db). Du kan legge til læringshastighet, optimeringstype og andre tilpassbare parametere til listen. I prosessen med å utarbeide publikasjonen viste det seg at arkitekturen mobilenet_v2 fra gjeldende versjon hard i R bruk kan ikke på grunn av endringer som ikke er tatt hensyn til i R-pakken, venter vi på at de skal fikse det.

Denne tilnærmingen gjorde det mulig å øke hastigheten på eksperimenter med forskjellige modeller betydelig sammenlignet med den mer tradisjonelle lanseringen av skript i RStudio (vi bemerker pakken som et mulig alternativ tfruns). Men den største fordelen er muligheten til å enkelt administrere lanseringen av skript i Docker eller ganske enkelt på serveren, uten å installere RStudio for dette.

6. Dockerisering av skript

Vi brukte Docker for å sikre portabilitet av miljøet for treningsmodeller mellom teammedlemmer og for rask distribusjon i skyen. Du kan begynne å bli kjent med dette verktøyet, som er relativt uvanlig for en R-programmerer, med dette serie med publikasjoner eller videokurs.

Docker lar deg både lage dine egne bilder fra bunnen av og bruke andre bilder som grunnlag for å lage dine egne. Når vi analyserte de tilgjengelige alternativene, kom vi til den konklusjon at installasjon av NVIDIA, CUDA+cuDNN-drivere og Python-biblioteker er en ganske omfangsrik del av bildet, og vi bestemte oss for å ta det offisielle bildet som grunnlag. tensorflow/tensorflow:1.12.0-gpu, og legger til de nødvendige R-pakkene der.

Den endelige docker-filen så slik ut:

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

For enkelhets skyld ble pakkene som ble brukt satt inn i variabler; hoveddelen av de skrevne skriptene kopieres inne i beholderne under montering. Vi endret også kommandoskallet til /bin/bash for enkel bruk av innhold /etc/os-release. Dette unngikk behovet for å spesifisere OS-versjonen i koden.

I tillegg ble det skrevet et lite bash-skript som lar deg starte en container med forskjellige kommandoer. Dette kan for eksempel være skript for å trene nevrale nettverk som tidligere var plassert inne i beholderen, eller et kommandoskall for feilsøking og overvåking av driften av beholderen:

Skript for å starte beholderen

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

Hvis dette bash-skriptet kjøres uten parametere, vil skriptet kalles inne i beholderen train_nn.R med standardverdier; hvis det første posisjonsargumentet er "bash", vil beholderen starte interaktivt med et kommandoskall. I alle andre tilfeller erstattes verdiene til posisjonsargumenter: CMD="Rscript /app/train_nn.R $@".

Det er verdt å merke seg at katalogene med kildedata og database, samt katalogen for lagring av trente modeller, er montert inne i beholderen fra vertssystemet, som lar deg få tilgang til resultatene av skriptene uten unødvendige manipulasjoner.

7. Bruke flere GPUer på Google Cloud

En av funksjonene til konkurransen var de svært støyende dataene (se tittelbildet, lånt fra @Leigh.plt fra ODS slack). Store batcher hjelper til med å bekjempe dette, og etter eksperimenter på en PC med 1 GPU bestemte vi oss for å mestre treningsmodeller på flere GPUer i skyen. Brukte GoogleCloud (god veiledning til det grunnleggende) på grunn av det store utvalget av tilgjengelige konfigurasjoner, rimelige priser og $300 bonus. Av grådighet bestilte jeg en 4xV100-instans med en SSD og massevis av RAM, og det var en stor feil. En slik maskin spiser raskt opp penger; du kan gå blakk og eksperimentere uten en påvist rørledning. For pedagogiske formål er det bedre å ta K80. Men den store mengden RAM kom godt med - sky-SSDen imponerte ikke med ytelsen, så databasen ble overført til dev/shm.

Av størst interesse er kodefragmentet som er ansvarlig for bruk av flere GPUer. Først lages modellen på CPU ved hjelp av en kontekstbehandling, akkurat som i 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
  )
})

Deretter kopieres den ukompilerte (dette er viktig) modellen til et gitt antall tilgjengelige GPUer, og først etter det blir den kompilert:

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

Den klassiske teknikken med å fryse alle lag unntatt det siste, trene opp det siste laget, fryse opp og trene opp hele modellen for flere GPUer kunne ikke implementeres.

Trening ble overvåket uten bruk. tensorboard, begrenser oss til å registrere logger og lagre modeller med informative navn etter hver epoke:

Tilbakeringinger

# Шаблон имени файла лога
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. I stedet for en konklusjon

En rekke problemer vi har støtt på er ennå ikke overvunnet:

  • в hard det er ingen ferdig funksjon for automatisk søk ​​etter optimal læringshastighet (analog lr_finder i biblioteket fort.ai); Med litt innsats er det mulig å portere tredjepartsimplementeringer til R, for eksempel, dette;
  • som en konsekvens av forrige punkt var det ikke mulig å velge riktig treningshastighet ved bruk av flere GPUer;
  • det er mangel på moderne nevrale nettverksarkitekturer, spesielt de som er forhåndstrent på imagenet;
  • ingen sykluspolitikk og diskriminerende læringshastigheter (kosinusgløding var på vår forespørsel implementert, Takk skeydan).

Hvilke nyttige ting ble lært fra denne konkurransen:

  • På maskinvare med relativt lav effekt kan du jobbe med anstendige (mange ganger størrelsen på RAM) datamengder uten smerte. Plastpose data bord sparer minne på grunn av modifikasjoner på stedet av tabeller, som unngår å kopiere dem, og når de brukes riktig, viser egenskapene nesten alltid den høyeste hastigheten blant alle verktøy som er kjent for oss for skriptspråk. Lagring av data i en database lar deg i mange tilfeller ikke tenke i det hele tatt på behovet for å presse hele datasettet inn i RAM.
  • Langsomme funksjoner i R kan erstattes med raske i C++ ved å bruke pakken Rcpp. Hvis i tillegg til bruk RcppThread eller RcppParallell, får vi flertrådede implementeringer på tvers av plattformer, så det er ikke nødvendig å parallellisere koden på R-nivå.
  • Pakke Rcpp kan brukes uten seriøs kunnskap om C++, er det nødvendige minimum skissert her. Header-filer for en rekke kule C-biblioteker som xtensor tilgjengelig på CRAN, det vil si at det dannes en infrastruktur for implementering av prosjekter som integrerer ferdiglaget høyytelses C++-kode i R. Ytterligere bekvemmelighet er syntaksutheving og en statisk C++-kodeanalysator i RStudio.
  • docpt lar deg kjøre selvstendige skript med parametere. Dette er praktisk å bruke på en ekstern server, inkl. under docker. I RStudio er det upraktisk å gjennomføre mange timer med eksperimenter med å trene nevrale nettverk, og å installere IDE på selve serveren er ikke alltid berettiget.
  • Docker sikrer kodeportabilitet og reproduserbarhet av resultater mellom utviklere med forskjellige versjoner av operativsystemet og biblioteker, samt enkel kjøring på servere. Du kan starte hele treningspipelinen med bare én kommando.
  • Google Cloud er en budsjettvennlig måte å eksperimentere med dyr maskinvare på, men du må velge konfigurasjoner nøye.
  • Å måle hastigheten til individuelle kodefragmenter er veldig nyttig, spesielt når du kombinerer R og C++, og med pakken benk - også veldig enkelt.

Totalt sett var denne opplevelsen veldig givende, og vi fortsetter å jobbe for å løse noen av problemene som ble reist.

Kilde: www.habr.com

Legg til en kommentar