ProHoster > blog > amministrazione > Quick Draw Doodle Recognition: come fare amicizia con R, C++ e reti neurali
Quick Draw Doodle Recognition: come fare amicizia con R, C++ e reti neurali
Ehi Habr!
Lo scorso autunno, Kaggle ha ospitato un concorso per classificare le immagini disegnate a mano, Quick Draw Doodle Recognition, a cui ha preso parte, tra gli altri, un team di scienziati R: Artem Klevtsova, Direttore Filippa и Andrej Ogurtsov. Non descriveremo il concorso nei dettagli, questo è già stato fatto recente pubblicazione.
Questa volta non ha funzionato con l'allevamento di medaglie, ma è stata acquisita molta preziosa esperienza, quindi vorrei raccontare alla comunità alcune delle cose più interessanti e utili su Kagle e nel lavoro quotidiano. Tra gli argomenti trattati: la vita difficile senza OpenCV, Analisi JSON (questi esempi esaminano l'integrazione del codice C++ in script o pacchetti in R utilizzando Rcpp), parametrizzazione degli script e dockerizzazione della soluzione finale. Tutto il codice del messaggio in una forma adatta per l'esecuzione è disponibile in repository.
1. Carica in modo efficiente i dati da CSV nel database MonetDB
I dati di questo concorso non vengono forniti sotto forma di immagini già pronte, ma sotto forma di 340 file CSV (un file per ogni classe) contenenti JSON con coordinate di punti. Collegando questi punti con delle linee, otteniamo un'immagine finale che misura 256x256 pixel. Inoltre per ogni record è presente un'etichetta che indica se l'immagine è stata correttamente riconosciuta dal classificatore utilizzato al momento della raccolta del dataset, un codice di due lettere del paese di residenza dell'autore dell'immagine, un identificatore univoco, un timestamp e un nome di classe che corrisponde al nome del file. Una versione semplificata dei dati originali pesa 7.4 GB nell'archivio e circa 20 GB dopo l'estrazione, i dati completi dopo l'estrazione occupano 240 GB. Gli organizzatori hanno assicurato che entrambe le versioni riproducessero gli stessi disegni, il che significa che la versione completa era ridondante. In ogni caso, archiviare 50 milioni di immagini in file grafici o sotto forma di array è stato subito considerato non redditizio e abbiamo deciso di unire tutti i file CSV dall'archivio treno_simplificato.zip nel database con successiva generazione “al volo” di immagini della dimensione richiesta per ciascun lotto.
Come DBMS è stato scelto un sistema ben collaudato Monet DB, ovvero un'implementazione per R come pacchetto MonetDBLite. Il pacchetto include una versione incorporata del server database e consente di prelevare il server direttamente da una sessione R e lavorarci lì. La creazione di un database e la connessione ad esso vengono eseguite con un comando:
con <- DBI::dbConnect(drv = MonetDBLite::MonetDBLite(), Sys.getenv("DBDIR"))
Dovremo creare due tabelle: una per tutti i dati, l'altra per le informazioni di servizio sui file scaricati (utile se qualcosa va storto e il processo deve essere ripreso dopo aver scaricato diversi file):
Il modo più veloce per caricare i dati nel database era copiare direttamente i file CSV utilizzando il comando SQL COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTDove tablename - nome della tabella e path - il percorso del file. Lavorando con l'archivio, si è scoperto che l'implementazione integrata unzip in R non funziona correttamente con un numero di file dall'archivio, quindi abbiamo utilizzato il sistema unzip (utilizzando il parametro getOption("unzip")).
Funzione per scrivere nel database
#' @title Извлечение и загрузка файлов
#'
#' @description
#' Извлечение CSV-файлов из ZIP-архива и загрузка их в базу данных
#'
#' @param con Объект подключения к базе данных (класс `MonetDBEmbeddedConnection`).
#' @param tablename Название таблицы в базе данных.
#' @oaram zipfile Путь к ZIP-архиву.
#' @oaram filename Имя файла внури ZIP-архива.
#' @param preprocess Функция предобработки, которая будет применена извлечённому файлу.
#' Должна принимать один аргумент `data` (объект `data.table`).
#'
#' @return `TRUE`.
#'
upload_file <- function(con, tablename, zipfile, filename, preprocess = NULL) {
# Проверка аргументов
checkmate::assert_class(con, "MonetDBEmbeddedConnection")
checkmate::assert_string(tablename)
checkmate::assert_string(filename)
checkmate::assert_true(DBI::dbExistsTable(con, tablename))
checkmate::assert_file_exists(zipfile, access = "r", extension = "zip")
checkmate::assert_function(preprocess, args = c("data"), null.ok = TRUE)
# Извлечение файла
path <- file.path(tempdir(), filename)
unzip(zipfile, files = filename, exdir = tempdir(),
junkpaths = TRUE, unzip = getOption("unzip"))
on.exit(unlink(file.path(path)))
# Применяем функция предобработки
if (!is.null(preprocess)) {
.data <- data.table::fread(file = path)
.data <- preprocess(data = .data)
data.table::fwrite(x = .data, file = path, append = FALSE)
rm(.data)
}
# Запрос к БД на импорт CSV
sql <- sprintf(
"COPY OFFSET 2 INTO %s FROM '%s' USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORT",
tablename, path
)
# Выполнение запроса к БД
DBI::dbExecute(con, sql)
# Добавление записи об успешной загрузке в служебную таблицу
DBI::dbExecute(con, sprintf("INSERT INTO upload_log(file_name, uploaded) VALUES('%s', true)",
filename))
return(invisible(TRUE))
}
Se è necessario trasformare la tabella prima di scriverla nel database, è sufficiente passare l'argomento preprocess funzione che trasformerà i dati.
Codice per il caricamento sequenziale dei dati nel database:
Scrittura dei dati nel database
# Список файлов для записи
files <- unzip(zipfile, list = TRUE)$Name
# Список исключений, если часть файлов уже была загружена
to_skip <- DBI::dbGetQuery(con, "SELECT file_name FROM upload_log")[[1L]]
files <- setdiff(files, to_skip)
if (length(files) > 0L) {
# Запускаем таймер
tictoc::tic()
# Прогресс бар
pb <- txtProgressBar(min = 0L, max = length(files), style = 3)
for (i in seq_along(files)) {
upload_file(con = con, tablename = "doodles",
zipfile = zipfile, filename = files[i])
setTxtProgressBar(pb, i)
}
close(pb)
# Останавливаем таймер
tictoc::toc()
}
# 526.141 sec elapsed - копирование SSD->SSD
# 558.879 sec elapsed - копирование USB->SSD
Il tempo di caricamento dei dati può variare a seconda delle caratteristiche di velocità dell'unità utilizzata. Nel nostro caso, leggere e scrivere all'interno di un SSD o da un'unità flash (file sorgente) a un SSD (DB) richiede meno di 10 minuti.
Sono necessari alcuni secondi in più per creare una colonna con un'etichetta di classe intera e una colonna di indice (ORDERED INDEX) con i numeri di riga in base ai quali verranno campionate le osservazioni durante la creazione dei batch:
Creazione di colonne e indici aggiuntivi
message("Generate lables")
invisible(DBI::dbExecute(con, "ALTER TABLE doodles ADD label_int int"))
invisible(DBI::dbExecute(con, "UPDATE doodles SET label_int = dense_rank() OVER (ORDER BY word) - 1"))
message("Generate row numbers")
invisible(DBI::dbExecute(con, "ALTER TABLE doodles ADD id serial"))
invisible(DBI::dbExecute(con, "CREATE ORDERED INDEX doodles_id_ord_idx ON doodles(id)"))
Per risolvere il problema della creazione di un batch al volo, dovevamo raggiungere la massima velocità di estrazione di righe casuali dalla tabella doodles. Per questo abbiamo utilizzato 3 trucchi. Il primo era ridurre la dimensionalità del tipo che memorizza l'ID dell'osservazione. Nel set di dati originale, il tipo richiesto per memorizzare l'ID è bigint, ma il numero di osservazioni rende possibile adattare i loro identificatori, pari al numero ordinale, nel tipo int. In questo caso la ricerca è molto più veloce. Il secondo trucco era usare ORDERED INDEX — siamo giunti a questa decisione empiricamente, dopo aver esaminato tutto ciò che era disponibile opzioni. Il terzo consisteva nell'utilizzare query con parametri. L'essenza del metodo è eseguire il comando una volta PREPARE con successivo utilizzo di un'espressione preparata durante la creazione di un gruppo di query dello stesso tipo, ma in realtà c'è un vantaggio rispetto a una semplice SELECT risulta rientrare nell’intervallo di errore statistico.
Il processo di caricamento dei dati non consuma più di 450 MB di RAM. Cioè, l'approccio descritto ti consente di spostare set di dati del peso di decine di gigabyte su quasi tutti gli hardware economici, inclusi alcuni dispositivi a scheda singola, il che è piuttosto interessante.
Non resta che misurare la velocità di recupero dei dati (casuali) e valutare la scala durante il campionamento di lotti di dimensioni diverse:
Punto di riferimento della banca dati
library(ggplot2)
set.seed(0)
# Подключение к базе данных
con <- DBI::dbConnect(MonetDBLite::MonetDBLite(), Sys.getenv("DBDIR"))
# Функция для подготовки запроса на стороне сервера
prep_sql <- function(batch_size) {
sql <- sprintf("PREPARE SELECT id FROM doodles WHERE id IN (%s)",
paste(rep("?", batch_size), collapse = ","))
res <- DBI::dbSendQuery(con, sql)
return(res)
}
# Функция для извлечения данных
fetch_data <- function(rs, batch_size) {
ids <- sample(seq_len(n), batch_size)
res <- DBI::dbFetch(DBI::dbBind(rs, as.list(ids)))
return(res)
}
# Проведение замера
res_bench <- bench::press(
batch_size = 2^(4:10),
{
rs <- prep_sql(batch_size)
bench::mark(
fetch_data(rs, batch_size),
min_iterations = 50L
)
}
)
# Параметры бенчмарка
cols <- c("batch_size", "min", "median", "max", "itr/sec", "total_time", "n_itr")
res_bench[, cols]
# batch_size min median max `itr/sec` total_time n_itr
# <dbl> <bch:tm> <bch:tm> <bch:tm> <dbl> <bch:tm> <int>
# 1 16 23.6ms 54.02ms 93.43ms 18.8 2.6s 49
# 2 32 38ms 84.83ms 151.55ms 11.4 4.29s 49
# 3 64 63.3ms 175.54ms 248.94ms 5.85 8.54s 50
# 4 128 83.2ms 341.52ms 496.24ms 3.00 16.69s 50
# 5 256 232.8ms 653.21ms 847.44ms 1.58 31.66s 50
# 6 512 784.6ms 1.41s 1.98s 0.740 1.1m 49
# 7 1024 681.7ms 2.72s 4.06s 0.377 2.16m 49
ggplot(res_bench, aes(x = factor(batch_size), y = median, group = 1)) +
geom_point() +
geom_line() +
ylab("median time, s") +
theme_minimal()
DBI::dbDisconnect(con, shutdown = TRUE)
2. Preparazione dei lotti
L'intero processo di preparazione del batch consiste nei seguenti passaggi:
Analisi di diversi JSON contenenti vettori di stringhe con coordinate di punti.
Disegnare linee colorate in base alle coordinate dei punti su un'immagine della dimensione richiesta (ad esempio 256×256 o 128×128).
Convertire le immagini risultanti in un tensore.
Nell'ambito della competizione tra i kernel Python, il problema è stato risolto principalmente utilizzando OpenCV. Uno degli analoghi più semplici e ovvi in R sarebbe simile a questo:
Implementazione della conversione da JSON a tensore in R
r_process_json_str <- function(json, line.width = 3,
color = TRUE, scale = 1) {
# Парсинг JSON
coords <- jsonlite::fromJSON(json, simplifyMatrix = FALSE)
tmp <- tempfile()
# Удаляем временный файл по завершению функции
on.exit(unlink(tmp))
png(filename = tmp, width = 256 * scale, height = 256 * scale, pointsize = 1)
# Пустой график
plot.new()
# Размер окна графика
plot.window(xlim = c(256 * scale, 0), ylim = c(256 * scale, 0))
# Цвета линий
cols <- if (color) rainbow(length(coords)) else "#000000"
for (i in seq_along(coords)) {
lines(x = coords[[i]][[1]] * scale, y = coords[[i]][[2]] * scale,
col = cols[i], lwd = line.width)
}
dev.off()
# Преобразование изображения в 3-х мерный массив
res <- png::readPNG(tmp)
return(res)
}
r_process_json_vector <- function(x, ...) {
res <- lapply(x, r_process_json_str, ...)
# Объединение 3-х мерных массивов картинок в 4-х мерный в тензор
res <- do.call(abind::abind, c(res, along = 0))
return(res)
}
Il disegno viene eseguito utilizzando gli strumenti R standard e salvato in un PNG temporaneo archiviato nella RAM (su Linux, le directory temporanee di R si trovano nella directory /tmp, montato nella RAM). Questo file viene quindi letto come un array tridimensionale con numeri compresi tra 0 e 1. Questo è importante perché un BMP più convenzionale verrebbe letto in un array grezzo con codici colore esadecimali.
Questa implementazione ci è sembrata non ottimale, poiché la formazione di grandi lotti richiede tempi indecentemente lunghi, e abbiamo deciso di sfruttare l'esperienza dei nostri colleghi utilizzando una potente libreria OpenCV. A quel tempo non esisteva un pacchetto pronto per R (non ce n'è adesso), quindi un'implementazione minima della funzionalità richiesta è stata scritta in C++ con integrazione nel codice R utilizzando Rcpp.
Per risolvere il problema sono stati utilizzati i seguenti pacchetti e librerie:
OpenCV per lavorare con immagini e disegnare linee. Utilizzate librerie di sistema e file di intestazione preinstallati, nonché collegamento dinamico.
tensore per lavorare con array e tensori multidimensionali. Abbiamo utilizzato i file header inclusi nel pacchetto R con lo stesso nome. La libreria consente di lavorare con array multidimensionali, sia nell'ordine principale della riga che in quello principale della colonna.
ndjson per l'analisi JSON. Questa libreria è utilizzata in tensore automaticamente se è presente nel progetto.
RcppThread per organizzare l'elaborazione multi-thread di un vettore da JSON. Utilizzati i file header forniti da questo pacchetto. Da più popolare RcppParallel Il pacchetto, tra le altre cose, ha un meccanismo di interruzione del loop integrato.
Va notato che tensore si è rivelata una manna dal cielo: oltre ad avere funzionalità estese e prestazioni elevate, i suoi sviluppatori si sono rivelati abbastanza reattivi e hanno risposto alle domande in modo tempestivo e dettagliato. Con il loro aiuto, è stato possibile implementare trasformazioni di matrici OpenCV in tensori xtensori, nonché un modo per combinare tensori di immagini tridimensionali in un tensore quadridimensionale della dimensione corretta (il batch stesso).
Materiali per l'apprendimento di Rcpp, xtensor e RcppThread
Per compilare file che utilizzano file di sistema e collegamento dinamico con le librerie installate sul sistema, abbiamo utilizzato il meccanismo plugin implementato nel pacchetto Rcpp. Per trovare automaticamente percorsi e flag, abbiamo utilizzato una popolare utility Linux pkg-config.
Implementazione del plugin Rcpp per l'utilizzo della libreria OpenCV
Il codice di implementazione per l'analisi di JSON e la generazione di un batch per la trasmissione al modello è riportato sotto lo spoiler. Innanzitutto, aggiungi una directory di progetto locale per cercare i file di intestazione (necessari per ndjson):
Implementazione della conversione da JSON a tensore in C++
// [[Rcpp::plugins(cpp14)]]
// [[Rcpp::plugins(opencv)]]
// [[Rcpp::depends(xtensor)]]
// [[Rcpp::depends(RcppThread)]]
#include <xtensor/xjson.hpp>
#include <xtensor/xadapt.hpp>
#include <xtensor/xview.hpp>
#include <xtensor-r/rtensor.hpp>
#include <opencv2/core/core.hpp>
#include <opencv2/highgui/highgui.hpp>
#include <opencv2/imgproc/imgproc.hpp>
#include <Rcpp.h>
#include <RcppThread.h>
// Синонимы для типов
using RcppThread::parallelFor;
using json = nlohmann::json;
using points = xt::xtensor<double,2>; // Извлечённые из JSON координаты точек
using strokes = std::vector<points>; // Извлечённые из JSON координаты точек
using xtensor3d = xt::xtensor<double, 3>; // Тензор для хранения матрицы изоображения
using xtensor4d = xt::xtensor<double, 4>; // Тензор для хранения множества изображений
using rtensor3d = xt::rtensor<double, 3>; // Обёртка для экспорта в R
using rtensor4d = xt::rtensor<double, 4>; // Обёртка для экспорта в R
// Статические константы
// Размер изображения в пикселях
const static int SIZE = 256;
// Тип линии
// См. https://en.wikipedia.org/wiki/Pixel_connectivity#2-dimensional
const static int LINE_TYPE = cv::LINE_4;
// Толщина линии в пикселях
const static int LINE_WIDTH = 3;
// Алгоритм ресайза
// https://docs.opencv.org/3.1.0/da/d54/group__imgproc__transform.html#ga5bb5a1fea74ea38e1a5445ca803ff121
const static int RESIZE_TYPE = cv::INTER_LINEAR;
// Шаблон для конвертирования OpenCV-матрицы в тензор
template <typename T, int NCH, typename XT=xt::xtensor<T,3,xt::layout_type::column_major>>
XT to_xt(const cv::Mat_<cv::Vec<T, NCH>>& src) {
// Размерность целевого тензора
std::vector<int> shape = {src.rows, src.cols, NCH};
// Общее количество элементов в массиве
size_t size = src.total() * NCH;
// Преобразование cv::Mat в xt::xtensor
XT res = xt::adapt((T*) src.data, size, xt::no_ownership(), shape);
return res;
}
// Преобразование JSON в список координат точек
strokes parse_json(const std::string& x) {
auto j = json::parse(x);
// Результат парсинга должен быть массивом
if (!j.is_array()) {
throw std::runtime_error("'x' must be JSON array.");
}
strokes res;
res.reserve(j.size());
for (const auto& a: j) {
// Каждый элемент массива должен быть 2-мерным массивом
if (!a.is_array() || a.size() != 2) {
throw std::runtime_error("'x' must include only 2d arrays.");
}
// Извлечение вектора точек
auto p = a.get<points>();
res.push_back(p);
}
return res;
}
// Отрисовка линий
// Цвета HSV
cv::Mat ocv_draw_lines(const strokes& x, bool color = true) {
// Исходный тип матрицы
auto stype = color ? CV_8UC3 : CV_8UC1;
// Итоговый тип матрицы
auto dtype = color ? CV_32FC3 : CV_32FC1;
auto bg = color ? cv::Scalar(0, 0, 255) : cv::Scalar(255);
auto col = color ? cv::Scalar(0, 255, 220) : cv::Scalar(0);
cv::Mat img = cv::Mat(SIZE, SIZE, stype, bg);
// Количество линий
size_t n = x.size();
for (const auto& s: x) {
// Количество точек в линии
size_t n_points = s.shape()[1];
for (size_t i = 0; i < n_points - 1; ++i) {
// Точка начала штриха
cv::Point from(s(0, i), s(1, i));
// Точка окончания штриха
cv::Point to(s(0, i + 1), s(1, i + 1));
// Отрисовка линии
cv::line(img, from, to, col, LINE_WIDTH, LINE_TYPE);
}
if (color) {
// Меняем цвет линии
col[0] += 180 / n;
}
}
if (color) {
// Меняем цветовое представление на RGB
cv::cvtColor(img, img, cv::COLOR_HSV2RGB);
}
// Меняем формат представления на float32 с диапазоном [0, 1]
img.convertTo(img, dtype, 1 / 255.0);
return img;
}
// Обработка JSON и получение тензора с данными изображения
xtensor3d process(const std::string& x, double scale = 1.0, bool color = true) {
auto p = parse_json(x);
auto img = ocv_draw_lines(p, color);
if (scale != 1) {
cv::Mat out;
cv::resize(img, out, cv::Size(), scale, scale, RESIZE_TYPE);
cv::swap(img, out);
out.release();
}
xtensor3d arr = color ? to_xt<double,3>(img) : to_xt<double,1>(img);
return arr;
}
// [[Rcpp::export]]
rtensor3d cpp_process_json_str(const std::string& x,
double scale = 1.0,
bool color = true) {
xtensor3d res = process(x, scale, color);
return res;
}
// [[Rcpp::export]]
rtensor4d cpp_process_json_vector(const std::vector<std::string>& x,
double scale = 1.0,
bool color = false) {
size_t n = x.size();
size_t dim = floor(SIZE * scale);
size_t channels = color ? 3 : 1;
xtensor4d res({n, dim, dim, channels});
parallelFor(0, n, [&x, &res, scale, color](int i) {
xtensor3d tmp = process(x[i], scale, color);
auto view = xt::view(res, i, xt::all(), xt::all(), xt::all());
view = tmp;
});
return res;
}
Questo codice dovrebbe essere inserito nel file src/cv_xt.cpp e compilare con il comando Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); richiesto anche per lavoro nlohmann/json.hpp di deposito. Il codice è suddiviso in diverse funzioni:
to_xt — una funzione basata su modelli per trasformare una matrice di immagini (cv::Mat) a un tensore xt::xtensor;
parse_json — la funzione analizza una stringa JSON, estrae le coordinate dei punti, impacchettandole in un vettore;
ocv_draw_lines — dal vettore di punti risultante, disegna linee multicolori;
process — combina le funzioni di cui sopra e aggiunge anche la possibilità di ridimensionare l'immagine risultante;
cpp_process_json_str - wrapper sopra la funzione process, che esporta il risultato in un oggetto R (array multidimensionale);
cpp_process_json_vector - wrapper sopra la funzione cpp_process_json_str, che consente di elaborare un vettore di stringa in modalità multi-thread.
Per disegnare linee multicolori è stato utilizzato il modello di colore HSV, seguito dalla conversione in RGB. Testiamo il risultato:
Come puoi vedere, l'aumento di velocità si è rivelato molto significativo e non è possibile raggiungere il codice C++ parallelizzando il codice R.
3. Iteratori per lo scarico dei batch dal database
R ha una meritata reputazione per l'elaborazione dei dati che si adattano alla RAM, mentre Python è più caratterizzato dall'elaborazione iterativa dei dati, che consente di implementare facilmente e naturalmente calcoli out-of-core (calcoli utilizzando memoria esterna). Un esempio classico e rilevante per noi nel contesto del problema descritto sono le reti neurali profonde addestrate con il metodo della discesa del gradiente con approssimazione del gradiente ad ogni passaggio utilizzando una piccola porzione di osservazioni, o mini-batch.
I framework di deep learning scritti in Python hanno classi speciali che implementano iteratori basati su dati: tabelle, immagini in cartelle, formati binari, ecc. Puoi utilizzare opzioni già pronte o scriverne di tue per attività specifiche. In R possiamo sfruttare tutte le funzionalità della libreria Python keras con i suoi vari backend utilizzando il pacchetto con lo stesso nome, che a sua volta funziona sopra il pacchetto reticolare. Quest'ultimo merita un lungo articolo a parte; non solo consente di eseguire codice Python da R, ma consente anche di trasferire oggetti tra sessioni R e Python, eseguendo automaticamente tutte le conversioni di tipo necessarie.
Abbiamo eliminato la necessità di archiviare tutti i dati nella RAM utilizzando MonetDBLite, tutto il lavoro della "rete neurale" verrà eseguito dal codice originale in Python, dobbiamo solo scrivere un iteratore sui dati, poiché non c'è nulla di pronto per una situazione del genere in R o Python. Ci sono essenzialmente solo due requisiti: deve restituire batch in un ciclo infinito e salvare il suo stato tra le iterazioni (quest'ultimo in R è implementato nel modo più semplice utilizzando le chiusure). In precedenza, era necessario convertire esplicitamente gli array R in array numpy all'interno dell'iteratore, ma la versione attuale del pacchetto keras lo fa da sola.
L'iteratore per i dati di training e validazione si è rivelato il seguente:
Iteratore per i dati di training e validazione
train_generator <- function(db_connection = con,
samples_index,
num_classes = 340,
batch_size = 32,
scale = 1,
color = FALSE,
imagenet_preproc = FALSE) {
# Проверка аргументов
checkmate::assert_class(con, "DBIConnection")
checkmate::assert_integerish(samples_index)
checkmate::assert_count(num_classes)
checkmate::assert_count(batch_size)
checkmate::assert_number(scale, lower = 0.001, upper = 5)
checkmate::assert_flag(color)
checkmate::assert_flag(imagenet_preproc)
# Перемешиваем, чтобы брать и удалять использованные индексы батчей по порядку
dt <- data.table::data.table(id = sample(samples_index))
# Проставляем номера батчей
dt[, batch := (.I - 1L) %/% batch_size + 1L]
# Оставляем только полные батчи и индексируем
dt <- dt[, if (.N == batch_size) .SD, keyby = batch]
# Устанавливаем счётчик
i <- 1
# Количество батчей
max_i <- dt[, max(batch)]
# Подготовка выражения для выгрузки
sql <- sprintf(
"PREPARE SELECT drawing, label_int FROM doodles WHERE id IN (%s)",
paste(rep("?", batch_size), collapse = ",")
)
res <- DBI::dbSendQuery(con, sql)
# Аналог keras::to_categorical
to_categorical <- function(x, num) {
n <- length(x)
m <- numeric(n * num)
m[x * n + seq_len(n)] <- 1
dim(m) <- c(n, num)
return(m)
}
# Замыкание
function() {
# Начинаем новую эпоху
if (i > max_i) {
dt[, id := sample(id)]
data.table::setkey(dt, batch)
# Сбрасываем счётчик
i <<- 1
max_i <<- dt[, max(batch)]
}
# ID для выгрузки данных
batch_ind <- dt[batch == i, id]
# Выгрузка данных
batch <- DBI::dbFetch(DBI::dbBind(res, as.list(batch_ind)), n = -1)
# Увеличиваем счётчик
i <<- i + 1
# Парсинг JSON и подготовка массива
batch_x <- cpp_process_json_vector(batch$drawing, scale = scale, color = color)
if (imagenet_preproc) {
# Шкалирование c интервала [0, 1] на интервал [-1, 1]
batch_x <- (batch_x - 0.5) * 2
}
batch_y <- to_categorical(batch$label_int, num_classes)
result <- list(batch_x, batch_y)
return(result)
}
}
La funzione prende come input una variabile con connessione al database, il numero di righe utilizzate, il numero di classi, la dimensione del batch, la scala (scale = 1 corrisponde al rendering di immagini di 256x256 pixel, scale = 0.5 — 128x128 pixel), indicatore di colore (color = FALSE specifica il rendering in scala di grigi quando utilizzato color = TRUE ogni tratto viene disegnato con un nuovo colore) e un indicatore di preelaborazione per le reti pre-addestrate su imagenet. Quest'ultimo è necessario per scalare i valori dei pixel dall'intervallo [0, 1] all'intervallo [-1, 1], utilizzato durante l'addestramento del programma fornito keras modelli.
La funzione esterna contiene il controllo del tipo di argomento, una tabella data.table con numeri di riga mescolati casualmente da samples_index e numeri di batch, contatore e numero massimo di batch, nonché un'espressione SQL per scaricare i dati dal database. Inoltre, abbiamo definito un veloce analogo della funzione all'interno keras::to_categorical(). Abbiamo utilizzato quasi tutti i dati per l'addestramento, lasciando metà percentuale per la convalida, quindi la dimensione dell'epoca era limitata dal parametro steps_per_epoch quando chiamato keras::fit_generator()e la condizione if (i > max_i) ha funzionato solo per l'iteratore di convalida.
Nella funzione interna vengono recuperati gli indici delle righe per il batch successivo, i record vengono scaricati dal database con l'incremento del contatore batch, l'analisi JSON (funzione cpp_process_json_vector(), scritto in C++) e creando array corrispondenti alle immagini. Quindi vengono creati vettori one-hot con etichette di classe, gli array con valori di pixel ed etichette vengono combinati in un elenco, che è il valore restituito. Per velocizzare il lavoro, abbiamo utilizzato la creazione di indici nelle tabelle data.table e modifica tramite il collegamento - senza questi "chip" del pacchetto tabella dati È abbastanza difficile immaginare di lavorare in modo efficace con una quantità significativa di dati in R.
I risultati delle misurazioni della velocità su un laptop Core i5 sono i seguenti:
Se disponi di una quantità sufficiente di RAM, puoi accelerare notevolmente il funzionamento del database trasferendolo su questa stessa RAM (32 GB sono sufficienti per il nostro compito). In Linux, la partizione è montata per impostazione predefinita /dev/shm, occupando fino alla metà della capacità della RAM. Puoi evidenziarne di più modificandoli /etc/fstabper ottenere un record come tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Assicurati di riavviare e controlla il risultato eseguendo il comando df -h.
L'iteratore per i dati di test sembra molto più semplice, poiché il set di dati di test si inserisce interamente nella RAM:
Iteratore per i dati di test
test_generator <- function(dt,
batch_size = 32,
scale = 1,
color = FALSE,
imagenet_preproc = FALSE) {
# Проверка аргументов
checkmate::assert_data_table(dt)
checkmate::assert_count(batch_size)
checkmate::assert_number(scale, lower = 0.001, upper = 5)
checkmate::assert_flag(color)
checkmate::assert_flag(imagenet_preproc)
# Проставляем номера батчей
dt[, batch := (.I - 1L) %/% batch_size + 1L]
data.table::setkey(dt, batch)
i <- 1
max_i <- dt[, max(batch)]
# Замыкание
function() {
batch_x <- cpp_process_json_vector(dt[batch == i, drawing],
scale = scale, color = color)
if (imagenet_preproc) {
# Шкалирование c интервала [0, 1] на интервал [-1, 1]
batch_x <- (batch_x - 0.5) * 2
}
result <- list(batch_x)
i <<- i + 1
return(result)
}
}
4. Selezione dell'architettura del modello
La prima architettura utilizzata è stata rete mobile v1, le cui caratteristiche sono discusse in Questo Messaggio. È incluso di serie keras e, di conseguenza, è disponibile nel pacchetto con lo stesso nome per R. Ma provando a usarlo con immagini a canale singolo, si è verificata una cosa strana: il tensore di ingresso deve sempre avere la dimensione (batch, height, width, 3), ovvero il numero di canali non può essere modificato. Non esiste tale limitazione in Python, quindi ci siamo affrettati a scrivere la nostra implementazione di questa architettura, seguendo l'articolo originale (senza il dropout presente nella versione Keras):
Gli svantaggi di questo approccio sono evidenti. Voglio testare molti modelli, ma al contrario, non voglio riscrivere manualmente ogni architettura. Siamo stati inoltre privati dell'opportunità di utilizzare i pesi dei modelli pre-addestrati su imagenet. Come al solito, lo studio della documentazione ha aiutato. Funzione get_config() consente di ottenere una descrizione del modello in un formato adatto alla modifica (base_model_conf$layers - un elenco R regolare) e la funzione from_config() esegue la conversione inversa in un oggetto del modello:
Ora non è difficile scrivere una funzione universale per ottenere uno qualsiasi di quelli forniti keras modelli con o senza pesi allenati su imagenet:
Funzione per caricare architetture già pronte
get_model <- function(name = "mobilenet_v2",
input_shape = NULL,
weights = "imagenet",
pooling = "avg",
num_classes = NULL,
optimizer = keras::optimizer_adam(lr = 0.002),
loss = "categorical_crossentropy",
metrics = NULL,
color = TRUE,
compile = FALSE) {
# Проверка аргументов
checkmate::assert_string(name)
checkmate::assert_integerish(input_shape, lower = 1, upper = 256, len = 3)
checkmate::assert_count(num_classes)
checkmate::assert_flag(color)
checkmate::assert_flag(compile)
# Получаем объект из пакета keras
model_fun <- get0(paste0("application_", name), envir = asNamespace("keras"))
# Проверка наличия объекта в пакете
if (is.null(model_fun)) {
stop("Model ", shQuote(name), " not found.", call. = FALSE)
}
base_model <- model_fun(
input_shape = input_shape,
include_top = FALSE,
weights = weights,
pooling = pooling
)
# Если изображение не цветное, меняем размерность входа
if (!color) {
base_model_conf <- keras::get_config(base_model)
base_model_conf$layers[[1]]$config$batch_input_shape[[4]] <- 1L
base_model <- keras::from_config(base_model_conf)
}
predictions <- keras::get_layer(base_model, "global_average_pooling2d_1")$output
predictions <- keras::layer_dense(predictions, units = num_classes, activation = "softmax")
model <- keras::keras_model(
inputs = base_model$input,
outputs = predictions
)
if (compile) {
keras::compile(
object = model,
optimizer = optimizer,
loss = loss,
metrics = metrics
)
}
return(model)
}
Quando si utilizzano immagini a canale singolo, non vengono utilizzati pesi preaddestrati. Questo potrebbe essere risolto: utilizzando la funzione get_weights() ottieni i pesi del modello sotto forma di un elenco di array R, modifica la dimensione del primo elemento di questo elenco (prendendo un canale di colore o calcolando la media di tutti e tre), quindi carica nuovamente i pesi nel modello con la funzione set_weights(). Non abbiamo mai aggiunto questa funzionalità, perché in questa fase era già chiaro che sarebbe stato più produttivo lavorare con immagini a colori.
Abbiamo effettuato la maggior parte degli esperimenti utilizzando le versioni mobilenet 1 e 2, nonché resnet34. Architetture più moderne come SE-ResNeXt hanno ottenuto buoni risultati in questa competizione. Sfortunatamente, non avevamo a nostra disposizione implementazioni già pronte e non abbiamo scritto le nostre (ma le scriveremo sicuramente).
5. Parametrizzazione degli script
Per comodità, tutto il codice per l'avvio dell'addestramento è stato progettato come un unico script, parametrizzato utilizzando dottoressa следующим обрахом:
doc <- '
Usage:
train_nn.R --help
train_nn.R --list-models
train_nn.R [options]
Options:
-h --help Show this message.
-l --list-models List available models.
-m --model=<model> Neural network model name [default: mobilenet_v2].
-b --batch-size=<size> Batch size [default: 32].
-s --scale-factor=<ratio> Scale factor [default: 0.5].
-c --color Use color lines [default: FALSE].
-d --db-dir=<path> Path to database directory [default: Sys.getenv("db_dir")].
-r --validate-ratio=<ratio> Validate sample ratio [default: 0.995].
-n --n-gpu=<number> Number of GPUs [default: 1].
'
args <- docopt::docopt(doc)
Pacchetto dottoressa rappresenta l'implementazione http://docopt.org/ per R. Con il suo aiuto, gli script vengono avviati con semplici comandi come Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db o ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, se file train_nn.R è eseguibile (questo comando inizierà l'addestramento del modello resnet50 su immagini a tre colori di dimensione 128x128 pixel il database deve trovarsi nella cartella /home/andrey/doodle_db). Puoi aggiungere all'elenco la velocità di apprendimento, il tipo di ottimizzatore e qualsiasi altro parametro personalizzabile. Nel processo di preparazione della pubblicazione, si è scoperto che l'architettura mobilenet_v2 dalla versione attuale keras nell'uso R non deve a causa di modifiche non prese in considerazione nel pacchetto R, stiamo aspettando che risolvano il problema.
Questo approccio ha permesso di velocizzare notevolmente la sperimentazione con modelli diversi rispetto al più tradizionale lancio di script in RStudio (segnaliamo il pacchetto come possibile alternativa tfruns). Ma il vantaggio principale è la possibilità di gestire facilmente l'avvio degli script in Docker o semplicemente sul server, senza installare RStudio per questo.
6. Dockerizzazione degli script
Abbiamo utilizzato Docker per garantire la portabilità dell'ambiente per i modelli di formazione tra i membri del team e per una rapida implementazione nel cloud. Puoi iniziare a familiarizzare con questo strumento, relativamente insolito per un programmatore R, con questo serie di pubblicazioni o videocorso.
Docker ti consente sia di creare le tue immagini da zero sia di utilizzare altre immagini come base per crearne di tue. Analizzando le opzioni disponibili, siamo giunti alla conclusione che l'installazione dei driver NVIDIA, CUDA+cuDNN e delle librerie Python è una parte abbastanza voluminosa dell'immagine e abbiamo deciso di prendere come base l'immagine ufficiale tensorflow/tensorflow:1.12.0-gpu, aggiungendo lì i pacchetti R necessari.
Per comodità, i pacchetti utilizzati sono stati inseriti in variabili; la maggior parte degli script scritti vengono copiati all'interno dei contenitori in fase di assemblaggio. Abbiamo anche cambiato la shell dei comandi in /bin/bash per facilitare la fruizione dei contenuti /etc/os-release. Ciò ha evitato la necessità di specificare la versione del sistema operativo nel codice.
Inoltre, è stato scritto un piccolo script bash che consente di avviare un contenitore con vari comandi. Potrebbero ad esempio trattarsi di script per l'addestramento delle reti neurali precedentemente posizionati all'interno del contenitore o di una shell di comandi per il debug e il monitoraggio del funzionamento del contenitore:
Script per avviare il contenitore
#!/bin/sh
DBDIR=${PWD}/db
LOGSDIR=${PWD}/logs
MODELDIR=${PWD}/models
DATADIR=${PWD}/data
ARGS="--runtime=nvidia --rm -v ${DBDIR}:/db -v ${LOGSDIR}:/app/logs -v ${MODELDIR}:/app/models -v ${DATADIR}:/app/data"
if [ -z "$1" ]; then
CMD="Rscript /app/train_nn.R"
elif [ "$1" = "bash" ]; then
ARGS="${ARGS} -ti"
else
CMD="Rscript /app/train_nn.R $@"
fi
docker run ${ARGS} doodles-tf ${CMD}
Se questo script bash viene eseguito senza parametri, lo script verrà chiamato all'interno del contenitore train_nn.R con valori predefiniti; se il primo argomento posizionale è "bash", il contenitore verrà avviato in modo interattivo con una shell di comandi. In tutti gli altri casi, i valori degli argomenti posizionali vengono sostituiti: CMD="Rscript /app/train_nn.R $@".
Vale la pena notare che le directory con i dati di origine e il database, nonché la directory per il salvataggio dei modelli addestrati, sono montate all'interno del contenitore dal sistema host, il che consente di accedere ai risultati degli script senza manipolazioni inutili.
7. Utilizzo di più GPU su Google Cloud
Una delle caratteristiche del concorso erano i dati molto rumorosi (vedi l'immagine del titolo, presa in prestito da @Leigh.plt da ODS slack). Grandi lotti aiutano a combattere questo problema e, dopo gli esperimenti su un PC con 1 GPU, abbiamo deciso di padroneggiare i modelli di allenamento su diverse GPU nel cloud. Utilizzato GoogleCloud (buona guida alle nozioni di base) grazie all'ampia selezione di configurazioni disponibili, prezzi ragionevoli e bonus di $ 300. Per avidità, ho ordinato un'istanza 4xV100 con un SSD e un sacco di RAM, e questo è stato un grosso errore. Una macchina del genere consuma rapidamente denaro; puoi andare in rovina sperimentando senza una pipeline collaudata. Per scopi didattici è meglio prendere il K80. Ma la grande quantità di RAM è tornata utile: l'SSD cloud non ha impressionato con le sue prestazioni, quindi è stato trasferito il database dev/shm.
Di grande interesse è il frammento di codice responsabile dell'utilizzo di più GPU. Innanzitutto, il modello viene creato sulla CPU utilizzando un gestore di contesto, proprio come in Python:
Non è stato possibile implementare la tecnica classica di congelamento di tutti i livelli tranne l'ultimo, addestramento dell'ultimo livello, scongelamento e riaddestramento dell'intero modello per diverse GPU.
La formazione è stata monitorata senza utilizzo. tavola tensoriale, limitandoci a registrare i log e a salvare modelli con nomi informativi dopo ogni epoca:
Numerosi problemi che abbiamo riscontrato non sono ancora stati risolti:
в keras non esiste una funzione già pronta per la ricerca automatica del tasso di apprendimento ottimale (analogico lr_finder in biblioteca veloce.ai); Con un certo sforzo, è possibile trasferire implementazioni di terze parti su R, ad esempio, questo;
in conseguenza del punto precedente non è stato possibile selezionare la corretta velocità di training quando si utilizzano più GPU;
mancano le moderne architetture di rete neurale, in particolare quelle pre-addestrate su imagenet;
nessuna politica del ciclo unico e tassi di apprendimento discriminativi (la ricottura del coseno era su nostra richiesta implementatoGrazie skeydan).
Quali cose utili sono state apprese da questa competizione:
Su hardware relativamente a basso consumo, puoi lavorare con volumi di dati decenti (molte volte superiori alla dimensione della RAM) senza problemi. Sacchetto di plastica tabella dati risparmia memoria grazie alla modifica sul posto delle tabelle, che evita di copiarle e, se utilizzate correttamente, le sue capacità dimostrano quasi sempre la massima velocità tra tutti gli strumenti a noi noti per i linguaggi di scripting. Il salvataggio dei dati in un database consente, in molti casi, di non pensare affatto alla necessità di comprimere l'intero set di dati nella RAM.
Le funzioni lente in R possono essere sostituite con quelle veloci in C++ utilizzando il pacchetto Rcpp. Se oltre all'uso RcppThread o RcppParallel, otteniamo implementazioni multi-thread multipiattaforma, quindi non è necessario parallelizzare il codice a livello R.
Per pacchetto Rcpp può essere utilizzato senza una conoscenza approfondita del C++, viene delineato il minimo richiesto qui. File di intestazione per una serie di fantastiche librerie C come tensore disponibile su CRAN, ovvero si sta formando un'infrastruttura per l'implementazione di progetti che integrano codice C++ già pronto ad alte prestazioni in R. Ulteriore comodità è l'evidenziazione della sintassi e un analizzatore di codice C++ statico in RStudio.
dottoressa consente di eseguire script autonomi con parametri. Questo è comodo per l'uso su un server remoto, incl. sotto la finestra mobile. In RStudio è scomodo condurre molte ore di esperimenti con l'addestramento delle reti neurali e l'installazione dell'IDE sul server stesso non è sempre giustificata.
Docker garantisce la portabilità del codice e la riproducibilità dei risultati tra sviluppatori con diverse versioni del sistema operativo e delle librerie, nonché la facilità di esecuzione sui server. Puoi avviare l'intera pipeline di formazione con un solo comando.
Google Cloud è un modo economico per sperimentare hardware costoso, ma devi scegliere attentamente le configurazioni.
Misurare la velocità dei singoli frammenti di codice è molto utile, soprattutto quando si combinano R e C++ e con il pacchetto panchina - anche molto facile.
Nel complesso questa esperienza è stata molto gratificante e continuiamo a lavorare per risolvere alcune delle questioni sollevate.