Quick Draw Doodle Recognition: come fare amicizia con R, C++ e reti neurali

Quick Draw Doodle Recognition: come fare amicizia con R, C++ e reti neurali

Ehi Habr!

Lo scorso autunno, Kaggle ha ospitato un concorso per classificare le immagini disegnate a mano, Quick Draw Doodle Recognition, a cui ha preso parte, tra gli altri, un team di scienziati R: Artem Klevtsova, Direttore Filippa и Andrej Ogurtsov. Non descriveremo il concorso nei dettagli, questo è già stato fatto recente pubblicazione.

Questa volta non ha funzionato con l'allevamento di medaglie, ma è stata acquisita molta preziosa esperienza, quindi vorrei raccontare alla comunità alcune delle cose più interessanti e utili su Kagle e nel lavoro quotidiano. Tra gli argomenti trattati: la vita difficile senza OpenCV, Analisi JSON (questi esempi esaminano l'integrazione del codice C++ in script o pacchetti in R utilizzando Rcpp), parametrizzazione degli script e dockerizzazione della soluzione finale. Tutto il codice del messaggio in una forma adatta per l'esecuzione è disponibile in repository.

Contenuto:

  1. Carica in modo efficiente i dati da CSV a MonetDB
  2. Preparazione dei lotti
  3. Iteratori per lo scarico dei batch dal database
  4. Selezione di un'architettura del modello
  5. Parametrizzazione dello script
  6. Dockerizzazione degli script
  7. Utilizzo di più GPU su Google Cloud
  8. Invece di una conclusione

1. Carica in modo efficiente i dati da CSV nel database MonetDB

I dati di questo concorso non vengono forniti sotto forma di immagini già pronte, ma sotto forma di 340 file CSV (un file per ogni classe) contenenti JSON con coordinate di punti. Collegando questi punti con delle linee, otteniamo un'immagine finale che misura 256x256 pixel. Inoltre per ogni record è presente un'etichetta che indica se l'immagine è stata correttamente riconosciuta dal classificatore utilizzato al momento della raccolta del dataset, un codice di due lettere del paese di residenza dell'autore dell'immagine, un identificatore univoco, un timestamp e un nome di classe che corrisponde al nome del file. Una versione semplificata dei dati originali pesa 7.4 GB nell'archivio e circa 20 GB dopo l'estrazione, i dati completi dopo l'estrazione occupano 240 GB. Gli organizzatori hanno assicurato che entrambe le versioni riproducessero gli stessi disegni, il che significa che la versione completa era ridondante. In ogni caso, archiviare 50 milioni di immagini in file grafici o sotto forma di array è stato subito considerato non redditizio e abbiamo deciso di unire tutti i file CSV dall'archivio treno_simplificato.zip nel database con successiva generazione “al volo” di immagini della dimensione richiesta per ciascun lotto.

Come DBMS è stato scelto un sistema ben collaudato Monet DB, ovvero un'implementazione per R come pacchetto MonetDBLite. Il pacchetto include una versione incorporata del server database e consente di prelevare il server direttamente da una sessione R e lavorarci lì. La creazione di un database e la connessione ad esso vengono eseguite con un comando:

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

Dovremo creare due tabelle: una per tutti i dati, l'altra per le informazioni di servizio sui file scaricati (utile se qualcosa va storto e il processo deve essere ripreso dopo aver scaricato diversi file):

Creazione di tabelle

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

Il modo più veloce per caricare i dati nel database era copiare direttamente i file CSV utilizzando il comando SQL COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTDove tablename - nome della tabella e path - il percorso del file. Lavorando con l'archivio, si è scoperto che l'implementazione integrata unzip in R non funziona correttamente con un numero di file dall'archivio, quindi abbiamo utilizzato il sistema unzip (utilizzando il parametro getOption("unzip")).

Funzione per scrivere nel database

#' @title Извлечение и загрузка файлов
#'
#' @description
#' Извлечение CSV-файлов из ZIP-архива и загрузка их в базу данных
#'
#' @param con Объект подключения к базе данных (класс `MonetDBEmbeddedConnection`).
#' @param tablename Название таблицы в базе данных.
#' @oaram zipfile Путь к ZIP-архиву.
#' @oaram filename Имя файла внури ZIP-архива.
#' @param preprocess Функция предобработки, которая будет применена извлечённому файлу.
#'   Должна принимать один аргумент `data` (объект `data.table`).
#'
#' @return `TRUE`.
#'
upload_file <- function(con, tablename, zipfile, filename, preprocess = NULL) {
  # Проверка аргументов
  checkmate::assert_class(con, "MonetDBEmbeddedConnection")
  checkmate::assert_string(tablename)
  checkmate::assert_string(filename)
  checkmate::assert_true(DBI::dbExistsTable(con, tablename))
  checkmate::assert_file_exists(zipfile, access = "r", extension = "zip")
  checkmate::assert_function(preprocess, args = c("data"), null.ok = TRUE)

  # Извлечение файла
  path <- file.path(tempdir(), filename)
  unzip(zipfile, files = filename, exdir = tempdir(), 
        junkpaths = TRUE, unzip = getOption("unzip"))
  on.exit(unlink(file.path(path)))

  # Применяем функция предобработки
  if (!is.null(preprocess)) {
    .data <- data.table::fread(file = path)
    .data <- preprocess(data = .data)
    data.table::fwrite(x = .data, file = path, append = FALSE)
    rm(.data)
  }

  # Запрос к БД на импорт CSV
  sql <- sprintf(
    "COPY OFFSET 2 INTO %s FROM '%s' USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORT",
    tablename, path
  )
  # Выполнение запроса к БД
  DBI::dbExecute(con, sql)

  # Добавление записи об успешной загрузке в служебную таблицу
  DBI::dbExecute(con, sprintf("INSERT INTO upload_log(file_name, uploaded) VALUES('%s', true)",
                              filename))

  return(invisible(TRUE))
}

Se è necessario trasformare la tabella prima di scriverla nel database, è sufficiente passare l'argomento preprocess funzione che trasformerà i dati.

Codice per il caricamento sequenziale dei dati nel database:

Scrittura dei dati nel database

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

Il tempo di caricamento dei dati può variare a seconda delle caratteristiche di velocità dell'unità utilizzata. Nel nostro caso, leggere e scrivere all'interno di un SSD o da un'unità flash (file sorgente) a un SSD (DB) richiede meno di 10 minuti.

Sono necessari alcuni secondi in più per creare una colonna con un'etichetta di classe intera e una colonna di indice (ORDERED INDEX) con i numeri di riga in base ai quali verranno campionate le osservazioni durante la creazione dei batch:

Creazione di colonne e indici aggiuntivi

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

Per risolvere il problema della creazione di un batch al volo, dovevamo raggiungere la massima velocità di estrazione di righe casuali dalla tabella doodles. Per questo abbiamo utilizzato 3 trucchi. Il primo era ridurre la dimensionalità del tipo che memorizza l'ID dell'osservazione. Nel set di dati originale, il tipo richiesto per memorizzare l'ID è bigint, ma il numero di osservazioni rende possibile adattare i loro identificatori, pari al numero ordinale, nel tipo int. In questo caso la ricerca è molto più veloce. Il secondo trucco era usare ORDERED INDEX — siamo giunti a questa decisione empiricamente, dopo aver esaminato tutto ciò che era disponibile opzioni. Il terzo consisteva nell'utilizzare query con parametri. L'essenza del metodo è eseguire il comando una volta PREPARE con successivo utilizzo di un'espressione preparata durante la creazione di un gruppo di query dello stesso tipo, ma in realtà c'è un vantaggio rispetto a una semplice SELECT risulta rientrare nell’intervallo di errore statistico.

Il processo di caricamento dei dati non consuma più di 450 MB di RAM. Cioè, l'approccio descritto ti consente di spostare set di dati del peso di decine di gigabyte su quasi tutti gli hardware economici, inclusi alcuni dispositivi a scheda singola, il che è piuttosto interessante.

Non resta che misurare la velocità di recupero dei dati (casuali) e valutare la scala durante il campionamento di lotti di dimensioni diverse:

Punto di riferimento della banca dati

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: come fare amicizia con R, C++ e reti neurali

2. Preparazione dei lotti

L'intero processo di preparazione del batch consiste nei seguenti passaggi:

  1. Analisi di diversi JSON contenenti vettori di stringhe con coordinate di punti.
  2. Disegnare linee colorate in base alle coordinate dei punti su un'immagine della dimensione richiesta (ad esempio 256×256 o 128×128).
  3. Convertire le immagini risultanti in un tensore.

Nell'ambito della competizione tra i kernel Python, il problema è stato risolto principalmente utilizzando OpenCV. Uno degli analoghi più semplici e ovvi in ​​R sarebbe simile a questo:

Implementazione della conversione da JSON a tensore in R

r_process_json_str <- function(json, line.width = 3, 
                               color = TRUE, scale = 1) {
  # Парсинг JSON
  coords <- jsonlite::fromJSON(json, simplifyMatrix = FALSE)
  tmp <- tempfile()
  # Удаляем временный файл по завершению функции
  on.exit(unlink(tmp))
  png(filename = tmp, width = 256 * scale, height = 256 * scale, pointsize = 1)
  # Пустой график
  plot.new()
  # Размер окна графика
  plot.window(xlim = c(256 * scale, 0), ylim = c(256 * scale, 0))
  # Цвета линий
  cols <- if (color) rainbow(length(coords)) else "#000000"
  for (i in seq_along(coords)) {
    lines(x = coords[[i]][[1]] * scale, y = coords[[i]][[2]] * scale, 
          col = cols[i], lwd = line.width)
  }
  dev.off()
  # Преобразование изображения в 3-х мерный массив
  res <- png::readPNG(tmp)
  return(res)
}

r_process_json_vector <- function(x, ...) {
  res <- lapply(x, r_process_json_str, ...)
  # Объединение 3-х мерных массивов картинок в 4-х мерный в тензор
  res <- do.call(abind::abind, c(res, along = 0))
  return(res)
}

Il disegno viene eseguito utilizzando gli strumenti R standard e salvato in un PNG temporaneo archiviato nella RAM (su Linux, le directory temporanee di R si trovano nella directory /tmp, montato nella RAM). Questo file viene quindi letto come un array tridimensionale con numeri compresi tra 0 e 1. Questo è importante perché un BMP più convenzionale verrebbe letto in un array grezzo con codici colore esadecimali.

Testiamo il risultato:

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: come fare amicizia con R, C++ e reti neurali

Il lotto stesso sarà formato come segue:

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

Questa implementazione ci è sembrata non ottimale, poiché la formazione di grandi lotti richiede tempi indecentemente lunghi, e abbiamo deciso di sfruttare l'esperienza dei nostri colleghi utilizzando una potente libreria OpenCV. A quel tempo non esisteva un pacchetto pronto per R (non ce n'è adesso), quindi un'implementazione minima della funzionalità richiesta è stata scritta in C++ con integrazione nel codice R utilizzando Rcpp.

Per risolvere il problema sono stati utilizzati i seguenti pacchetti e librerie:

  1. OpenCV per lavorare con immagini e disegnare linee. Utilizzate librerie di sistema e file di intestazione preinstallati, nonché collegamento dinamico.

  2. tensore per lavorare con array e tensori multidimensionali. Abbiamo utilizzato i file header inclusi nel pacchetto R con lo stesso nome. La libreria consente di lavorare con array multidimensionali, sia nell'ordine principale della riga che in quello principale della colonna.

  3. ndjson per l'analisi JSON. Questa libreria è utilizzata in tensore automaticamente se è presente nel progetto.

  4. RcppThread per organizzare l'elaborazione multi-thread di un vettore da JSON. Utilizzati i file header forniti da questo pacchetto. Da più popolare RcppParallel Il pacchetto, tra le altre cose, ha un meccanismo di interruzione del loop integrato.

Va notato che tensore si è rivelata una manna dal cielo: oltre ad avere funzionalità estese e prestazioni elevate, i suoi sviluppatori si sono rivelati abbastanza reattivi e hanno risposto alle domande in modo tempestivo e dettagliato. Con il loro aiuto, è stato possibile implementare trasformazioni di matrici OpenCV in tensori xtensori, nonché un modo per combinare tensori di immagini tridimensionali in un tensore quadridimensionale della dimensione corretta (il batch stesso).

Materiali per l'apprendimento di Rcpp, xtensor e 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

Per compilare file che utilizzano file di sistema e collegamento dinamico con le librerie installate sul sistema, abbiamo utilizzato il meccanismo plugin implementato nel pacchetto Rcpp. Per trovare automaticamente percorsi e flag, abbiamo utilizzato una popolare utility Linux pkg-config.

Implementazione del plugin Rcpp per l'utilizzo della libreria OpenCV

Rcpp::registerPlugin("opencv", function() {
  # Возможные названия пакета
  pkg_config_name <- c("opencv", "opencv4")
  # Бинарный файл утилиты pkg-config
  pkg_config_bin <- Sys.which("pkg-config")
  # Проврека наличия утилиты в системе
  checkmate::assert_file_exists(pkg_config_bin, access = "x")
  # Проверка наличия файла настроек OpenCV для pkg-config
  check <- sapply(pkg_config_name, 
                  function(pkg) system(paste(pkg_config_bin, pkg)))
  if (all(check != 0)) {
    stop("OpenCV config for the pkg-config not found", call. = FALSE)
  }

  pkg_config_name <- pkg_config_name[check == 0]
  list(env = list(
    PKG_CXXFLAGS = system(paste(pkg_config_bin, "--cflags", pkg_config_name), 
                          intern = TRUE),
    PKG_LIBS = system(paste(pkg_config_bin, "--libs", pkg_config_name), 
                      intern = TRUE)
  ))
})

Come risultato del funzionamento del plugin, durante il processo di compilazione verranno sostituiti i seguenti valori:

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"

Il codice di implementazione per l'analisi di JSON e la generazione di un batch per la trasmissione al modello è riportato sotto lo spoiler. Innanzitutto, aggiungi una directory di progetto locale per cercare i file di intestazione (necessari per ndjson):

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

Implementazione della conversione da JSON a tensore in C++

// [[Rcpp::plugins(cpp14)]]
// [[Rcpp::plugins(opencv)]]
// [[Rcpp::depends(xtensor)]]
// [[Rcpp::depends(RcppThread)]]

#include <xtensor/xjson.hpp>
#include <xtensor/xadapt.hpp>
#include <xtensor/xview.hpp>
#include <xtensor-r/rtensor.hpp>
#include <opencv2/core/core.hpp>
#include <opencv2/highgui/highgui.hpp>
#include <opencv2/imgproc/imgproc.hpp>
#include <Rcpp.h>
#include <RcppThread.h>

// Синонимы для типов
using RcppThread::parallelFor;
using json = nlohmann::json;
using points = xt::xtensor<double,2>;     // Извлечённые из JSON координаты точек
using strokes = std::vector<points>;      // Извлечённые из JSON координаты точек
using xtensor3d = xt::xtensor<double, 3>; // Тензор для хранения матрицы изоображения
using xtensor4d = xt::xtensor<double, 4>; // Тензор для хранения множества изображений
using rtensor3d = xt::rtensor<double, 3>; // Обёртка для экспорта в R
using rtensor4d = xt::rtensor<double, 4>; // Обёртка для экспорта в R

// Статические константы
// Размер изображения в пикселях
const static int SIZE = 256;
// Тип линии
// См. https://en.wikipedia.org/wiki/Pixel_connectivity#2-dimensional
const static int LINE_TYPE = cv::LINE_4;
// Толщина линии в пикселях
const static int LINE_WIDTH = 3;
// Алгоритм ресайза
// https://docs.opencv.org/3.1.0/da/d54/group__imgproc__transform.html#ga5bb5a1fea74ea38e1a5445ca803ff121
const static int RESIZE_TYPE = cv::INTER_LINEAR;

// Шаблон для конвертирования OpenCV-матрицы в тензор
template <typename T, int NCH, typename XT=xt::xtensor<T,3,xt::layout_type::column_major>>
XT to_xt(const cv::Mat_<cv::Vec<T, NCH>>& src) {
  // Размерность целевого тензора
  std::vector<int> shape = {src.rows, src.cols, NCH};
  // Общее количество элементов в массиве
  size_t size = src.total() * NCH;
  // Преобразование cv::Mat в xt::xtensor
  XT res = xt::adapt((T*) src.data, size, xt::no_ownership(), shape);
  return res;
}

// Преобразование JSON в список координат точек
strokes parse_json(const std::string& x) {
  auto j = json::parse(x);
  // Результат парсинга должен быть массивом
  if (!j.is_array()) {
    throw std::runtime_error("'x' must be JSON array.");
  }
  strokes res;
  res.reserve(j.size());
  for (const auto& a: j) {
    // Каждый элемент массива должен быть 2-мерным массивом
    if (!a.is_array() || a.size() != 2) {
      throw std::runtime_error("'x' must include only 2d arrays.");
    }
    // Извлечение вектора точек
    auto p = a.get<points>();
    res.push_back(p);
  }
  return res;
}

// Отрисовка линий
// Цвета HSV
cv::Mat ocv_draw_lines(const strokes& x, bool color = true) {
  // Исходный тип матрицы
  auto stype = color ? CV_8UC3 : CV_8UC1;
  // Итоговый тип матрицы
  auto dtype = color ? CV_32FC3 : CV_32FC1;
  auto bg = color ? cv::Scalar(0, 0, 255) : cv::Scalar(255);
  auto col = color ? cv::Scalar(0, 255, 220) : cv::Scalar(0);
  cv::Mat img = cv::Mat(SIZE, SIZE, stype, bg);
  // Количество линий
  size_t n = x.size();
  for (const auto& s: x) {
    // Количество точек в линии
    size_t n_points = s.shape()[1];
    for (size_t i = 0; i < n_points - 1; ++i) {
      // Точка начала штриха
      cv::Point from(s(0, i), s(1, i));
      // Точка окончания штриха
      cv::Point to(s(0, i + 1), s(1, i + 1));
      // Отрисовка линии
      cv::line(img, from, to, col, LINE_WIDTH, LINE_TYPE);
    }
    if (color) {
      // Меняем цвет линии
      col[0] += 180 / n;
    }
  }
  if (color) {
    // Меняем цветовое представление на RGB
    cv::cvtColor(img, img, cv::COLOR_HSV2RGB);
  }
  // Меняем формат представления на float32 с диапазоном [0, 1]
  img.convertTo(img, dtype, 1 / 255.0);
  return img;
}

// Обработка JSON и получение тензора с данными изображения
xtensor3d process(const std::string& x, double scale = 1.0, bool color = true) {
  auto p = parse_json(x);
  auto img = ocv_draw_lines(p, color);
  if (scale != 1) {
    cv::Mat out;
    cv::resize(img, out, cv::Size(), scale, scale, RESIZE_TYPE);
    cv::swap(img, out);
    out.release();
  }
  xtensor3d arr = color ? to_xt<double,3>(img) : to_xt<double,1>(img);
  return arr;
}

// [[Rcpp::export]]
rtensor3d cpp_process_json_str(const std::string& x, 
                               double scale = 1.0, 
                               bool color = true) {
  xtensor3d res = process(x, scale, color);
  return res;
}

// [[Rcpp::export]]
rtensor4d cpp_process_json_vector(const std::vector<std::string>& x, 
                                  double scale = 1.0, 
                                  bool color = false) {
  size_t n = x.size();
  size_t dim = floor(SIZE * scale);
  size_t channels = color ? 3 : 1;
  xtensor4d res({n, dim, dim, channels});
  parallelFor(0, n, [&x, &res, scale, color](int i) {
    xtensor3d tmp = process(x[i], scale, color);
    auto view = xt::view(res, i, xt::all(), xt::all(), xt::all());
    view = tmp;
  });
  return res;
}

Questo codice dovrebbe essere inserito nel file src/cv_xt.cpp e compilare con il comando Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); richiesto anche per lavoro nlohmann/json.hpp di deposito. Il codice è suddiviso in diverse funzioni:

  • to_xt — una funzione basata su modelli per trasformare una matrice di immagini (cv::Mat) a un tensore xt::xtensor;

  • parse_json — la funzione analizza una stringa JSON, estrae le coordinate dei punti, impacchettandole in un vettore;

  • ocv_draw_lines — dal vettore di punti risultante, disegna linee multicolori;

  • process — combina le funzioni di cui sopra e aggiunge anche la possibilità di ridimensionare l'immagine risultante;

  • cpp_process_json_str - wrapper sopra la funzione process, che esporta il risultato in un oggetto R (array multidimensionale);

  • cpp_process_json_vector - wrapper sopra la funzione cpp_process_json_str, che consente di elaborare un vettore di stringa in modalità multi-thread.

Per disegnare linee multicolori è stato utilizzato il modello di colore HSV, seguito dalla conversione in RGB. Testiamo il risultato:

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

Quick Draw Doodle Recognition: come fare amicizia con R, C++ e reti neurali
Confronto della velocità delle implementazioni in R e 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: come fare amicizia con R, C++ e reti neurali

Come puoi vedere, l'aumento di velocità si è rivelato molto significativo e non è possibile raggiungere il codice C++ parallelizzando il codice R.

3. Iteratori per lo scarico dei batch dal database

R ha una meritata reputazione per l'elaborazione dei dati che si adattano alla RAM, mentre Python è più caratterizzato dall'elaborazione iterativa dei dati, che consente di implementare facilmente e naturalmente calcoli out-of-core (calcoli utilizzando memoria esterna). Un esempio classico e rilevante per noi nel contesto del problema descritto sono le reti neurali profonde addestrate con il metodo della discesa del gradiente con approssimazione del gradiente ad ogni passaggio utilizzando una piccola porzione di osservazioni, o mini-batch.

I framework di deep learning scritti in Python hanno classi speciali che implementano iteratori basati su dati: tabelle, immagini in cartelle, formati binari, ecc. Puoi utilizzare opzioni già pronte o scriverne di tue per attività specifiche. In R possiamo sfruttare tutte le funzionalità della libreria Python keras con i suoi vari backend utilizzando il pacchetto con lo stesso nome, che a sua volta funziona sopra il pacchetto reticolare. Quest'ultimo merita un lungo articolo a parte; non solo consente di eseguire codice Python da R, ma consente anche di trasferire oggetti tra sessioni R e Python, eseguendo automaticamente tutte le conversioni di tipo necessarie.

Abbiamo eliminato la necessità di archiviare tutti i dati nella RAM utilizzando MonetDBLite, tutto il lavoro della "rete neurale" verrà eseguito dal codice originale in Python, dobbiamo solo scrivere un iteratore sui dati, poiché non c'è nulla di pronto per una situazione del genere in R o Python. Ci sono essenzialmente solo due requisiti: deve restituire batch in un ciclo infinito e salvare il suo stato tra le iterazioni (quest'ultimo in R è implementato nel modo più semplice utilizzando le chiusure). In precedenza, era necessario convertire esplicitamente gli array R in array numpy all'interno dell'iteratore, ma la versione attuale del pacchetto keras lo fa da sola.

L'iteratore per i dati di training e validazione si è rivelato il seguente:

Iteratore per i dati di training e validazione

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

La funzione prende come input una variabile con connessione al database, il numero di righe utilizzate, il numero di classi, la dimensione del batch, la scala (scale = 1 corrisponde al rendering di immagini di 256x256 pixel, scale = 0.5 — 128x128 pixel), indicatore di colore (color = FALSE specifica il rendering in scala di grigi quando utilizzato color = TRUE ogni tratto viene disegnato con un nuovo colore) e un indicatore di preelaborazione per le reti pre-addestrate su imagenet. Quest'ultimo è necessario per scalare i valori dei pixel dall'intervallo [0, 1] all'intervallo [-1, 1], utilizzato durante l'addestramento del programma fornito keras modelli.

La funzione esterna contiene il controllo del tipo di argomento, una tabella data.table con numeri di riga mescolati casualmente da samples_index e numeri di batch, contatore e numero massimo di batch, nonché un'espressione SQL per scaricare i dati dal database. Inoltre, abbiamo definito un veloce analogo della funzione all'interno keras::to_categorical(). Abbiamo utilizzato quasi tutti i dati per l'addestramento, lasciando metà percentuale per la convalida, quindi la dimensione dell'epoca era limitata dal parametro steps_per_epoch quando chiamato keras::fit_generator()e la condizione if (i > max_i) ha funzionato solo per l'iteratore di convalida.

Nella funzione interna vengono recuperati gli indici delle righe per il batch successivo, i record vengono scaricati dal database con l'incremento del contatore batch, l'analisi JSON (funzione cpp_process_json_vector(), scritto in C++) e creando array corrispondenti alle immagini. Quindi vengono creati vettori one-hot con etichette di classe, gli array con valori di pixel ed etichette vengono combinati in un elenco, che è il valore restituito. Per velocizzare il lavoro, abbiamo utilizzato la creazione di indici nelle tabelle data.table e modifica tramite il collegamento - senza questi "chip" del pacchetto tabella dati È abbastanza difficile immaginare di lavorare in modo efficace con una quantità significativa di dati in R.

I risultati delle misurazioni della velocità su un laptop Core i5 sono i seguenti:

Punto di riferimento dell'iteratore

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: come fare amicizia con R, C++ e reti neurali

Se disponi di una quantità sufficiente di RAM, puoi accelerare notevolmente il funzionamento del database trasferendolo su questa stessa RAM (32 GB sono sufficienti per il nostro compito). In Linux, la partizione è montata per impostazione predefinita /dev/shm, occupando fino alla metà della capacità della RAM. Puoi evidenziarne di più modificandoli /etc/fstabper ottenere un record come tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Assicurati di riavviare e controlla il risultato eseguendo il comando df -h.

L'iteratore per i dati di test sembra molto più semplice, poiché il set di dati di test si inserisce interamente nella RAM:

Iteratore per i dati di test

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. Selezione dell'architettura del modello

La prima architettura utilizzata è stata rete mobile v1, le cui caratteristiche sono discusse in Questo Messaggio. È incluso di serie keras e, di conseguenza, è disponibile nel pacchetto con lo stesso nome per R. Ma provando a usarlo con immagini a canale singolo, si è verificata una cosa strana: il tensore di ingresso deve sempre avere la dimensione (batch, height, width, 3), ovvero il numero di canali non può essere modificato. Non esiste tale limitazione in Python, quindi ci siamo affrettati a scrivere la nostra implementazione di questa architettura, seguendo l'articolo originale (senza il dropout presente nella versione Keras):

Architettura Mobilenet v1

library(keras)

top_3_categorical_accuracy <- custom_metric(
    name = "top_3_categorical_accuracy",
    metric_fn = function(y_true, y_pred) {
         metric_top_k_categorical_accuracy(y_true, y_pred, k = 3)
    }
)

layer_sep_conv_bn <- function(object, 
                              filters,
                              alpha = 1,
                              depth_multiplier = 1,
                              strides = c(2, 2)) {

  # NB! depth_multiplier !=  resolution multiplier
  # https://github.com/keras-team/keras/issues/10349

  layer_depthwise_conv_2d(
    object = object,
    kernel_size = c(3, 3), 
    strides = strides,
    padding = "same",
    depth_multiplier = depth_multiplier
  ) %>%
  layer_batch_normalization() %>% 
  layer_activation_relu() %>%
  layer_conv_2d(
    filters = filters * alpha,
    kernel_size = c(1, 1), 
    strides = c(1, 1)
  ) %>%
  layer_batch_normalization() %>% 
  layer_activation_relu() 
}

get_mobilenet_v1 <- function(input_shape = c(224, 224, 1),
                             num_classes = 340,
                             alpha = 1,
                             depth_multiplier = 1,
                             optimizer = optimizer_adam(lr = 0.002),
                             loss = "categorical_crossentropy",
                             metrics = c("categorical_crossentropy",
                                         top_3_categorical_accuracy)) {

  inputs <- layer_input(shape = input_shape)

  outputs <- inputs %>%
    layer_conv_2d(filters = 32, kernel_size = c(3, 3), strides = c(2, 2), padding = "same") %>%
    layer_batch_normalization() %>% 
    layer_activation_relu() %>%
    layer_sep_conv_bn(filters = 64, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 128, strides = c(2, 2)) %>%
    layer_sep_conv_bn(filters = 128, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 256, strides = c(2, 2)) %>%
    layer_sep_conv_bn(filters = 256, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(2, 2)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 1024, strides = c(2, 2)) %>%
    layer_sep_conv_bn(filters = 1024, strides = c(1, 1)) %>%
    layer_global_average_pooling_2d() %>%
    layer_dense(units = num_classes) %>%
    layer_activation_softmax()

    model <- keras_model(
      inputs = inputs,
      outputs = outputs
    )

    model %>% compile(
      optimizer = optimizer,
      loss = loss,
      metrics = metrics
    )

    return(model)
}

Gli svantaggi di questo approccio sono evidenti. Voglio testare molti modelli, ma al contrario, non voglio riscrivere manualmente ogni architettura. Siamo stati inoltre privati ​​dell'opportunità di utilizzare i pesi dei modelli pre-addestrati su imagenet. Come al solito, lo studio della documentazione ha aiutato. Funzione get_config() consente di ottenere una descrizione del modello in un formato adatto alla modifica (base_model_conf$layers - un elenco R regolare) e la funzione from_config() esegue la conversione inversa in un oggetto del modello:

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)

Ora non è difficile scrivere una funzione universale per ottenere uno qualsiasi di quelli forniti keras modelli con o senza pesi allenati su imagenet:

Funzione per caricare architetture già pronte

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

Quando si utilizzano immagini a canale singolo, non vengono utilizzati pesi preaddestrati. Questo potrebbe essere risolto: utilizzando la funzione get_weights() ottieni i pesi del modello sotto forma di un elenco di array R, modifica la dimensione del primo elemento di questo elenco (prendendo un canale di colore o calcolando la media di tutti e tre), quindi carica nuovamente i pesi nel modello con la funzione set_weights(). Non abbiamo mai aggiunto questa funzionalità, perché in questa fase era già chiaro che sarebbe stato più produttivo lavorare con immagini a colori.

Abbiamo effettuato la maggior parte degli esperimenti utilizzando le versioni mobilenet 1 e 2, nonché resnet34. Architetture più moderne come SE-ResNeXt hanno ottenuto buoni risultati in questa competizione. Sfortunatamente, non avevamo a nostra disposizione implementazioni già pronte e non abbiamo scritto le nostre (ma le scriveremo sicuramente).

5. Parametrizzazione degli script

Per comodità, tutto il codice per l'avvio dell'addestramento è stato progettato come un unico script, parametrizzato utilizzando dottoressa следующим обрахом:

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)

Pacchetto dottoressa rappresenta l'implementazione http://docopt.org/ per R. Con il suo aiuto, gli script vengono avviati con semplici comandi come Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db o ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, se file train_nn.R è eseguibile (questo comando inizierà l'addestramento del modello resnet50 su immagini a tre colori di dimensione 128x128 pixel il database deve trovarsi nella cartella /home/andrey/doodle_db). Puoi aggiungere all'elenco la velocità di apprendimento, il tipo di ottimizzatore e qualsiasi altro parametro personalizzabile. Nel processo di preparazione della pubblicazione, si è scoperto che l'architettura mobilenet_v2 dalla versione attuale keras nell'uso R non deve a causa di modifiche non prese in considerazione nel pacchetto R, stiamo aspettando che risolvano il problema.

Questo approccio ha permesso di velocizzare notevolmente la sperimentazione con modelli diversi rispetto al più tradizionale lancio di script in RStudio (segnaliamo il pacchetto come possibile alternativa tfruns). Ma il vantaggio principale è la possibilità di gestire facilmente l'avvio degli script in Docker o semplicemente sul server, senza installare RStudio per questo.

6. Dockerizzazione degli script

Abbiamo utilizzato Docker per garantire la portabilità dell'ambiente per i modelli di formazione tra i membri del team e per una rapida implementazione nel cloud. Puoi iniziare a familiarizzare con questo strumento, relativamente insolito per un programmatore R, con questo serie di pubblicazioni o videocorso.

Docker ti consente sia di creare le tue immagini da zero sia di utilizzare altre immagini come base per crearne di tue. Analizzando le opzioni disponibili, siamo giunti alla conclusione che l'installazione dei driver NVIDIA, CUDA+cuDNN e delle librerie Python è una parte abbastanza voluminosa dell'immagine e abbiamo deciso di prendere come base l'immagine ufficiale tensorflow/tensorflow:1.12.0-gpu, aggiungendo lì i pacchetti R necessari.

Il file docker finale assomigliava a questo:

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

Per comodità, i pacchetti utilizzati sono stati inseriti in variabili; la maggior parte degli script scritti vengono copiati all'interno dei contenitori in fase di assemblaggio. Abbiamo anche cambiato la shell dei comandi in /bin/bash per facilitare la fruizione dei contenuti /etc/os-release. Ciò ha evitato la necessità di specificare la versione del sistema operativo nel codice.

Inoltre, è stato scritto un piccolo script bash che consente di avviare un contenitore con vari comandi. Potrebbero ad esempio trattarsi di script per l'addestramento delle reti neurali precedentemente posizionati all'interno del contenitore o di una shell di comandi per il debug e il monitoraggio del funzionamento del contenitore:

Script per avviare il contenitore

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

Se questo script bash viene eseguito senza parametri, lo script verrà chiamato all'interno del contenitore train_nn.R con valori predefiniti; se il primo argomento posizionale è "bash", il contenitore verrà avviato in modo interattivo con una shell di comandi. In tutti gli altri casi, i valori degli argomenti posizionali vengono sostituiti: CMD="Rscript /app/train_nn.R $@".

Vale la pena notare che le directory con i dati di origine e il database, nonché la directory per il salvataggio dei modelli addestrati, sono montate all'interno del contenitore dal sistema host, il che consente di accedere ai risultati degli script senza manipolazioni inutili.

7. Utilizzo di più GPU su Google Cloud

Una delle caratteristiche del concorso erano i dati molto rumorosi (vedi l'immagine del titolo, presa in prestito da @Leigh.plt da ODS slack). Grandi lotti aiutano a combattere questo problema e, dopo gli esperimenti su un PC con 1 GPU, abbiamo deciso di padroneggiare i modelli di allenamento su diverse GPU nel cloud. Utilizzato GoogleCloud (buona guida alle nozioni di base) grazie all'ampia selezione di configurazioni disponibili, prezzi ragionevoli e bonus di $ 300. Per avidità, ho ordinato un'istanza 4xV100 con un SSD e un sacco di RAM, e questo è stato un grosso errore. Una macchina del genere consuma rapidamente denaro; puoi andare in rovina sperimentando senza una pipeline collaudata. Per scopi didattici è meglio prendere il K80. Ma la grande quantità di RAM è tornata utile: l'SSD cloud non ha impressionato con le sue prestazioni, quindi è stato trasferito il database dev/shm.

Di grande interesse è il frammento di codice responsabile dell'utilizzo di più GPU. Innanzitutto, il modello viene creato sulla CPU utilizzando un gestore di contesto, proprio come in Python:

with(tensorflow::tf$device("/cpu:0"), {
  model_cpu <- get_model(
    name = model_name,
    input_shape = input_shape,
    weights = weights,
    metrics =(top_3_categorical_accuracy,
    compile = FALSE
  )
})

Quindi il modello non compilato (questo è importante) viene copiato su un determinato numero di GPU disponibili e solo dopo viene compilato:

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

Non è stato possibile implementare la tecnica classica di congelamento di tutti i livelli tranne l'ultimo, addestramento dell'ultimo livello, scongelamento e riaddestramento dell'intero modello per diverse GPU.

La formazione è stata monitorata senza utilizzo. tavola tensoriale, limitandoci a registrare i log e a salvare modelli con nomi informativi dopo ogni epoca:

Richiamate

# Шаблон имени файла лога
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. Invece di una conclusione

Numerosi problemi che abbiamo riscontrato non sono ancora stati risolti:

  • в keras non esiste una funzione già pronta per la ricerca automatica del tasso di apprendimento ottimale (analogico lr_finder in biblioteca veloce.ai); Con un certo sforzo, è possibile trasferire implementazioni di terze parti su R, ad esempio, questo;
  • in conseguenza del punto precedente non è stato possibile selezionare la corretta velocità di training quando si utilizzano più GPU;
  • mancano le moderne architetture di rete neurale, in particolare quelle pre-addestrate su imagenet;
  • nessuna politica del ciclo unico e tassi di apprendimento discriminativi (la ricottura del coseno era su nostra richiesta implementatoGrazie skeydan).

Quali cose utili sono state apprese da questa competizione:

  • Su hardware relativamente a basso consumo, puoi lavorare con volumi di dati decenti (molte volte superiori alla dimensione della RAM) senza problemi. Sacchetto di plastica tabella dati risparmia memoria grazie alla modifica sul posto delle tabelle, che evita di copiarle e, se utilizzate correttamente, le sue capacità dimostrano quasi sempre la massima velocità tra tutti gli strumenti a noi noti per i linguaggi di scripting. Il salvataggio dei dati in un database consente, in molti casi, di non pensare affatto alla necessità di comprimere l'intero set di dati nella RAM.
  • Le funzioni lente in R possono essere sostituite con quelle veloci in C++ utilizzando il pacchetto Rcpp. Se oltre all'uso RcppThread o RcppParallel, otteniamo implementazioni multi-thread multipiattaforma, quindi non è necessario parallelizzare il codice a livello R.
  • Per pacchetto Rcpp può essere utilizzato senza una conoscenza approfondita del C++, viene delineato il minimo richiesto qui. File di intestazione per una serie di fantastiche librerie C come tensore disponibile su CRAN, ovvero si sta formando un'infrastruttura per l'implementazione di progetti che integrano codice C++ già pronto ad alte prestazioni in R. Ulteriore comodità è l'evidenziazione della sintassi e un analizzatore di codice C++ statico in RStudio.
  • dottoressa consente di eseguire script autonomi con parametri. Questo è comodo per l'uso su un server remoto, incl. sotto la finestra mobile. In RStudio è scomodo condurre molte ore di esperimenti con l'addestramento delle reti neurali e l'installazione dell'IDE sul server stesso non è sempre giustificata.
  • Docker garantisce la portabilità del codice e la riproducibilità dei risultati tra sviluppatori con diverse versioni del sistema operativo e delle librerie, nonché la facilità di esecuzione sui server. Puoi avviare l'intera pipeline di formazione con un solo comando.
  • Google Cloud è un modo economico per sperimentare hardware costoso, ma devi scegliere attentamente le configurazioni.
  • Misurare la velocità dei singoli frammenti di codice è molto utile, soprattutto quando si combinano R e C++ e con il pacchetto panchina - anche molto facile.

Nel complesso questa esperienza è stata molto gratificante e continuiamo a lavorare per risolvere alcune delle questioni sollevate.

Fonte: habr.com

Aggiungi un commento