Quick Draw Doodle Recognition: hur man blir vän med R, C++ och neurala nätverk

Quick Draw Doodle Recognition: hur man blir vän med R, C++ och neurala nätverk

Hej Habr!

I höstas var Kaggle värd för en tävling för att klassificera handritade bilder, Quick Draw Doodle Recognition, där bland annat ett team av R-forskare deltog: Artem Klevtsova, Philippa Manager и Andrey Ogurtsov. Vi kommer inte att beskriva tävlingen i detalj, det har redan gjorts i nyligen publicerad.

Den här gången gick det inte med medaljodling, men en hel del värdefull erfarenhet har vunnits, så jag skulle vilja berätta för samhället om ett antal av de mest intressanta och användbara sakerna på Kagle och i det dagliga arbetet. Bland de ämnen som diskuteras: svårt liv utan OpenCV, JSON-tolkning (dessa exempel undersöker integrationen av C++-kod i skript eller paket i R med Rcpp), parametrering av skript och dockerisering av den slutliga lösningen. All kod från meddelandet i en form som lämpar sig för exekvering är tillgänglig i förråd.

Innehåll:

  1. Ladda data effektivt från CSV till MonetDB
  2. Förbereda partier
  3. Iteratorer för avlastning av partier från databasen
  4. Att välja en modellarkitektur
  5. Skriptparameterisering
  6. Dockerisering av skript
  7. Använda flera GPU:er på Google Cloud
  8. I stället för en slutsats

1. Ladda effektivt data från CSV till MonetDB-databasen

Data i denna tävling tillhandahålls inte i form av färdiga bilder, utan i form av 340 CSV-filer (en fil för varje klass) som innehåller JSONs med punktkoordinater. Genom att koppla ihop dessa punkter med linjer får vi en slutlig bild som mäter 256x256 pixlar. För varje post finns det också en etikett som anger om bilden identifierades korrekt av klassificeraren som användes vid tidpunkten för insamlingen av datauppsättningen, en tvåbokstavskod för upphovsmannens bosättningsland, en unik identifierare, en tidsstämpel och ett klassnamn som matchar filnamnet. En förenklad version av originaldata väger 7.4 GB i arkivet och cirka 20 GB efter uppackning, hela datan efter uppackning tar upp 240 GB. Arrangörerna såg till att båda versionerna återgav samma ritningar, vilket innebär att den fullständiga versionen var överflödig. Hur som helst ansågs det omedelbart olönsamt att lagra 50 miljoner bilder i grafiska filer eller i form av arrayer, och vi bestämde oss för att slå samman alla CSV-filer från arkivet train_simplified.zip in i databasen med efterföljande generering av bilder i den önskade storleken "on the fly" för varje batch.

Ett väl beprövat system valdes som DBMS MonetDB, nämligen en implementering för R som ett paket MonetDBLite. Paketet innehåller en inbäddad version av databasservern och låter dig hämta servern direkt från en R-session och arbeta med den där. Att skapa en databas och ansluta till den utförs med ett kommando:

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

Vi kommer att behöva skapa två tabeller: en för all data, den andra för serviceinformation om nedladdade filer (användbart om något går fel och processen måste återupptas efter nedladdning av flera filer):

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

Det snabbaste sättet att ladda data till databasen var att direkt kopiera CSV-filer med SQL - kommando COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTvar tablename - tabellnamn och path - sökvägen till filen. Under arbetet med arkivet upptäcktes att den inbyggda implementeringen unzip i R fungerar inte korrekt med ett antal filer från arkivet, så vi använde systemet unzip (med hjälp av parametern getOption("unzip")).

Funktion för att skriva till 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))
}

Om du behöver transformera tabellen innan du skriver den till databasen räcker det med att skicka in argumentet preprocess funktion som omvandlar data.

Kod för att sekventiellt ladda data till databasen:

Skriva data till 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

Dataladdningstiden kan variera beroende på hastighetsegenskaperna för den använda enheten. I vårt fall tar läsning och skrivning inom en SSD eller från en flashenhet (källfil) till en SSD (DB) mindre än 10 minuter.

Det tar ytterligare några sekunder att skapa en kolumn med en heltalsklassetikett och en indexkolumn (ORDERED INDEX) med radnummer som observationer kommer att samplas med när man skapar batcher:

Skapa ytterligare kolumner och index

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

För att lösa problemet med att skapa en batch i farten behövde vi uppnå maximal hastighet för att extrahera slumpmässiga rader från tabellen doodles. För detta använde vi 3 knep. Den första var att minska dimensionaliteten för den typ som lagrar observations-ID. I den ursprungliga datamängden är den typ som krävs för att lagra ID:t bigint, men antalet observationer gör det möjligt att passa in deras identifierare, lika med ordningsnumret, i typen int. Sökningen går mycket snabbare i det här fallet. Det andra tricket var att använda ORDERED INDEX — Vi kom till detta beslut empiriskt, efter att ha gått igenom alla tillgängliga alternativ. Den tredje var att använda parametriserade frågor. Kärnan i metoden är att utföra kommandot en gång PREPARE med efterföljande användning av ett förberett uttryck när du skapar en massa frågor av samma typ, men det finns faktiskt en fördel i jämförelse med en enkel SELECT visade sig ligga inom ramarna för statistiska fel.

Processen att ladda upp data förbrukar inte mer än 450 MB RAM. Det vill säga, det beskrivna tillvägagångssättet låter dig flytta datamängder som väger tiotals gigabyte på nästan vilken budgethårdvara som helst, inklusive vissa enkelkortsenheter, vilket är ganska coolt.

Allt som återstår är att mäta hastigheten för att hämta (slumpmässiga) data och utvärdera skalningen vid provtagning av partier av olika storlekar:

Databas 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: hur man blir vän med R, C++ och neurala nätverk

2. Förbereda satser

Hela batchberedningsprocessen består av följande steg:

  1. Analysera flera JSONs som innehåller vektorer av strängar med koordinater för punkter.
  2. Rita färgade linjer baserat på koordinaterna för punkter på en bild med önskad storlek (till exempel 256×256 eller 128×128).
  3. Konvertera de resulterande bilderna till en tensor.

Som en del av konkurrensen mellan Python-kärnor löstes problemet främst med hjälp av OpenCV. En av de enklaste och mest uppenbara analogerna i R skulle se ut så här:

Implementering av JSON till 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)
}

Ritning utförs med vanliga R-verktyg och sparas i en tillfällig PNG lagrad i RAM (på Linux finns temporära R-kataloger i katalogen /tmp, monterad i RAM). Denna fil läses sedan som en tredimensionell array med siffror från 0 till 1. Detta är viktigt eftersom en mer konventionell BMP skulle läsas in i en rå array med hexadecimala färgkoder.

Låt oss testa 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: hur man blir vän med R, C++ och neurala nätverk

Själva partiet kommer att bildas enligt följande:

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

Denna implementering verkade suboptimal för oss, eftersom bildandet av stora partier tar anständigt lång tid, och vi bestämde oss för att dra nytta av våra kollegors erfarenhet genom att använda ett kraftfullt bibliotek OpenCV. På den tiden fanns det inget färdigt paket för R (det finns inget nu), så en minimal implementering av den nödvändiga funktionaliteten skrevs i C++ med integration i R-kod med hjälp av Rcpp.

För att lösa problemet användes följande paket och bibliotek:

  1. OpenCV för att arbeta med bilder och rita linjer. Använde förinstallerade systembibliotek och rubrikfiler, samt dynamisk länkning.

  2. xtensor för att arbeta med flerdimensionella arrayer och tensorer. Vi använde header-filer som ingår i R-paketet med samma namn. Biblioteket låter dig arbeta med flerdimensionella arrayer, både i radstor och kolumnstor ordning.

  3. ndjson för att analysera JSON. Detta bibliotek används i xtensor automatiskt om det finns i projektet.

  4. RcppThread för att organisera flertrådad bearbetning av en vektor från JSON. Använde header-filerna som tillhandahålls av detta paket. Från mer populära RcppParallell Paketet har bland annat en inbyggd loop-avbrottsmekanism.

Det bör noteras att xtensor visade sig vara en gudagåva: förutom det faktum att den har omfattande funktionalitet och hög prestanda, visade sig dess utvecklare vara ganska lyhörda och svarade på frågor snabbt och i detalj. Med deras hjälp var det möjligt att implementera transformationer av OpenCV-matriser till xtensortensorer, samt ett sätt att kombinera 3-dimensionella bildtensorer till en 4-dimensionell tensor med rätt dimension (selva batchen).

Material för att lära sig Rcpp, xtensor och 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

För att kompilera filer som använder systemfiler och dynamisk länkning med bibliotek installerade på systemet använde vi plugin-mekanismen implementerad i paketet Rcpp. För att automatiskt hitta sökvägar och flaggor använde vi ett populärt Linux-verktyg pkg-config.

Implementering av Rcpp-plugin för användning 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 ett resultat av pluginens funktion kommer följande värden att ersättas under kompileringsprocessen:

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 för att analysera JSON och generera en batch för överföring till modellen ges under spoilern. Lägg först till en lokal projektkatalog för att söka efter rubrikfiler (behövs för ndjson):

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

Implementering av JSON till 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;
}

Denna kod ska placeras i filen src/cv_xt.cpp och kompilera med kommandot Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); krävs också för arbete nlohmann/json.hpp av förvaret. Koden är uppdelad i flera funktioner:

  • to_xt — en mallfunktion för att transformera en bildmatris (cv::Mat) till en tensor xt::xtensor;

  • parse_json — funktionen analyserar en JSON-sträng, extraherar punkternas koordinater, packar dem i en vektor;

  • ocv_draw_lines — från den resulterande vektorn av punkter, ritar flerfärgade linjer;

  • process — kombinerar ovanstående funktioner och lägger också till möjligheten att skala den resulterande bilden;

  • cpp_process_json_str - omslag över funktionen process, som exporterar resultatet till ett R-objekt (flerdimensionell array);

  • cpp_process_json_vector - omslag över funktionen cpp_process_json_str, som låter dig bearbeta en strängvektor i flertrådsläge.

För att rita flerfärgade linjer användes HSV-färgmodellen, följt av konvertering till RGB. Låt oss testa 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: hur man blir vän med R, C++ och neurala nätverk
Jämförelse av hastigheten på implementeringar i R och 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: hur man blir vän med R, C++ och neurala nätverk

Som du kan se visade sig hastighetsökningen vara mycket betydande, och det är inte möjligt att komma ikapp med C++-kod genom att parallellisera R-kod.

3. Iteratorer för avlastning av partier från databasen

R har ett välförtjänt rykte för att bearbeta data som passar i RAM, medan Python kännetecknas mer av iterativ databehandling, vilket gör att du enkelt och naturligt kan implementera out-of-core-beräkningar (beräkningar med hjälp av externt minne). Ett klassiskt och relevant exempel för oss i samband med det beskrivna problemet är djupa neurala nätverk som tränas med gradient descent-metoden med approximation av gradienten vid varje steg med hjälp av en liten del av observationer, eller mini-batch.

Ramverk för djupinlärning skrivna i Python har speciella klasser som implementerar iteratorer baserat på data: tabeller, bilder i mappar, binära format, etc. Du kan använda färdiga alternativ eller skriva egna för specifika uppgifter. I R kan vi dra nytta av alla funktioner i Python-biblioteket Keras med sina olika backends som använder paketet med samma namn, som i sin tur fungerar ovanpå paketet nätformiga. Den senare förtjänar en separat lång artikel; det låter dig inte bara köra Python-kod från R, utan låter dig också överföra objekt mellan R- och Python-sessioner, vilket automatiskt utför alla nödvändiga typkonverteringar.

Vi blev av med behovet av att lagra all data i RAM genom att använda MonetDBLite, allt arbete med "neurala nätverk" kommer att utföras av den ursprungliga koden i Python, vi måste bara skriva en iterator över data, eftersom det inte finns något klart för en sådan situation i antingen R eller Python. Det finns i huvudsak bara två krav för det: det måste returnera batcher i en ändlös loop och spara dess tillstånd mellan iterationer (det senare i R implementeras på det enklaste sättet med stängningar). Tidigare krävdes det att explicit konvertera R-arrayer till numpy-arrayer inuti iteratorn, men den aktuella versionen av paketet Keras gör det själv.

Iteratorn för tränings- och valideringsdata visade sig vara följande:

Iterator för tränings- och 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)
  }
}

Funktionen tar som indata en variabel med koppling till databasen, antalet rader som används, antalet klasser, batchstorlek, skala (scale = 1 motsvarar att rendera bilder på 256x256 pixlar, scale = 0.5 — 128x128 pixlar), färgindikator (color = FALSE anger rendering i gråskala när den används color = TRUE varje streck ritas i en ny färg) och en förbearbetningsindikator för nätverk som är förtränade på imagenet. Det senare behövs för att skala pixelvärden från intervallet [0, 1] till intervallet [-1, 1], som användes vid träning av den medföljande Keras modeller.

Den externa funktionen innehåller argumenttypkontroll, en tabell data.table med slumpmässigt blandade radnummer från samples_index och batchnummer, räknare och maximalt antal batcher, samt ett SQL-uttryck för avlastning av data från databasen. Dessutom definierade vi en snabb analog av funktionen inuti keras::to_categorical(). Vi använde nästan all data för träning och lämnade en halv procent för validering, så epokstorleken begränsades av parametern steps_per_epoch när man ringer keras::fit_generator()och tillståndet if (i > max_i) fungerade bara för valideringsiteratorn.

I den interna funktionen hämtas radindex för nästa batch, poster laddas ur databasen med batchräknaren ökande, JSON parsing (funktion cpp_process_json_vector(), skrivet i C++) och skapa arrayer som motsvarar bilder. Sedan skapas en-heta vektorer med klassetiketter, arrayer med pixelvärden och etiketter kombineras till en lista, som är returvärdet. För att påskynda arbetet använde vi skapandet av index i tabeller data.table och modifiering via länken - utan dessa paket "chips" datatabell Det är ganska svårt att föreställa sig att arbeta effektivt med någon betydande mängd data i R.

Resultaten av hastighetsmätningar på en bärbar Core i5 är följande:

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: hur man blir vän med R, C++ och neurala nätverk

Om du har tillräckligt med RAM-minne kan du på allvar påskynda driften av databasen genom att överföra den till samma RAM-minne (32 GB räcker för vår uppgift). I Linux är partitionen monterad som standard /dev/shm, som upptar upp till halva RAM-kapaciteten. Du kan markera mer genom att redigera /etc/fstabatt få en skiva som tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Se till att starta om och kontrollera resultatet genom att köra kommandot df -h.

Iteratorn för testdata ser mycket enklare ut, eftersom testdataset passar helt och hållet i RAM:

Iterator för 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. Val av modellarkitektur

Den första arkitekturen som användes var mobilnät v1, vars egenskaper diskuteras i detta meddelande. Den ingår som standard Keras och är följaktligen tillgänglig i paketet med samma namn för R. Men när man försöker använda det med enkanaliga bilder visade sig en märklig sak: ingångstensorn måste alltid ha dimensionen (batch, height, width, 3), det vill säga antalet kanaler kan inte ändras. Det finns ingen sådan begränsning i Python, så vi skyndade oss och skrev vår egen implementering av den här arkitekturen, efter den ursprungliga artikeln (utan bortfallet som finns i keras-versionen):

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

Nackdelarna med detta tillvägagångssätt är uppenbara. Jag vill testa många modeller, men tvärtom, jag vill inte skriva om varje arkitektur manuellt. Vi fråntogs också möjligheten att använda vikterna från modeller som förtränats på imagenet. Som vanligt hjälpte det att studera dokumentationen. Fungera get_config() låter dig få en beskrivning av modellen i en form som lämpar sig för redigering (base_model_conf$layers - en vanlig R-lista), och funktionen from_config() utför den omvända konverteringen till ett 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)

Nu är det inte svårt att skriva en universell funktion för att få någon av de medföljande Keras modeller med eller utan vikter tränade på imagenet:

Funktion för att ladda färdiga 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)
}

Vid användning av enkanalsbilder används inga förtränade vikter. Detta kan fixas: med funktionen get_weights() få modellvikterna i form av en lista med R-matriser, ändra dimensionen på det första elementet i denna lista (genom att ta en färgkanal eller medelvärde för alla tre), och ladda sedan tillbaka vikterna i modellen med funktionen set_weights(). Vi har aldrig lagt till den här funktionen, eftersom det redan i detta skede stod klart att det var mer produktivt att arbeta med färgbilder.

Vi utförde de flesta experimenten med mobilnätversion 1 och 2, samt resnet34. Modernare arkitekturer som SE-ResNeXt presterade bra i denna tävling. Tyvärr hade vi inga färdiga implementeringar till vårt förfogande, och vi skrev inga egna (men vi kommer definitivt att skriva).

5. Parametrering av skript

För enkelhetens skull designades all kod för att börja träna som ett enda skript, parametriserat med hjälp av docpt enligt följande:

doc <- '
Usage:
  train_nn.R --help
  train_nn.R --list-models
  train_nn.R [options]

Options:
  -h --help                   Show this message.
  -l --list-models            List available models.
  -m --model=<model>          Neural network model name [default: mobilenet_v2].
  -b --batch-size=<size>      Batch size [default: 32].
  -s --scale-factor=<ratio>   Scale factor [default: 0.5].
  -c --color                  Use color lines [default: FALSE].
  -d --db-dir=<path>          Path to database directory [default: Sys.getenv("db_dir")].
  -r --validate-ratio=<ratio> Validate sample ratio [default: 0.995].
  -n --n-gpu=<number>         Number of GPUs [default: 1].
'
args <- docopt::docopt(doc)

paket docpt representerar genomförandet http://docopt.org/ för R. Med dess hjälp lanseras skript med enkla kommandon 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, if fil train_nn.R är körbar (det här kommandot börjar träna modellen resnet50 på trefärgsbilder som mäter 128x128 pixlar måste databasen finnas i mappen /home/andrey/doodle_db). Du kan lägga till inlärningshastighet, optimeringstyp och andra anpassningsbara parametrar till listan. I arbetet med att förbereda publikationen visade det sig att arkitekturen mobilenet_v2 från den aktuella versionen Keras i R-användning får inte på grund av ändringar som inte tagits med i R-paketet, väntar vi på att de ska fixa det.

Detta tillvägagångssätt gjorde det möjligt att avsevärt påskynda experiment med olika modeller jämfört med den mer traditionella lanseringen av skript i RStudio (vi noterar paketet som ett möjligt alternativ tfruns). Men den största fördelen är möjligheten att enkelt hantera lanseringen av skript i Docker eller helt enkelt på servern, utan att installera RStudio för detta.

6. Dockerisering av skript

Vi använde Docker för att säkerställa portabilitet av miljön för utbildningsmodeller mellan teammedlemmar och för snabb implementering i molnet. Du kan börja bekanta dig med detta verktyg, som är relativt ovanligt för en R-programmerare, med detta serie publikationer eller videokurs.

Docker låter dig både skapa dina egna bilder från grunden och använda andra bilder som grund för att skapa dina egna. När vi analyserade de tillgängliga alternativen kom vi till slutsatsen att installation av NVIDIA, CUDA+cuDNN-drivrutiner och Python-bibliotek är en ganska omfattande del av bilden, och vi bestämde oss för att ta den officiella bilden som grund tensorflow/tensorflow:1.12.0-gpu, lägga till de nödvändiga R-paketen där.

Den sista docker-filen såg ut så här:

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

För enkelhetens skull lades de använda paketen in i variabler; huvuddelen av de skrivna skripten kopieras inuti behållarna under monteringen. Vi ändrade också kommandoskalet till /bin/bash för enkel användning av innehållet /etc/os-release. Detta undvek behovet av att ange OS-versionen i koden.

Dessutom skrevs ett litet bash-skript som låter dig starta en behållare med olika kommandon. Dessa kan till exempel vara skript för att träna neurala nätverk som tidigare placerats inuti behållaren, eller ett kommandoskal för felsökning och övervakning av behållarens funktion:

Skript för att starta behållaren

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

Om detta bash-skript körs utan parametrar kommer skriptet att anropas inuti behållaren train_nn.R med standardvärden; om det första positionsargumentet är "bash", kommer behållaren att starta interaktivt med ett kommandoskal. I alla andra fall ersätts värdena för positionsargument: CMD="Rscript /app/train_nn.R $@".

Det är värt att notera att katalogerna med källdata och databas, såväl som katalogen för att spara tränade modeller, är monterade inuti behållaren från värdsystemet, vilket gör att du kan komma åt resultaten av skripten utan onödiga manipulationer.

7. Använda flera grafikprocessorer på Google Cloud

En av funktionerna i tävlingen var den mycket bullriga informationen (se titelbilden, lånad från @Leigh.plt från ODS slack). Stora partier hjälper till att bekämpa detta, och efter experiment på en PC med 1 GPU bestämde vi oss för att bemästra träningsmodeller på flera GPU:er i molnet. Använde GoogleCloud (bra guide till grunderna) på grund av det stora urvalet av tillgängliga konfigurationer, rimliga priser och $300 bonus. Av girighet beställde jag en 4xV100-instans med en SSD och massor av RAM, och det var ett stort misstag. En sådan maskin äter snabbt upp pengar, du kan experimentera utan en beprövad pipeline. För utbildningsändamål är det bättre att ta K80. Men den stora mängden RAM kom väl till pass - molnet SSD imponerade inte med dess prestanda, så databasen överfördes till dev/shm.

Av störst intresse är kodfragmentet som är ansvarigt för att använda flera GPU:er. Först skapas modellen på CPU:n med hjälp av en kontexthanterare, precis 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
  )
})

Sedan kopieras den okompilerade (detta är viktigt) modellen till ett givet antal tillgängliga GPU:er, och först efter det kompileras den:

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 klassiska tekniken att frysa alla lager utom det sista, träna det sista lagret, frysa upp och träna om hela modellen för flera GPU:er kunde inte implementeras.

Träningen övervakades utan användning. tensorboard, begränsar oss till att spela in loggar och spara modeller med informativa namn efter varje epok:

Återuppringningar

# Шаблон имени файла лога
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. Istället för en slutsats

Ett antal problem som vi har stött på har ännu inte övervunnits:

  • в Keras det finns ingen färdig funktion för att automatiskt söka efter den optimala inlärningshastigheten (analog lr_finder i biblioteket snabbt.ai); Med viss ansträngning är det möjligt att porta implementeringar från tredje part till R, till exempel, detta;
  • som en konsekvens av föregående punkt var det inte möjligt att välja rätt träningshastighet vid användning av flera GPU:er;
  • det finns en brist på moderna neurala nätverksarkitekturer, särskilt de som är förutbildade på imagenet;
  • ingen cykelpolicy och diskriminerande inlärningshastigheter (cosinusglödgning var på vår begäran genomförs, tack skeydan).

Vilka användbara saker lärde man sig från den här tävlingen:

  • På hårdvara med relativt låg effekt kan du arbeta med anständiga (många gånger storleken på RAM) datavolymer utan smärta. Plastpåse datatabell sparar minne på grund av modifiering på plats av tabeller, vilket undviker att kopiera dem, och när de används på rätt sätt visar dess kapacitet nästan alltid den högsta hastigheten bland alla verktyg som vi känner till för skriptspråk. Genom att spara data i en databas kan du i många fall inte tänka alls på behovet av att klämma in hela datamängden i RAM.
  • Långsamma funktioner i R kan ersättas med snabba i C++ med hjälp av paketet Rcpp. Om förutom att använda RcppThread eller RcppParallell, vi får flertrådiga implementeringar över plattformar, så det finns inget behov av att parallellisera koden på R-nivån.
  • Paket Rcpp kan användas utan seriös kunskap om C++, det erforderliga minimumet anges här. Header-filer för ett antal coola C-bibliotek som xtensor tillgänglig på CRAN, det vill säga en infrastruktur bildas för implementering av projekt som integrerar färdig högpresterande C++-kod i R. Ytterligare bekvämlighet är syntaxmarkering och en statisk C++-kodanalysator i RStudio.
  • docpt låter dig köra fristående skript med parametrar. Detta är bekvämt att använda på en fjärrserver, inkl. under hamnarbetare. I RStudio är det obekvämt att genomföra många timmars experiment med att träna neurala nätverk, och det är inte alltid motiverat att installera IDE på själva servern.
  • Docker säkerställer kodportabilitet och reproducerbarhet av resultat mellan utvecklare med olika versioner av operativsystemet och bibliotek, samt enkel exekvering på servrar. Du kan starta hela träningspipen med bara ett kommando.
  • Google Cloud är ett budgetvänligt sätt att experimentera med dyr hårdvara, men du måste välja konfigurationer noggrant.
  • Att mäta hastigheten för enskilda kodfragment är mycket användbart, särskilt när man kombinerar R och C++ och med paketet bänk - också väldigt lätt.

Sammantaget var den här upplevelsen mycket givande och vi fortsätter att arbeta för att lösa några av de problem som tagits upp.

Källa: will.com

Lägg en kommentar