
Ciao, Habr!
Nell'autunno dello scorso anno, su Kaggle si è tenuto un concorso per la classificazione di immagini disegnate a mano, Quick Draw Doodle Recognition, al quale ha partecipato, tra gli altri, un team di R. , e . Non ci soffermeremo a descrivere nel dettaglio la competizione, questo è già stato fatto in .
Questa volta, non è andata bene con il bottino di medaglie, ma abbiamo acquisito molte esperienze preziose, quindi vorremmo condividere con la comunità alcune delle cose più interessanti e utili sia su Kaggle che nel lavoro quotidiano. Tra gli argomenti esaminati: la vita non facile senza OpenCV, il parsing dei JSON (in questi esempi si considera l'integrazione del codice C++ in script o pacchetti R tramite Rcpp), la parametrizzazione degli script e la containerizzazione della soluzione finale. Tutto il codice menzionato è disponibile in un formato eseguibile in .
Contenuto:
1. Caricamento efficiente dei dati da CSV in un database MonetDB
I dati in questa competizione non sono forniti come immagini pronte, ma sotto forma di 340 file CSV (uno per ogni classe), contenenti JSON con le coordinate dei punti. Collegando questi punti con delle linee, otteniamo l'immagine finale di dimensione 256x256 pixel. Inoltre, per ogni registrazione è fornita un'etichetta che indica se l'immagine è stata correttamente riconosciuta dal classificatore utilizzato al momento della raccolta del dataset, un codice di due lettere per il paese di residenza dell'autore del disegno, un identificatore unico, un timbro temporale e il nome della classe che corrisponde al nome del file. La versione semplificata dei dati originali pesa 7.4 GB in archivio e circa 20 GB dopo l'estrazione, 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 forma di array è stato subito riconosciuto come non redditizio, quindi abbiamo deciso di unire tutti i file CSV dall'archivio train_simplified.zip in un database con successiva generazione delle immagini nella dimensione desiderata "al volo" per ogni batch.
Come SGBD è stato scelto un sistema ben collaudato MonetDB, e in particolare 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 di lavorare con esso. La creazione del database e la connessione avvengono tramite un unico 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 caricati (utile se qualcosa va storto e il processo deve 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 dati nel database è stato il copia diretta di file CSV tramite SQL — il comando COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORT, dove tablename — nome della tabella e path — percorso del file. Durante il lavoro con l'archivio si è scoperto che l'implementazione integrata unzip in R non funziona correttamente con alcuni file dell'archivio, quindi abbiamo utilizzato l'opzione di sistema unzip (utilizzando il parametro getOption("unzip")).
La 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))
}Nel caso in cui sia necessario trasformare la tabella prima di scriverla nel database, è sufficiente passare l'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 memorizzazione utilizzato. Nel nostro caso, la lettura e la scrittura all'interno dello stesso SSD o da una chiavetta USB (file sorgente) su SSD (DB) richiedono meno di 10 minuti.
Altri alcuni secondi sono necessari per creare una colonna con l'etichetta intera della classe e una colonna indice (ORDERED INDEX) con i numeri di riga, secondo cui verranno selezionate le osservazioni durante la creazione dei batch:
Creazione di colonne aggiuntive e indice
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 risolvere il compito di formare un batch "al volo", era necessario ottenere la massima velocità nell'estrazione di righe casuali dalla tabella doodles. A questo scopo abbiamo utilizzato 3 trucchi. Il primo consisteva nel ridurre la dimensione del tipo in cui viene memorizzato l'ID dell'osservazione. Nel set di dati originale, per memorizzare l'ID era richiesto un tipo bigint, ma il numero di osservazioni consente di memorizzare i loro identificativi, corrispondenti al numero d'ordine, in un tipo int. La ricerca avviene quindi in modo significativamente più veloce. Il secondo trucco è stato l'utilizzo di ORDERED INDEX — a questa soluzione siamo arrivati empiricamente, provando tutte le opzioni disponibili . Il terzo consisteva nell'utilizzo di query parametrizzate. La sostanza del metodo consiste nell'eseguire una sola volta il comando PREPARE seguendone l'uso dell'espressione preparata nella creazione di un mucchio di richieste dello stesso tipo, ma in realtà il vantaggio rispetto a una semplice SELECT si è rivelato essere nell'ordine dell'errore statistico.
Il processo di caricamento dei dati consuma non più di 450 MB di RAM. Quindi, l'approccio descritto consente di gestire set di dati del peso di decine di gigabyte praticamente su qualsiasi hardware economico, inclusi alcuni single-board computer, il che è piuttosto impressionante.
Resta da misurare la velocità di estrazione dei dati (casuali) e valutare la scalabilità durante il campionamento 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 lato 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 è composto dalle seguenti fasi:
- Parsing di più JSON contenenti vettori di stringhe con le coordinate dei punti.
- Disegno di linee colorate in base alle coordinate dei punti su un'immagine di dimensioni appropriate (ad esempio, 256×256 o 128×128).
- Conversione delle immagini ottenute in un tensore.
Nell'ambito di una competizione tra kernel su Python, il problema è stato affrontato principalmente usando OpenCV. Una delle analogie più semplici e ovvie in R apparirà come segue:
Implementazione della conversione 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()
# Rimuoviamo il file temporaneo al termine della funzione
on.exit(unlink(tmp))
png(filename = tmp, width = 256 * scale, height = 256 * scale, pointsize = 1)
# Grafico vuoto
plot.new()
# Dimensione della finestra del grafico
plot.window(xlim = c(256 * scale, 0), ylim = c(256 * scale, 0))
# Colori delle linee
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()
# Conversione dell'immagine in un array tridimensionale
res <- png::readPNG(tmp)
return(res)
}
r_process_json_vector <- function(x, ...) {
res <- lapply(x, r_process_json_str, ...)
# Unione di array tridimensionali di immagini in un tensor quadrimensionale
res <- do.call(abind::abind, c(res, along = 0))
return(res)
}Il disegno viene eseguito utilizzando gli strumenti standard di R, salvando in un PNG temporaneo che risiede nella RAM (in Linux, le directory temporanee di R si trovano nella cartella /tmp, montato nella RAM). Successivamente, questo file viene letto come un array tridimensionale con numeri nel range da 0 a 1. Questo è importante poiché un BMP più comune sarebbe stato letto come un array raw con codici esadecimali dei colori.
Proviamo 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 verrà formato nel seguente modo:
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 formazione di grandi batch richiede un tempo eccessivo, e abbiamo deciso di sfruttare l'esperienza dei colleghi, utilizzando una potente libreria OpenCV. A quel tempo non esisteva un pacchetto pronto per R (non esiste neanche adesso), quindi è stata scritta una realizzazione minima della funzionalità richiesta in C++ con integrazione nel codice R tramite Rcpp.
Per affrontare il problema sono stati utilizzati i seguenti pacchetti e librerie:
OpenCV per la gestione delle immagini e il disegno delle linee. Sono state utilizzate librerie e file di intestazione di sistema già installati, oltre a link dinamici.
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 JSON. Questa libreria viene utilizzata in xtensor automaticamente se presente nel progetto.
RcppThread per organizzare l'elaborazione multithread di un vettore 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 una vasta funzionalità e alta prestazione, i suoi sviluppatori si sono dimostrati molto reattivi rispondendo in modo rapido e dettagliato alle domande emerse. Con il loro aiuto, è stato possibile implementare le conversioni delle matrici OpenCV in tensori xtensor, oltre a un metodo per unire i tensori di immagini tridimensionali in un tensore quadrimensionale di dimensioni corrette (ovvero il batch).
Materiali per studiare Rcpp, xtensor e RcppThread
Per compilare i 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 trovare automaticamente percorsi e flag abbiamo utilizzato un'utile utility di Linux 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")
# Verifica 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 il 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)
))
})Di conseguenza, il plugin inserir à i seguenti valori durante il processo di compilazione:
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 del parsing JSON e la creazione di un batch da inviare al modello è riportato sotto il spoiler. Prima aggiungiamo la directory locale del progetto per la ricerca dei file di intestazione (necessario 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 deve essere inserito nel file src/cv_xt.cpp e compilato con il comando Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); è necessario anche nlohmann/json.hpp da . Il codice è suddiviso in diverse funzioni:
to_xt— una funzione templata per la conversione di una matrice d'immagine (cv::Mat) in un tensorext::xtensor;parse_json— la funzione analizza la stringa JSON, estrae le coordinate dei punti, imballandole in un vettore;ocv_draw_lines— dalla vettore di punti ottenuto disegna linee multicolori;process— unisce le funzioni sopra descritte e aggiunge la possibilità di scalare l'immagine ottenuta;cpp_process_json_str— un wrapper per la funzioneprocess, che esporta il risultato in un oggetto R (array multidimensionale);cpp_process_json_vector— un wrapper per la funzionecpp_process_json_str, che consente di elaborare un vettore di stringhe in modalità multithread.
Per disegnare linee multicolori è stato utilizzato il modello di colore HSV con successiva 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)) 
Confronto della 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("espressione", "min", "mediana", "max", "itr/sec", "tempo_totale", "n_itr")
res_bench[, cols]
# espressione min mediana max `itr/sec` tempo_totale 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]
# espressione batch_size min mediana max `itr/sec` tempo_totale n_itr
#
# 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 = mediana,
group = espressione, color = espressione)) +
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, il guadagno di velocità è stato molto significativo e raggiungere il codice in C++ tramite la parallelizzazione del codice in R non sembra possibile.
3. Iterator per il caricamento dei batch dal DB
R ha una meritatissima reputazione come linguaggio per l'elaborazione di dati che possono essere memorizzati in RAM, mentre per Python è più caratteristica l'elaborazione iterativa dei dati, che consente di implementare facilmente e senza sforzo calcoli out-of-core (calcoli che utilizzano memoria esterna). Un classico esempio appropriato per noi nel contesto del problema descritto sono le reti neurali profonde, addestrate mediante la discesa del gradiente con approssimazione del gradiente ad ogni passo su una piccola porzione di osservazioni, o mini-batch.
I framework per il deep learning scritti in Python hanno classi speciali che implementano iteratori per i dati: tabelle, immagini in cartelle, formati binari, ecc. È possibile utilizzare opzioni pronte o scrivere le proprie per compiti specifici. In R possiamo sfruttare tutte le potenzialità della libreria di Python keras con i suoi vari backend tramite il pacchetto omonimo, a sua volta funzionante sopra il pacchetto reticulate. Quest'ultimo merita un articolo a parte; non solo consente di eseguire codice Python da R, ma assicura anche il passaggio di oggetti tra sessioni R e Python, eseguendo automaticamente tutte le trasformazioni di tipo necessarie.
Siamo stati sollevati dalla necessità di memorizzare tutti i dati in RAM grazie all'uso di MonetDBLite, tutto il lavoro "neurale" sarà eseguito dal codice originale in Python, ci resta solo da scrivere un iteratore per i dati, poiché non esiste una soluzione pronta per tale situazione né in R né in Python. I requisiti per esso sono sostanzialmente due: deve restituire batch in un ciclo infinito e mantenere il proprio stato tra le iterazioni (quest'ultimo in R viene realizzato in modo semplice tramite chiusure). In precedenza, era necessario all'interno dell'iteratore trasformare esplicitamente gli array di R in array numpy, ma l'attuale versione del pacchetto keras lo fa da sola.
L'iteratore per i dati di addestramento e validazione è il seguente:
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 come input una variabile con la connessione al DB, i numeri delle righe utilizzate, il numero di classi, la dimensione del batch, la scala (scale = 1 corrispondente alla visualizzazione di immagini 256x256 pixel, scale = 0.5 — 128x128 pixel), un indicatore di coloritura (color = FALSE stabilisce il rendering in scala di grigi, se utilizzato color = TRUE ogni tratto viene disegnato con un nuovo colore) e un indicatore di preelaborazione per le reti pre-addestrate su imagenet. L'ultimo è necessario per scalare i valori dei pixel dall'intervallo [0, 1] all'intervallo [-1, 1], utilizzato durante l'addestramento fornito nel pacchetto keras modelli.
La funzione esterna contiene un controllo dei tipi di argomenti, una tabella data.table con numeri di riga mescolati casualmente da samples_index e i numeri dei batch, un contatore e il numero massimo di batch, oltre a un'espressione SQL per l'estrazione dei dati dal DB. 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 chiami keras::fit_generator(), e la condizione if (i > max_i) si attivava solo per l'iteratore di validazione.
Nella funzione interna avviene l'estrazione degli indici delle righe per il batch corrente, l'estrazione dei record dal DB con l'incremento del 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 con i valori dei pixel e le etichette vengono uniti in un elenco, che è il valore restituito. Per accelerare il funzionamento è stato utilizzato la creazione di indici nelle tabelle data.table e la modifica per riferimento: senza questi "trucchi" del pacchetto data.table è piuttosto difficile immaginare un funzionamento efficace con volumi di dati significativi in R.
I risultati delle misurazioni della velocità di funzionamento 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 campione di addestramento
train_ind <- sample(ind, floor(length(ind) * 0.995))
# Indici per il campione di validazione
val_ind <- ind[-train_ind]
rm(ind)
# Fattore di scala
scale <- 0.5
# Esecuzione della misurazione
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 c'è una quantità sufficiente di RAM, il funzionamento del database può essere notevolmente accelerato spostandolo in questa memoria (per il nostro scopo, 32 GB sono sufficienti). Su Linux, di default viene montata una partizione /dev/shm, che occupa fino alla metà della capacità della RAM. Si può anche allocare di più, modificando /etc/fstab, in modo da ottenere una registrazione del tipo tmpfs /dev/shm tmpfs defaults,size=25g 0 0. È fondamentale riavviare e controllare il risultato, eseguendo il comando df -h.
L'iteratore per i dati di test appare molto più semplice, poiché il set di dati di test può essere interamente caricato in 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. Scelta dell'architettura del modello
La prima delle architetture utilizzate è stata , le cui caratteristiche sono state analizzate in un rapporto. Essa è parte della fornitura standard keras e, di conseguenza, è disponibile nel pacchetto omonimo per R. Ma nel tentativo di utilizzarla con immagini a canale singolo, è emersa una strana situazione: il tensore di input deve sempre avere la dimensione (batch, height, width, 3), cioè il numero di canali non può essere cambiato. 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 variante 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 vuole riscrivere manualmente ogni architettura. Inoltre, eravamo privi della possibilità di utilizzare i pesi dei modelli precedentemente addestrati su ImageNet. Come al solito, lo studio della documentazione ci ha aiutato. La funzione get_config() consente di ottenere una descrizione del modello in un formato modificabile (base_model_conf$layers — una lista R standard), e la funzione from_config() esegue la conversione 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 uno qualsiasi dei modelli forniti con pesi preaddestrati su imagenet o senza di essi: keras 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) { # Controllo dei parametri 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)# Otteniamo l'oggetto dal pacchetto keras model_fun <- get0(paste0("application_", name), envir = asNamespace("keras")) # Verifica dell'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, cambiamo 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 in scala di grigi, i pesi preaddestrati non vengono utilizzati. Questo potrebbe essere risolto: utilizzando la funzioneget_weights() per ottenere i pesi del modello sotto forma di lista di array R, modificare la dimensione del primo elemento di questa lista (prendendo un canale di colore o mediando i tre), e poi caricare nuovamente i pesi nel modello con la funzione set_weights() . Non abbiamo aggiunto questa funzionalità perché a questo punto era già chiaro che era più produttivo lavorare con immagini a colori.. Non abbiamo aggiunto questa funzionalità, poiché a questo punto era già chiaro che era più produttivo lavorare con immagini a colori.
La maggior parte degli esperimenti è stata effettuata utilizzando le versioni 1 e 2 di mobilenet, così come resnet34. In questa competizione, architetture più moderne come SE-ResNeXt hanno mostrato buone prestazioni. Sfortunatamente, non avevamo a disposizione implementazioni pronte, e non abbiamo scritto le nostre (ma lo faremo sicuramente).
5. Parametrizzazione degli script
Per comodità, tutto il codice per avviare l'addestramento è stato strutturato come un unico script, parametrizzato tramite nel seguente modo:
doc <- '
Usage:
train_nn.R --help
train_nn.R --list-models
train_nn.R [options]
Options:
-h --help Mostra questo messaggio.
-l --list-models Elenca i modelli disponibili.
-m --model= Nome del modello di rete neurale [default: mobilenet_v2].
-b --batch-size= Dimensione del batch [default: 32].
-s --scale-factor= Fattore di scala [default: 0.5].
-c --color Usa colori per le linee [default: FALSE].
-d --db-dir= Percorso della directory del database [default: Sys.getenv("db_dir")].
-r --validate-ratio= Rapporto di convalida dei campioni [default: 0.995].
-n --n-gpu= Numero di GPU [default: 1].
'
args <- docopt::docopt(doc)Pacchetto docopt implementazione per R. Con esso, gli script vengono eseguiti con comandi semplici 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 delle dimensioni 128x128 pixel, il database deve trovarsi nella cartella /home/andrey/doodle_db). È possibile aggiungere nel elenco il tasso di apprendimento, il tipo di ottimizzatore e altri parametri configurabili. Durante la preparazione della pubblicazione, è emerso che l'architettura mobilenet_v2 della versione attuale keras in R può essere utilizzata a causa di modifiche non considerate nel pacchetto R — stiamo aspettando che venga risolto.
Questo approccio ha permesso di accelerare notevolmente gli esperimenti con diversi modelli rispetto all'esecuzione più tradizionale degli script in RStudio (come possibile alternativa, segnaliamo il pacchetto ). Ma il principale vantaggio consiste nella possibilità di gestire facilmente l'esecuzione degli script in Docker o semplicemente su un server, senza dover installare RStudio per questo.
6. Dockerizzazione degli script
Abbiamo utilizzato Docker per garantire la portabilità dell'ambiente di addestramento dei modelli tra i membri del team e per un rapido deployment nel cloud. Puoi iniziare a familiarizzare con questo strumento relativamente inusuale per un programmatore R con una serie di pubblicazioni o con .
Docker consente sia di creare immagini proprie "da zero", sia di utilizzare altre immagini come base per crearne di proprie. Analizzando le opzioni disponibili, siamo giunti alla conclusione che l'installazione dei driver NVIDIA, CUDA+cuDNN e delle librerie Python rappresenta una parte piuttosto ampia dell'immagine e abbiamo deciso di utilizzare l'immagine ufficiale come base. tensorflow/tensorflow:1.12.0-gpu, aggiungendo i pacchetti R necessari.
Il file Docker finale è risultato essere il seguente:
Dockerfile
FROM tensorflow/tensorflow:1.12.0-gpu
MAINTAINER Artem Klevtsov
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 spostati in variabili; la parte principale degli script scritti viene copiata all'interno dei container durante la costruzione. Abbiamo anche cambiato la shell predefinita in /bin/bash per facilitare l'uso dei contenuti /etc/os-release. Questo ha reso non necessaria l'indicazione della versione del sistema operativo nel codice.
In aggiunta, è stato scritto un piccolo script bash che consente di avviare il container con diverse comandi. Ad esempio, possono essere script per l'addestramento delle reti neurali, precedentemente inseriti nel container, o una shell per la risoluzione dei problemi e il monitoraggio del funzionamento del container:
Script per avviare il container
#!/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, all'interno del contenitore verrà eseguito uno script train_nn.R con valori predefiniti; se il primo argomento posizionale è "bash", allora il contenitore verrà avviato in modalità interattiva con una shell. In tutti gli altri casi, verranno sostituiti i valori degli argomenti posizionali: CMD="Rscript /app/train_nn.R $@".
È importante notare che le directory con 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, il che consente l'accesso ai risultati del lavoro degli script senza ulteriori manipolazioni.
7. Utilizzo di più GPU su Google Cloud
Una delle caratteristiche della competizione erano i dati piuttosto rumorosi (vedi l'immagine di copertura, presa da @Leigh.plt su ODS-slack). Combattersi aiuta a lavorare con batch di grandi dimensioni, e dopo esperimenti su PC con 1 GPU abbiamo deciso di imparare ad addestrare modelli su più GPU nel cloud. Abbiamo usato GoogleCloud () a causa della grande scelta di configurazioni disponibili, prezzi accettabili e un bonus di $300. Per avidità abbiamo ordinato un'istanza con 4xV100 con SSD e tanta RAM, e questo è stato un grande errore. Il denaro viene rapidamente consumato da tale macchina, senza una pipeline ben collaudata si può andare in rovina con gli esperimenti. Per scopi di apprendimento è meglio prendere K80. Un grande volume di RAM è stato utile: l'SSD cloud non ha impressionato per velocità, quindi il database veniva trasferito ad ogni avvio dell'istanza su dev/shm.
Il segmento di codice che interessa di più è quello che gestisce l'uso di più GPU. Prima il modello viene creato sulla CPU utilizzando il 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 richiesto 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 siamo riusciti a implementare la classica tecnica di congelare tutti gli strati, tranne l'ultimo, addestrare l'ultimo strato, quindi sbloccare e riaddestrare interamente il modello per più GPU.
Abbiamo monitorato l'addestramento senza utilizzare tensorboard, limitandosi a registrare i log e a salvare i modelli con nomi informativi dopo ogni epoca:
Callback
# Шаблон имени файла лога
log_file_tmpl <- file.path("logs", sprintf(
"%s_%d_%dch_%s.csv",
model_name,
dim_size,
channels,
format(Sys.time(), "%Y%m%d%H%M%OS")
))
# Шаблон имени файла модели
model_file_tmpl <- file.path("models", sprintf(
"%s_%d_%dch_{epoch:02d}_{val_loss:.2f}.h5",
model_name,
dim_size,
channels
))
callbacks_list <- list(
keras::callback_csv_logger(
filename = log_file_tmpl
),
keras::callback_early_stopping(
monitor = "val_loss",
min_delta = 1e-4,
patience = 8,
verbose = 1,
mode = "min"
),
keras::callback_reduce_lr_on_plateau(
monitor = "val_loss",
factor = 0.5, # уменьшаем lr в 2 раза
patience = 4,
verbose = 1,
min_delta = 1e-4,
mode = "min"
),
keras::callback_model_checkpoint(
filepath = model_file_tmpl,
monitor = "val_loss",
save_best_only = FALSE,
save_weights_only = FALSE,
mode = "min"
)
)8. In conclusione
Una serie di problemi che abbiamo affrontato non sono stati ancora risolti:
- in keras manca una funzione pronta per la ricerca automatica della velocità di apprendimento ottimale (analoga a
lr_findernella libreria fast.ai); con alcuni sforzi, è possibile portare su R implementazioni di terze parti, ad esempio, ; - come conseguenza del punto precedente, non siamo riusciti a trovare la giusta velocità di apprendimento utilizzando più GPU;
- mancano architetture moderne di reti neurali, specialmente quelle pre-addestrate su imagenet;
- non c'è one cycle policy e discriminative learning rates (il cosine annealing su nostra richiesta è stato , grazie ).
Cosa utile si è riusciti a trarre da questa competizione:
- Su hardware relativamente poco potente è possibile lavorare senza problemi con grandi volumi di dati (molto superiori alla dimensione della RAM). Il pacchetto data.table risparmia memoria grazie alla modifica in-place delle tabelle, evitando così la copia e, se utilizzato correttamente, mostra quasi sempre la maggiore velocità tra tutti gli strumenti a noi conosciuti per linguaggi di scripting. Salvare dati in un DB consente, in molti casi, di non pensare affatto alla necessità di far entrare l'intero dataset in RAM.
- Le funzioni lente in R possono essere sostituite da quelle veloci in C++ utilizzando il pacchetto Rcpp. Se in più si utilizza RcppThread o RcppParallel, si ottengono implementazioni cross-platform multi-threaded, quindi non è necessario parallelizzare il codice a livello di R.
- Il pacchetto Rcpp può essere utilizzato senza conoscenze avanzate di C++, il minimo necessario è esposto . I file di intestazione per diverse librerie C avanzate come xtensor sono disponibili su CRAN, quindi si sta formando un'infrastruttura per realizzare progetti che integrano in R codice C++ ad alte prestazioni pronto all'uso. Ulteriore comodità — l'evidenziazione della sintassi e l'analizzatore statico di codice C++ in RStudio.
- docopt consente di eseguire script autonomi con parametri. È comodo per l'uso su server remoti, compreso sotto Docker. In RStudio, condurre esperimenti di formazione di reti neurali che durano ore non è pratico, e installare l'IDE su un server non è sempre giustificato.
- Docker garantisce la portabilità del codice e la riproducibilità dei risultati tra sviluppatori con diverse versioni del sistema operativo e delle librerie, oltre a semplificare l'avvio sui 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 con attenzione le configurazioni.
- Misurare la velocità di esecuzione di singoli frammenti di codice è molto utile, soprattutto quando si combinano R e C++, e con il pacchetto bench è anche molto facile.
In generale, questa esperienza è stata molto utile, e continuiamo a lavorare per risolvere alcuni dei problemi sollevati.
Fonte: habr.com
