
Ciao, Habr!
L'autunno scorso si è svolto su Kaggle un concorso per la classificazione di immagini disegnate a mano, Quick Draw Doodle Recognition, a cui ha partecipato, tra gli altri, un team di R. , e . Non approfondiremo troppo la competizione, poiché è stato già fatto in .
. Non abbiamo portato a casa medaglie questa volta, ma abbiamo acquisito molte esperienze preziose, quindi ci piacerebbe condividere con la comunità alcune delle tematiche più interessanti e utili su Kaggle e nel lavoro quotidiano. Tra gli argomenti trattati: la vita difficile senza OpenCV, il parsing di JSON (in questi esempi si esaminano le integrazioni del codice C++ in script o pacchetti R tramite Rcpp), la parametrizzazione degli script e la dockerizzazione della soluzione finale. Tutto il codice contenuto nel messaggio è disponibile in forma eseguibile in .
Contenuto:
1. Caricamento efficiente dei dati da CSV in un database MonetDB
I dati in questa competizione non vengono forniti sotto forma di immagini pronte, ma come 340 file CSV (uno per ogni classe), contenenti JSON con le coordinate dei punti. Unendo questi punti con delle linee, otteniamo l'immagine finale di dimensioni 256x256 pixel. Per ogni record è fornita anche un'etichetta che indica se l'immagine è stata riconosciuta correttamente dal classificatore utilizzato al momento della raccolta del dataset, un codice di due lettere del paese di residenza dell'autore del disegno, un identificatore unico, un timestamp e il nome della classe, che corrisponde al nome del file. Una versione semplificata dei dati originali pesa 7,4 GB in archivio e circa 20 GB dopo l'estrazione, mentre i dati completi occupano 240 GB dopo l'estrazione. Gli organizzatori hanno garantito che entrambe le versioni riproducono gli stessi disegni, quindi la versione completa è ridondante. In ogni caso, memorizzare 50 milioni di immagini in file grafici o in array è stato subito riconosciuto come economicamente non sostenibile, e abbiamo deciso di unire tutti i file CSV dall'archivio. train_simplified.zip in un database con generazione successiva delle immagini nella dimensione necessaria "al volo" per ogni batch.
Come sistema di gestione del database è stata scelta una soluzione ben collaudata MonetDB, ossia l'implementazione per R sotto forma di pacchetto . Il pacchetto include una versione embedded del server del database e consente di avviare il server direttamente dalla sessione R e lavorare con esso. La creazione del database e la connessione avvengono con un comando unico:
con <- DBI::dbConnect(drv = MonetDBLite::MonetDBLite(), Sys.getenv("DBDIR"))Dobbiamo creare due tabelle: una per tutti i dati e l'altra per le informazioni di servizio sui file caricati (utile nel caso in cui qualcosa vada storto e il processo debba essere ripreso dopo il caricamento di più file):
Creazione delle 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ù rapido per caricare i dati nel database si è rivelato essere la copia diretta dei file CSV tramite SQL — il comando COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORT, dove nome_tabella — nome della tabella e path — percorso del file. Durante la lavorazione con l'archivio è stato riscontrato che l'implementazione integrata unzip in R non funziona correttamente con diversi file dell'archivio, pertanto abbiamo utilizzato il sistema unzip (utilizzando il parametro getOption("unzip")).
Funzione per la scrittura 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))
}Nel caso in cui sia necessario trasformare la tabella prima della scrittura nel DB, è sufficiente passare come argomento preprocess la 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->SSDIl tempo di caricamento dei dati può variare a seconda delle caratteristiche di velocità del dispositivo di archiviazione utilizzato. Nel nostro caso, la lettura e la scrittura all'interno di un singolo SSD o da una chiavetta USB (file sorgente) a SSD (DB) richiede meno di 10 minuti.
Altri pochi secondi sono necessari per creare una colonna con un'etichetta di classe intera e una colonna indice (ORDERED INDEX) con i numeri di riga, su cui verranno selezionate le osservazioni durante la creazione dei batch:
Creazione di colonne e indici aggiuntivi
message("Genera etichette")
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("Genera numeri di riga")
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 affrontare il problema della creazione di un batch «al volo», abbiamo dovuto massimizzare la velocità di estrazione di righe casuali dalla tabella. doodles. A questo scopo abbiamo utilizzato 3 trucchi. Il primo consisteva nel ridurre la dimensionalità del tipo utilizzato per memorizzare l'ID dell'osservazione. Nel set di dati originale, per memorizzare l'ID è necessario un tipo bigint, ma il numero di osservazioni consente di contenere i loro identificatori, uguali al numero progressivo, in un tipo -int. La ricerca avviene quindi in modo significativamente più veloce. Il secondo trucco ha comportato l'uso di ORDERED INDEX — a questa soluzione siamo arrivati empiricamente, esaminando tutte le opzioni disponibili PREPARE e successivamente utilizzare l'espressione preparata per creare un insieme di richieste omogenee, ma in realtà il guadagno rispetto a un semplice con l'uso successivo dell'espressione preparata per creare una serie di richieste simili, ma in realtà il vantaggio rispetto a una semplice SELECT si è rivelato nell'area dell'errore statistico.
Il processo di caricamento dei dati consuma al massimo 450 MB di RAM. Ciò significa che l'approccio descritto consente di gestire dataset di decine di gigabyte praticamente su qualsiasi hardware economico, compresi alcuni single-board computer, il che è piuttosto eccezionale.
Rimane da eseguire misurazioni della velocità di estrazione dei dati (casuali) e valutare la scalabilità nella selezione di batch di dimensioni diverse:
Benchmark del database
library(ggplot2)
set.seed(0)
# Connessione al database
con <- DBI::dbConnect(MonetDBLite::MonetDBLite(), Sys.getenv("DBDIR"))
# Funzione per preparare la query sul server
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)
}
# Funzione per estrarre i dati
fetch_data <- function(rs, batch_size) {
ids <- sample(seq_len(n), batch_size)
res <- DBI::dbFetch(DBI::dbBind(rs, as.list(ids)))
return(res)
}
# Esecuzione della misurazione
res_bench <- bench::press(
batch_size = 2^(4:10),
{
rs <- prep_sql(batch_size)
bench::mark(
fetch_data(rs, batch_size),
min_iterations = 50L
)
}
)
# Parametri del benchmark
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
#
# 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("tempo mediano, s") +
theme_minimal()
DBI::dbDisconnect(con, shutdown = TRUE) 
2. Preparazione dei batch
L'intero processo di preparazione dei batch consiste nei seguenti passaggi:
- Parsing di più JSON contenenti vettori di stringhe con le coordinate dei punti.
- Disegno di linee colorate sulle coordinate dei punti in un'immagine di dimensioni appropriate (ad esempio, 256×256 o 128×128).
- Trasformazione delle immagini ottenute in tensori.
Nell'ambito della competizione tra i kernel in Python, il problema è stato affrontato principalmente con strumenti OpenCV. Uno degli equivalenti più semplici e ovvi in R apparirà nel modo seguente:
Implementazione della trasformazione da JSON a tensore in R
r_process_json_str <- function(json, line.width = 3,
color = TRUE, scale = 1) {
# Parsing JSON
coords <- jsonlite::fromJSON(json, simplifyMatrix = FALSE)
tmp <- tempfile()
# Remove temporary file upon function completion
on.exit(unlink(tmp))
png(filename = tmp, width = 256 * scale, height = 256 * scale, pointsize = 1)
# Empty plot
plot.new()
# Plot window size
plot.window(xlim = c(256 * scale, 0), ylim = c(256 * scale, 0))
# Line colors
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()
# Convert image to a 3D array
res <- png::readPNG(tmp)
return(res)
}
r_process_json_vector <- function(x, ...) {
res <- lapply(x, r_process_json_str, ...)
# Combine 3D arrays of images into a 4D tensor
res <- do.call(abind::abind, c(res, along = 0))
return(res)
}Il disegno viene eseguito utilizzando gli strumenti standard di R, con salvataggio in un PNG temporaneo memorizzato in RAM (in Linux, le directory temporanee di R si trovano nella directory /tmp, montata in RAM). Questo file viene quindi letto come un array tridimensionale di numeri compresi tra 0 e 1. Questo è importante, poiché un BMP più comune sarebbe stato letto come un array raw 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)) 
Il batch 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
# ..$ : NULLQuesta implementazione ci è sembrata non ottimale, poiché la generazione di grandi batch richiede un tempo eccessivo, e abbiamo deciso di avvalerci dell’esperienza dei colleghi, utilizzando una potente libreria OpenCV. All’epoca non esisteva un pacchetto pronto per R (e non ce n’è nemmeno ora), quindi è stata scritta una minima implementazione delle funzionalità richieste in C++ con integrazione nel codice R tramite Rcpp.
Per affrontare il problema sono state utilizzate le seguenti librerie e pacchetti:
OpenCV per lavorare con le immagini e disegnare linee. Sono state utilizzate librerie di sistema e file header preinstallati, oltre al linking dinamico.
xtensor per lavorare con array multidimensionali e tensori. Abbiamo utilizzato i file di intestazione inclusi nel pacchetto R omonimo. La libreria consente di lavorare con array multidimensionali, sia in ordine row major che column major.
ndjson per il parsing di JSON. Questa libreria è utilizzata in xtensor automaticamente se presente nel progetto.
RcppThread per organizzare l'elaborazione multithread di vettori da JSON. Abbiamo utilizzato i file di intestazione forniti da questo pacchetto. Rispetto al più popolare RcppParallel il pacchetto, tra l'altro, si distingue per il meccanismo integrato di interruzione del ciclo (interrupt).
Vale la pena notare che xtensor si è rivelato una vera scoperta: oltre ad avere un ampio insieme di funzionalità e alte prestazioni, i suoi sviluppatori sono stati molto reattivi ed hanno risposto in modo rapido e dettagliato alle domande. Con il loro aiuto è stato possibile realizzare trasformazioni di matrici OpenCV in tensori xtensor, così come il metodo per unire tensori di immagini 3D in un tensore 4D di dimensioni corrette (ovvero un batch).
Materiali per studiare Rcpp, xtensor e RcppThread
Per la compilazione di file che utilizzano file di sistema e il linking dinamico con le librerie installate nel sistema, abbiamo utilizzato il meccanismo dei plugin implementato nel pacchetto Rcpp. Per la ricerca automatica dei percorsi e dei flag, abbiamo utilizzato un'utility Linux popolare chiamata pkg-config.
Implementazione del plugin Rcpp per l'utilizzo della libreria OpenCV
Rcpp::registerPlugin("opencv", function() {
# Possibili nomi del pacchetto
pkg_config_name <- c("opencv", "opencv4")
# File binario dell'utility pkg-config
pkg_config_bin <- Sys.which("pkg-config")
# Controllo della presenza dell'utility nel sistema
checkmate::assert_file_exists(pkg_config_bin, access = "x")
# Controllo della presenza del file di configurazione OpenCV per pkg-config
check <- sapply(pkg_config_name,
function(pkg) system(paste(pkg_config_bin, pkg)))
if (all(check != 0)) {
stop("Configurazione OpenCV per pkg-config non trovata", 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)
))
})A seguito dell'operazione del plugin, durante il processo di compilazione saranno inseriti 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 per l'implementazione del parsing JSON e la generazione di un batch da inviare al modello è riportato sotto spoiler. Inizialmente, aggiungiamo la directory locale del progetto per cercare i file di intestazione (necessaria per ndjson):
Sys.setenv("PKG_CXXFLAGS" = paste0("-I", normalizePath(file.path("src"))))Implementazione della conversione JSON in un 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 deve essere inserito nel file src/cv_xt.cpp e compilato con il comando Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); è anche necessario per il funzionamento nlohmann/json.hpp di . Il codice è suddiviso in diverse funzioni:
to_xt— funzione templata per convertire una matrice immagine (cv::Mat) in un tensorext::xtensor;parse_json— la funzione analizza una stringa JSON, estraendo le coordinate dei punti e impacchettandole in un vettore;ocv_draw_lines— disegna linee colorate a partire dal vettore di punti ottenuto;process— unisce le funzioni sopra descritte e aggiunge la possibilità di scalare l'immagine ottenuta;cpp_process_json_str— un wrapper attorno alla funzioneprocess, che esporta il risultato in un oggetto R (array multidimensionale);cpp_process_json_vector— un wrapper attorno alla funzionecpp_process_json_str, che consente di elaborare un vettore di stringhe in modalità multithreading.
Per disegnare linee colorate è stato utilizzato il modello di colore HSV con successiva conversione in RGB. Verifichiamo il risultato:
arr <- cpp_process_json_str(tmp_data[4, drawing])
dim(arr)
# [1] 256 256 3
plot(magick::image_read(arr)) 
Confronto delle velocità di esecuzione 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
)
# Parametri del benchmark
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
#
# 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)
# Esecuzione della misurazione
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
# <bch:tm> <bch:tm> <bch:tm> <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("tempo mediano, s") +
theme_minimal() +
scale_color_discrete(name = "", labels = c("cpp", "r")) +
theme(legend.position = "bottom") 
Come possiamo vedere, l'aumento della velocità è stato molto significativo, e non è possibile raggiungere il codice in C++ attraverso la parallelizzazione del codice in R.
3. Iteratori per l'estrazione di batch dal DB
R ha una meritata reputazione come linguaggio per l'elaborazione dei dati che possono essere caricati in memoria, mentre Python è più orientato all'elaborazione iterativa dei dati, facilitando in modo semplice l'implementazione di calcoli out-of-core (calcoli che utilizzano memoria esterna). Un esempio classico e pertinente al contesto della nostra discussione è costituito dalle reti neurali profonde, addestrate mediante il metodo del gradiente discendente che approssima il gradiente a ogni passaggio su un piccolo campione di osservazioni, o mini-batch.
I framework per l'apprendimento profondo scritti in Python dispongono di classi speciali per implementare iteratori sui dati: tabelle, immagini nelle cartelle, formati binari, ecc. È possibile utilizzare opzioni predefinite o scrivere le proprie per compiti specifici. In R possiamo sfruttare tutte le potenzialità della libreria di Python. keras con i suoi vari backend utilizzando l'omonimo pacchetto, che a sua volta funziona sopra il pacchetto reticulate. Quest'ultimo merita un articolo a parte; non solo consente di eseguire codice Python da R, ma fornisce anche la trasmissione di oggetti tra le sessioni R e Python, eseguendo automaticamente tutte le necessarie conversioni di tipo.
Abbiamo eliminato la necessità di memorizzare tutti i dati in RAM grazie all'uso di MonetDBLite, tutta la parte 'neural network' sarà eseguita dal codice Python originale, ci rimane solo da scrivere un iteratore sui dati, poiché non esiste nulla di pronto per una situazione del genere né in R né in Python. Le sue richieste sono fondamentalmente due: deve restituire batch in un ciclo infinito e mantenere il suo stato tra le iterazioni (quest'ultima cosa in R è realizzata semplicemente tramite chiusure). In precedenza, era necessario all'interno dell'iteratore convertire esplicitamente gli array R in array numpy, ma l'ultima versione del pacchetto keras lo fa automaticamente.
L'iteratore per i dati di addestramento e validazione è risultato essere il seguente:
L'iteratore per i dati di addestramento 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 accetta una variabile con la connessione al database, i numeri delle righe utilizzate, il numero di classi, la dimensione del batch, la scala (scale = 1 corrisponde al rendering delle immagini di 256x256 pixel, scale = 0.5 — 128x128 pixel), l'indicatore di colorazione (color = FALSE imposta il rendering in scala di grigi, mentre utilizzando color = TRUE ogni tratto è disegnato con un nuovo colore) e l'indicatore di preprocessamento per le reti preaddestrate su imagenet. Quest'ultimo è necessario per scalare i valori dei pixel dall'intervallo [0, 1] all'intervallo [-1, 1], che è stato utilizzato durante l'addestramento delle keras modelli.
La funzione esterna contiene un controllo dei tipi degli argomenti, una tabella data.table con numeri di righe mescolati casualmente da samples_index e numeri di batch, un contatore e il numero massimo di batch, oltre a un'espressione SQL per l'estrazione dei dati dal database. Inoltre, abbiamo definito all'interno un analogo veloce della funzione keras::to_categorical().Abbiamo utilizzato quasi tutti i dati per l'addestramento, lasciando lo 0,5% per la validazione, quindi la dimensione dell'epoca era limitata dal parametro steps_per_epoch quando viene chiamato keras::fit_generator(), e la condizione if (i > max_i) si attivava solo per l'iteratore di validazione.
Nella funzione interna avviene la selezione degli indici delle righe per il batch successivo, il caricamento dei record dal database incrementando il contatore dei batch, il parsing dei JSON (funzione cpp_process_json_vector(), scritta in C++) e la creazione di array corrispondenti alle immagini. Vengono quindi creati vettori one-hot con le etichette delle classi, gli array contenenti i valori dei pixel e le etichette vengono uniti in un elenco, che è il valore di ritorno. Per accelerare il processo, è stata utilizzata la creazione di indici nelle tabelle data.table e la modifica per riferimento — senza questi "trucchi" del pacchetto data.table è piuttosto difficile immaginare un'efficace gestione di volumi di dati significativi in R.
I risultati delle misurazioni delle prestazioni su un Core i5 portatile sono i seguenti:
Benchmark 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]]
# Indici per il set di addestramento
train_ind <- sample(ind, floor(length(ind) * 0.995))
# Indici per il set di validazione
val_ind <- ind[-train_ind]
rm(ind)
# Coefficiente di scala
scale <- 0.5
# Esecuzione del benchmark
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
)
}
)
# Parametri del benchmark
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
#
# 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("tempo mediano, s") +
theme_minimal()
DBI::dbDisconnect(con, shutdown = TRUE) 
Se si dispone di un'adeguata quantità di RAM, è possibile accelerare significativamente il funzionamento del database trasferendolo in questa RAM (per il nostro scopo bastano 32 GB). In Linux, per impostazione predefinita, viene montata una partizione /dev/shm, che occupa fino alla metà della capacità della RAM. È possibile allocare anche di più, modificando /etc/fstab, in modo che si ottenga una voce del tipo tmpfs /dev/shm tmpfs defaults,size=25g 0 0. È necessario riavviare e controllare il risultato eseguendo il comando df -h.
L'iteratore per i dati di test appare molto più semplice, poiché l'intero dataset di test può essere contenuto nella RAM:
Iteratore per 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. Scelta dell'architettura del modello
La prima delle architetture utilizzate è stata , le cui caratteristiche sono state analizzate in un messaggio. È presente nell'installazione standard keras e, di conseguenza, è disponibile nel pacchetto omonimo per R. Ma nel tentativo di usarla con immagini a canale singolo è emerso un fatto strano: il tensore di input deve sempre avere la dimensione (batch, height, width, 3), cioè il numero di canali non può essere modificato. In Python non ci sono tali limitazioni, quindi ci siamo affrettati a scrivere la nostra implementazione di questa architettura, seguendo l'articolo originale (senza dropout, che è presente nella versione di 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 %
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 %
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 % compile(
optimizer = optimizer,
loss = loss,
metrics = metrics
)
return(model)
}Gli svantaggi di questo approccio sono evidenti. Si desidera testare molti modelli, ma non si ha voglia di riscrivere manualmente ogni architettura. Inoltre, ci è stata negata la possibilità di utilizzare i pesi dei modelli precedentemente addestrati su Imagenet. Come al solito, ci ha aiutato lo studio della documentazione. La funzione get_config() consente di ottenere la descrizione del modello in un formato editabile (base_model_conf$layers — una normale lista in stile R), mentre la funzione from_config() esegue la trasformazione inversa in un oggetto 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 qualsiasi dei modelli forniti con pesi addestrati su Imagenet o senza di essi: keras Funzione per caricare architetture pronte
Funzione per caricare architetture 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) {
# Verifica gli argomenti
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)
# Ottieni l'oggetto dal pacchetto keras
model_fun <- get0(paste0("application_", name), envir = asNamespace("keras"))
# Controlla l'esistenza dell'oggetto nel pacchetto
if (is.null(model_fun)) {
stop("Modello ", shQuote(name), " non trovato.", call. = FALSE)
}
base_model <- model_fun(
input_shape = input_shape,
include_top = FALSE,
weights = weights,
pooling = pooling
)
# Se l'immagine non è a colori, modifica la dimensione dell'input
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, i pesi pre-addestrati non vengono utilizzati. Questo potrebbe essere risolto con la funzione get_weights() per ottenere i pesi del modello come elenco di array R, modificare la dimensione del primo elemento di questo elenco (utilizzando un canale di colore o mediando i tre) e quindi caricare i pesi nel modello usando la funzione set_weights(). Non abbiamo aggiunto questa funzionalità, poiché a questo punto era chiaro che era più produttivo lavorare con immagini a colori.
La maggior parte degli esperimenti è stata condotta utilizzando mobilenet versioni 1 e 2, oltre a resnet34. In questa competizione, architetture più moderne, come SE-ResNeXt, hanno ottenuto buoni risultati. Purtroppo, non avevamo implementazioni pronte a disposizione, e non abbiamo scritto le nostre (ma lo faremo sicuramente).
5. Parametrizzazione degli script
Per comodità, tutto il codice per avviare l'addestramento è stato organizzato in un unico script, parametrizzato utilizzando nel seguente modo:
doc < - '
Uso:
train_nn.R --help
train_nn.R --list-models
train_nn.R [opzioni]
Opzioni:
-h --help Mostra questo messaggio.
-l --list-models Elenca i modelli disponibili.
-m --model= Nome del modello di rete neurale [predefinito: mobilenet_v2].
-b --batch-size= Dimensione del batch [predefinito: 32].
-s --scale-factor= Fattore di scala [predefinito: 0.5].
-c --color Utilizza linee colorate [predefinito: FALSE].
-d --db-dir= Percorso della directory del database [predefinito: Sys.getenv("db_dir")].
-r --validate-ratio= Rapporto di campionamento di validazione [predefinito: 0.995].
-n --n-gpu= Numero di GPU [predefinito: 1].
'
args < - docopt::docopt(doc)Il pacchetto docopt è un'implementazione per R. Con questo, gli script vengono eseguiti 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 il file train_nn.R è eseguibile (questo comando avvierà l'addestramento del modello resnet50 su immagini a colori di dimensioni 128x128 pixel, il database deve trovarsi nella cartella /home/andrey/doodle_db). Possono essere aggiunti la velocità di apprendimento, il tipo di ottimizzatore e qualsiasi altro parametro configurabile. Durante la preparazione della pubblicazione, è emerso che l'architettura mobilenet_v2 dell'ultima versione keras in R utilizza a causa delle modifiche non considerate nel pacchetto R — in attesa di una correzione.
Questo approccio ha consentito di accelerare significativamente gli esperimenti con diversi modelli rispetto all'avvio più tradizionale di script in RStudio (come possibile alternativa, segnaliamo il pacchetto ). Ma il principale vantaggio risiede nella possibilità di gestire facilmente l'esecuzione degli script in Docker o semplicemente su un server, senza dover installare RStudio.
6. Dockerizzazione degli script
Abbiamo utilizzato Docker per garantire la portabilità dell'ambiente per l'addestramento dei modelli tra i membri del team e per implementare rapidamente nel cloud. Per iniziare a familiarizzare con questo strumento relativamente nuovo per un programmatore R, si può consultare una serie di pubblicazioni o un .
Docker consente di creare immagini proprie "da zero", oltre a utilizzare altre immagini come base per crearne di nuove. Analizzando le opzioni disponibili, abbiamo concluso che l'installazione dei driver NVIDIA, CUDA+cuDNN e delle librerie Python è una parte piuttosto pesante dell'immagine, e abbiamo deciso di utilizzare come base l'immagine ufficiale tensorflow/tensorflow:1.12.0-gpu, aggiungendo i pacchetti R necessari.
Il file Docker finale è risultato essere il seguente:
Dockerfile
DA tensorflow/tensorflow:1.12.0-gpu
MANAGER Artem Klevtsov
SHELL ["/bin/bash", "-c"]
ARG LOCALE="it_IT.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 facilitare l'utilizzo, i pacchetti usati sono stati trasferiti in variabili; la parte principale degli script scritti viene copiata all'interno dei contenitori durante la costruzione. Abbiamo anche cambiato la shell in /bin/bash per facilitare l'uso del contenuto /etc/os-release. Questo ha evitato la necessità di specificare la versione del sistema operativo nel codice.
In aggiunta, è stato scritto un piccolo script bash che consente di eseguire il contenitore con comandi diversi. Ad esempio, potrebbero essere script per l'addestramento di reti neurali, precedentemente inseriti nel contenitore, o una shell per il debug e il monitoraggio del funzionamento del contenitore:
Script per l'esecuzione del 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 si esegue questo script bash senza parametri, all'interno del contenitore verrà chiamato lo script train_nn.R con i valori predefiniti; se il primo argomento posizionale è 'bash', il contenitore verrà avviato in modalità interattiva con la shell. In tutti gli altri casi vengono sostituiti i valori degli argomenti posizionali: CMD="Rscript /app/train_nn.R $@".
È importante notare che le directory contenenti i dati sorgente e il database, così come la directory per il salvataggio dei modelli addestrati, vengono montate all'interno del contenitore dal sistema host, consentendo l'accesso ai risultati delle esecuzioni degli script senza ulteriori manovre.
7. Utilizzo di più GPU su Google Cloud
Una delle peculiarità della competizione era la presenza di dati piuttosto rumorosi (vedi l'immagine principale, presa da @Leigh.plt su ODS-Slack). Combattere queste problematiche è facilitato dall'uso di batch di grandi dimensioni, e dopo esperimenti su un PC con 1 GPU, abbiamo deciso di esplorare l'addestramento dei modelli su più GPU nel cloud. Abbiamo utilizzato GoogleCloud () per la vasta scelta di configurazioni disponibili, prezzi competitivi e un bonus di $300. Per ingordigia, abbiamo ordinato un'istanza con 4xV100 con SSD e una grande quantità di RAM, e questo si è rivelato un grosso errore. Una macchina del genere consuma rapidamente denaro, quindi su esperimenti senza un pipeline ben strutturato si rischia di andare in rosso. Per scopi didattici è meglio optare per K80. Tuttavia, un’ampia quantità di RAM è stata utile — l'SSD cloud non ha impressionato per rapidità, quindi il database veniva trasferito su ogni avvio dell'istanza a dev/shm.
Il codice di maggiore interesse è quello responsabile dell'uso di più GPU. Prima 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
)
})Successivamente, il modello non compilato (questo è importante) viene copiato sul numero specificato di GPU disponibili e solo allora 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 siamo riusciti a implementare la classica tecnica di congelare tutti i livelli tranne l'ultimo, addestrare l'ultimo livello, scongelare e riaddestrare il modello nel suo complesso su più GPU.
Abbiamo monitorato l'addestramento senza utilizzare tensorboard, limitandoci a registrare i log e a salvare i modelli con nomi informativi dopo ogni epoca:
Callbacks
# Шаблон имени файла лога
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
Un certo numero di problemi con cui ci siamo confrontati non sono stati ancora superati:
- in keras non esiste una funzione pronta per la ricerca automatica della velocità di apprendimento ottimale (analogo a
lr_findernella libreria fast.ai); applicando alcuni sforzi, è possibile portare su R implementazioni di terze parti, ad esempio, ; - di conseguenza al punto precedente, non siamo riusciti a trovare la giusta velocità di apprendimento utilizzando più GPU;
- mancano architetture moderne di reti neurali, in particolare quelle pre-addestrate su imagenet;
- non c'è la one cycle policy e i tassi di apprendimento discriminativi (cosine annealing su nostra richiesta è stato , grazie ).
Cosa utile è stata portata via da questa competizione:
- Su hardware relativamente poco potente, è possibile lavorare senza problemi con volumi di dati significativi (superiori alla dimensione della RAM). Il pacchetto data.table risparmia memoria grazie alla modifica in place delle tabelle, evitando così la loro copia, e, se utilizzato correttamente, dimostra quasi sempre la massima velocità tra tutti gli strumenti per linguaggi di scripting a noi noti. Il salvataggio dei dati in un database consente in molti casi di non dover pensare nemmeno alla necessità di comprimere l'intero dataset nella RAM.
- Funzioni lente in R possono essere sostituite da funzioni veloci in C++ tramite il pacchetto Rcpp. Se inoltre si utilizza RcppThread o RcppParallel, otteniamo implementazioni multithread cross-platform, quindi non è necessario parallelizzare il codice a livello di R.
- Il pacchetto Rcpp può essere utilizzato senza una profonda conoscenza del C++, le nozioni di base necessarie sono esposte . I file header per diverse potenti librerie in C, come ad esempio xtensor sono disponibili su CRAN, il che significa che si sta formando un'infrastruttura per realizzare progetti che integrano in R codici ad alte prestazioni già pronti in C++. Un ulteriore vantaggio è la evidenziazione della sintassi e l'analizzatore statico del codice in C++ in RStudio.
- docopt permette di eseguire script autonomi con parametri. Questo è comodo per l'uso su server remoti, inclusi quelli con Docker. Risulta scomodo condurre esperimenti di apprendimento delle reti neurali prolungati in RStudio, e l'installazione stessa dell'IDE su un server non è sempre giustificata.
- Docker garantisce la portabilità del codice e la riproducibilità dei risultati tra sviluppatori con diverse versioni di OS e librerie, oltre alla facilità di esecuzione su server. È possibile avviare l'intero pipeline per l'addestramento con un solo comando.
- Google Cloud è un modo economico per sperimentare su hardware costoso, ma è necessario scegliere attentamente le configurazioni.
- Misurare la velocità di esecuzione di singoli frammenti di codice è molto utile, soprattutto quando si combina R e C++, e con il pacchetto bench è anche molto semplice.
In generale, questa esperienza è stata davvero utile e continuiamo a lavorare per risolvere alcuni dei problemi evidenziati.
Fonte: habr.com
