
Hallo, Habr!
Im Herbst des letzten Jahres fand auf Kaggle ein Wettbewerb zur Klassifizierung handgezeichneter Bilder statt, bei dem unter anderem ein Team von R-Entwicklern teilnahm, bestehend aus , und . Wir werden den Wettbewerb nicht im Detail beschreiben, das wurde bereits in .
gemacht. Mit dem Medaillensammeln klappte es diesmal nicht, aber wir konnten viel wertvolle Erfahrung sammeln. Daher möchten wir der Gemeinschaft von einigen besonders interessanten und nützlichen Themen auf Kaggle und in der täglichen Arbeit berichten. Zu den behandelten Themen gehören: das herausfordernde Leben ohne OpenCV, das Parsen von JSON-Daten (in diesen Beispielen wird die Integration von C++-Code in Skripte oder Pakete in R mittels Rcpp), die Parametrisierung von Skripten und die Dockerisierung der abschließenden Lösung. Der gesamte Code aus diesem Beitrag ist in ausführbarem Format verfügbar in .
Inhalt:
1. Effektive Datenladungen aus CSV in die MonetDB-Datenbank
Die Daten dieses Wettbewerbs werden nicht als fertige Bilder bereitgestellt, sondern als 340 CSV-Dateien (eine Datei für jede Klasse), die JSON-Daten mit den Koordinaten der Punkte enthalten. Indem wir diese Punkte miteinander verbinden, erhalten wir das endgültige Bild mit einer Auflösung von 256 x 256 Pixeln. Für jeden Eintrag wird außerdem ein Label angegeben, das angibt, ob das Bild vom zu dem Zeitpunkt der Datensammlung verwendeten Klassifikator korrekt erkannt wurde, ein zweibuchstabiger Ländercode des Wohnsitzlandes des Zeichners, eine eindeutige ID, ein Zeitstempel und der Klassennamen, der dem Dateinamen entspricht. Die vereinfachte Version der ursprünglichen Daten hat eine Größe von 7,4 GB im Archiv und etwa 20 GB nach der Entpackung, während die vollständigen Daten nach der Entpackung 240 GB umfassen. Die Organisatoren haben garantiert, dass beide Versionen die gleichen Zeichnungen reproduzieren, die vollständige Version also redundant ist. In jedem Fall wurde die Speicherung von 50 Millionen Bildern in Grafikdateien oder in Form von Arrays von vornherein als wirtschaftlich untragbar angesehen, weshalb wir beschlossen haben, alle CSV-Dateien aus dem Archiv zu einer Datenbank zusammenzuführen. train_simplified.zip mit anschließender Generierung der benötigten Bilder „on-the-fly“ für jeden Batch.
Als Datenbankmanagementsystem wurde eine bewährte Lösung gewählt, MonetDB, nämlich die R-Implementierung als Paket . Das Paket enthält eine eingebettete Version des Datenbankservers und ermöglicht das Starten des Servers direkt aus der R-Sitzung, um dort zu arbeiten. Die Erstellung einer Datenbank und die Verbindung dazu erfolgen mit einem einzigen Befehl:
con <- DBI::dbConnect(drv = MonetDBLite::MonetDBLite(), Sys.getenv("DBDIR"))Wir werden zwei Tabellen erstellen müssen: eine für alle Daten und eine für Informationen über die hochgeladenen Dateien (nützlich, falls etwas schiefgeht und der Prozess nach dem Hochladen mehrerer Dateien fortgesetzt werden muss):
Tabellen erstellen
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"
)
)
}Der schnellste Weg, Daten in die Datenbank zu laden, stellte sich als das direkte Kopieren von CSV-Dateien mit SQL-Methoden heraus — der Befehl COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORT, wobei Tabellenname — der Tabellenname und der Pfad. — Dateipfad. Bei der Arbeit mit dem Archiv wurde festgestellt, dass die integrierte Implementierung unzip in R nicht korrekt mit einer Reihe von Dateien aus dem Archiv funktioniert, daher haben wir das System-Tool verwendet unzip (unter Verwendung des Parameters getOption("unzip")).
Funktion zum Schreiben in die Datenbank
#' @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))
}Falls eine Umwandlung der Tabelle vor dem Schreiben in die Datenbank erforderlich ist, genügt es, die Funktion als Argument zu übergeben preprocess die die Daten umwandelt.
Code zum sequentiellen Laden der Daten in die Datenbank:
Daten in die Datenbank schreiben
# Список файлов для записи
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->SSDDie Ladezeit der Daten kann je nach Geschwindigkeitsmerkmalen des verwendeten Speichermediums variieren. In unserem Fall dauert das Lesen und Schreiben zwischen einem SSD oder von einem USB-Stick (Quell-Datei) auf SSD (Datenbank) weniger als 10 Minuten.
Einige zusätzliche Sekunden werden benötigt, um eine Spalte mit ganzzahligen Klassenlabels und eine Index-Spalte (ORDERED INDEX) mit den Zeilennummern zu erstellen, nach denen die Beobachtungen beim Erstellen von Batches ausgewählt werden:
Erstellung zusätzlicher Spalten und des Index
message("Label generieren")
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("Zeilennummern generieren")
invisible(DBI::dbExecute(con, "ALTER TABLE doodles ADD id serial"))
invisible(DBI::dbExecute(con, "CREATE ORDERED INDEX doodles_id_ord_idx ON doodles(id)"))Um die Aufgabe der dynamischen Batch-Bildung zu lösen, war es notwendig, die maximale Geschwindigkeit beim Abrufen zufälliger Zeilen aus der Tabelle zu erreichen. doodles. Dafür haben wir 3 Tricks verwendet. Der erste bestand darin, die Datentypgröße, in der die Beobachtungs-IDs gespeichert werden, zu reduzieren. Im ursprünglichen Datensatz wird für die Speicherung der IDs der Typ bigint, aber die Anzahl der Beobachtungen ermöglicht es, ihre Identifikatoren in den Typ int. Daher erfolgt die Suche erheblich schneller. Der zweite Trick war die Verwendung von ORDERED INDEX — zu dieser Lösung kamen wir empirisch, indem wir alle verfügbaren . Der dritte bestand darin, parametrische Abfragen zu verwenden. Die Methode beinhaltet die einmalige Ausführung des Befehls PREPARE , gefolgt von der Nutzung des vorbereiteten Ausdrucks zur Erstellung einer Reihe homogener Abfragen, wobei sich in der Praxis der Gewinn im Vergleich zu einfachen Abfragen zeigt. SELECT liegt im Bereich der statistischen Fehlertoleranz.
Der Datenübertragungsprozess benötigt nicht mehr als 450 MB RAM. Das beschriebene Vorgehen ermöglicht es, Datensätze mit einem Gewicht von mehreren Gigabyte praktisch auf jeder budgetfreundlichen Hardware zu verarbeiten, einschließlich einiger Einplatinencomputer, was ziemlich beeindruckend ist.
Jetzt müssen die Geschwindigkeitsmessungen für die (zufälligen) Datenauszüge durchgeführt und die Skalierbarkeit bei der Auswahl von Batches unterschiedlicher Größe bewertet werden:
Datenbank-Benchmark
library(ggplot2)
set.seed(0)
# Verbindung zur Datenbank
con <- DBI::dbConnect(MonetDBLite::MonetDBLite(), Sys.getenv("DBDIR"))
# Funktion zur Vorbereitung der Anfrage auf der Serverseite
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)
}
# Funktion zum Abrufen von Daten
fetch_data <- function(rs, batch_size) {
ids <- sample(seq_len(n), batch_size)
res <- DBI::dbFetch(DBI::dbBind(rs, as.list(ids)))
return(res)
}
# Durchführung der Messung
res_bench <- bench::press(
batch_size = 2^(4:10),
{
rs <- prep_sql(batch_size)
bench::mark(
fetch_data(rs, batch_size),
min_iterations = 50L
)
}
)
# Benchmark-Parameter
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("Medianzeit, s") +
theme_minimal()
DBI::dbDisconnect(con, shutdown = TRUE) 
2. Vorbereitung der Batchs
Der gesamte Prozess der Batch-Vorbereitung besteht aus den folgenden Schritten:
- Parsing mehrerer JSON-Dateien, die Vektoren von Zeichenfolgen mit Koordinatenpunkten enthalten.
- Zeichnen von farbigen Linien basierend auf den Koordinatenpunkten auf einem Bild der gewünschten Größe (z. B. 256×256 oder 128×128).
- Umwandlung der erhaltenen Bilder in einen Tensor.
Im Rahmen des Wettbewerbs unter Kernels in Python wurde das Problem hauptsächlich mit OpenCVgelöst. Eine der einfachsten und offensichtlichsten Entsprechungen in R könnte wie folgt aussehen:
Implementierung der Umwandlung von JSON in einen Tensor in R.
r_process_json_str <- function(json, line.width = 3,
color = TRUE, scale = 1) {
# JSON-Parsing
coords <- jsonlite::fromJSON(json, simplifyMatrix = FALSE)
tmp <- tempfile()
# Temporäre Datei am Ende der Funktion löschen
on.exit(unlink(tmp))
png(filename = tmp, width = 256 * scale, height = 256 * scale, pointsize = 1)
# Leeres Diagramm
plot.new()
# Größe des Diagrammfensters
plot.window(xlim = c(256 * scale, 0), ylim = c(256 * scale, 0))
# Linienfarben
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()
# Bild in ein 3D-Array umwandeln
res <- png::readPNG(tmp)
return(res)
}
r_process_json_vector <- function(x, ...) {
res <- lapply(x, r_process_json_str, ...)
# 3D-Arrays von Bildern in ein 4D-Tensor zusammenführen
res <- do.call(abind::abind, c(res, along = 0))
return(res)
}Die Zeichnung erfolgt mit den Standardmitteln von R und wird in einem temporären PNG gespeichert, das im RAM gehalten wird (unter Linux befinden sich die temporären R-Verzeichnisse im Verzeichnis /tmp, das im RAM montiert ist). Diese Datei wird dann als dreidimensionales Array mit Werten im Bereich von 0 bis 1 gelesen. Das ist wichtig, da das allgemeinere BMP in ein raw-Array mit hexadezimalen Farbcodes gelesen worden wäre.
Lass uns das Ergebnis testen:
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)) 
Der Batch wird wie folgt erstellt:
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
# ..$ : NULLWir fanden diese Implementierung suboptimal, da die Erstellung großer Batches unangemessen viel Zeit in Anspruch nimmt, und entschieden uns, die Erfahrungen von Kollegen zu nutzen und eine leistungsstarke Bibliothek zu verwenden, OpenCV. Zu diesem Zeitpunkt gab es kein fertiges Paket für R (und es gibt es auch jetzt nicht), daher wurde eine minimalistische Implementierung der erforderlichen Funktionalität in C++ geschrieben, die in den R-Code integriert wurde über Rcpp.
Um die Aufgabe zu lösen, wurden folgende Pakete und Bibliotheken verwendet:
OpenCV zum Arbeiten mit Bildern und Zeichnen von Linien. Wir verwendeten vorinstallierte Systembibliotheken und Header-Dateien sowie die dynamische Verknüpfung.
xtensor zum Arbeiten mit mehrdimensionalen Arrays und Tensoren. Wir haben die Header-Dateien verwendet, die im gleichnamigen R-Paket enthalten sind. Die Bibliothek ermöglicht die Arbeit mit mehrdimensionalen Arrays, sowohl im Zeilen- als auch im Spaltenformat.
ndjson zum Parsen von JSON. Diese Bibliothek wird in xtensor automatisch verwendet, sofern sie im Projekt vorhanden ist.
RcppThread zur Organisation der mehrthreadigen Verarbeitung von Vektoren aus JSON. Wir haben die Header-Dateien dieses Pakets verwendet. Von dem populäreren RcppParallel unterscheidet sich das Paket unter anderem durch einen integrierten Mechanismus zum Unterbrechen von Schleifen (interrupt).
It is worth noting that xtensor war ein echter Glücksgriff: Neben umfangreicher Funktionalität und hoher Leistung erwiesen sich die Entwickler als ziemlich hilfsbereit und beantworteten schnell und ausführlich alle Fragen. Mit ihrer Hilfe konnten wir OpenCV-Matrix-Transformationen in xtensor-Tensoren umsetzen sowie eine Methode zur Zusammenführung von 3D-Bildern in einen 4D-Tensor der richtigen Dimension (den Batch) realisieren.
Materialien zum Lernen von Rcpp, xtensor und RcppThread
Für die Kompilierung von Dateien, die Systemdateien verwenden und dynamisch mit im System installierten Bibliotheken verlinkt sind, haben wir den Plugin-Mechanismus verwendet, der im Paket implementiert ist. Rcpp. Zur automatischen Ermittlung der Pfade und Flags haben wir das beliebte Linux-Utility pkg-config.
Implementierung des Rcpp-Plugins zur Nutzung der OpenCV-Bibliothek
Rcpp::registerPlugin("opencv", function() {
# Mögliche Paketnamen
pkg_config_name <- c("opencv", "opencv4")
# Binärdatei des pkg-config-Utilities
pkg_config_bin <- Sys.which("pkg-config")
# Überprüfung der Verfügbarkeit des Utilities im System
checkmate::assert_file_exists(pkg_config_bin, access = "x")
# Überprüfung auf die Existenz der OpenCV-Konfigurationsdatei für pkg-config
check <- sapply(pkg_config_name,
function(pkg) system(paste(pkg_config_bin, pkg)))
if (all(check != 0)) {
stop("OpenCV-Konfiguration für pkg-config nicht gefunden", 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)
))
})Im Ergebnis der Plugin-Nutzung werden während des Kompilierungsprozesses folgende Werte eingefügt:
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"Der Code zur Implementierung des JSON-Parsings und zur Erstellung eines Batches für die Übertragung an das Modell ist im Spoiler enthalten. Zuerst fügen wir das lokale Projektverzeichnis hinzu, um Header-Dateien zu finden (notwendig für ndjson):
Sys.setenv("PKG_CXXFLAGS" = paste0("-I", normalizePath(file.path("src"))))Implementierung der Umwandlung von JSON in einen Tensor 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;
}Dieser Code sollte in die Datei src/cv_xt.cpp eingefügt werden und mit dem Befehl Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); außerdem wird benötigt nlohmann/json.hpp von . Der Code ist in mehrere Funktionen unterteilt:
to_xt— eine templatebasierte Funktion zur Umwandlung einer Bildmatrix (cv::Mat) in einen Tensorxt::xtensor;parse_json— Die Funktion parst eine JSON-Zeichenfolge, extrahiert die Koordinaten der Punkte und verpackt sie in einen Vektor;ocv_draw_lines— Zeichnet aus dem erhaltenen Punktvektor mehrfarbige Linien;prozess— Kombiniert die oben beschriebenen Funktionen und fügt die Möglichkeit hinzu, das resultierende Bild zu skalieren;cpp_process_json_str— Eine Wrapper-Funktion,prozessdie das Ergebnis in ein R-Objekt (mehrdimensionales Array) exportiert;cpp_process_json_vector— Eine Wrapper-Funktion,cpp_process_json_strdie die Verarbeitung eines Zeichenvektors im Multithreading-Modus ermöglicht.
Für das Zeichnen der mehrfarbigen Linien wurde das HSV-Farbmodell verwendet, gefolgt von einer Umwandlung in RGB. Testen wir das Ergebnis:
arr <- cpp_process_json_str(tmp_data[4, drawing])
dim(arr)
# [1] 256 256 3
plot(magick::image_read(arr)) 
Vergleich der Ausführungsgeschwindigkeit der Implementierungen in R und 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
)
# Benchmark-Parameter
cols <- c("expression", "min", "median", "max", "itr/sec", "total_time", "n_itr")
res_bench[, cols]
# expression min median max `itr/sec` total_time n_itr
#
# 1 r_process_json_str 3.49ms 3.55ms 4.47ms 273. 490ms 134
# 2 cpp_process_json_str 1.94ms 2.02ms 5.32ms 489. 497ms 243
library(ggplot2)
# Durchführung der Messung
res_bench <- bench::press(
batch_size = 2^(4:10),
{
.data <- tmp_data[sample(seq_len(.N), batch_size), drawing]
bench::mark(
r_process_json_vector(.data, scale = 0.5),
cpp_process_json_vector(.data, scale = 0.5),
min_iterations = 50,
check = FALSE
)
}
)
res_bench[, cols]
# expression batch_size min median max `itr/sec` total_time n_itr
# <bch:tm> <bch:tm> <bch:tm> <int>
# 1 r 16 50.61ms 53.34ms 54.82ms 19.1 471.13ms 9
# 2 cpp 16 4.46ms 5.39ms 7.78ms 192. 474.09ms 91
# 3 r 32 105.7ms 109.74ms 212.26ms 7.69 6.5s 50
# 4 cpp 32 7.76ms 10.97ms 15.23ms 95.6 522.78ms 50
# 5 r 64 211.41ms 226.18ms 332.65ms 3.85 12.99s 50
# 6 cpp 64 25.09ms 27.34ms 32.04ms 36.0 1.39s 50
# 7 r 128 534.5ms 627.92ms 659.08ms 1.61 31.03s 50
# 8 cpp 128 56.37ms 58.46ms 66.03ms 16.9 2.95s 50
# 9 r 256 1.15s 1.18s 1.29s 0.851 58.78s 50
# 10 cpp 256 114.97ms 117.39ms 130.09ms 8.45 5.92s 50
# 11 r 512 2.09s 2.15s 2.32s 0.463 1.8m 50
# 12 cpp 512 230.81ms 235.6ms 261.99ms 4.18 11.97s 50
# 13 r 1024 4s 4.22s 4.4s 0.238 3.5m 50
# 14 cpp 1024 410.48ms 431.43ms 462.44ms 2.33 21.45s 50
ggplot(res_bench, aes(x = factor(batch_size), y = median,
group = expression, color = expression)) +
geom_point() +
geom_line() +
ylab("mittlere Zeit, s") +
theme_minimal() +
scale_color_discrete(name = "", labels = c("cpp", "r")) +
theme(legend.position = "bottom") 
Wie wir sehen, war der Geschwindigkeitszuwachs erheblich, und es scheint unmöglich, den C++-Code durch Parallelisierung des R-Codes einzuholen.
3. Iteratoren zum Laden von Batches aus der Datenbank
R hat sich den Ruf eines Datensprache erarbeitet, die für die Verarbeitung von Daten im RAM geeignet ist, während Python eher für die iterative Datenverarbeitung bekannt ist, die leicht und unkompliziert Out-of-Core-Berechnungen ermöglicht. Ein klassisches und aktuelles Beispiel für solche Berechnungen in unserem Kontext sind tiefe neuronale Netzwerke, die durch Gradientenabstieg mit Gradientenapproximation in jedem Schritt anhand einer kleinen Menge von Beobachtungen, also einem Mini-Batch, trainiert werden.
Deep-Learning-Frameworks, die in Python geschrieben sind, verfügen über spezielle Klassen, die Iteratoren für Daten implementieren: Tabellen, Bilder in Ordnern, binäre Formate usw. Man kann fertige Lösungen verwenden oder eigene für spezifische Aufgaben schreiben. In R können wir alle Möglichkeiten der Python-Bibliothek nutzen. keras mit seinen verschiedenen Backends über das gleichnamige Paket, das seinerseits auf dem Paket basiert reticulate. Letzteres verdient einen eigenen großen Artikel; es ermöglicht nicht nur die Ausführung von Python-Code aus R, sondern sorgt auch für den Austausch von Objekten zwischen R- und Python-Sitzungen, indem es automatisch alle erforderlichen Typumwandlungen durchführt.
Die Notwendigkeit, alle Daten im RAM zu speichern, haben wir durch den Einsatz von MonetDBLite beseitigt. Die gesamte „neuronale Netzwerk“-Arbeit wird von dem originalen Python-Code durchgeführt; wir müssen lediglich einen Iterator für die Daten schreiben, da es für diese Situation weder in R noch in Python eine geeignete Lösung gibt. Es gibt nur zwei grundlegende Anforderungen: Er sollte Batch-Verarbeitung in einer Endlosschleife zurückgeben und seinen Zustand zwischen den Iterationen beibehalten (letzteres lässt sich in R ganz einfach mit Closures umsetzen). Früher musste im Iterator explizit R-Arrays in NumPy-Arrays umgewandelt werden, aber die aktuelle Version des Pakets keras macht das selbst.
Der Iterator für Trainings- und Validierungsdaten sieht folgendermaßen aus:
Iterator für Trainings- und Validierungsdaten
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)
}
}Die Funktion nimmt eine Variable mit einer Verbindung zur Datenbank, die Nummern der verwendeten Zeilen, die Anzahl der Klassen, die Batch-Größe sowie den Maßstab entgegen (scale = 1 entspricht der Darstellung von Bildern mit 256x256 Pixeln, scale = 0.5 — 128x128 Pixel), einen Farbindikator (color = FALSE stellt die Darstellung in Graustufen ein; bei Verwendung von color = TRUE wird jeder Strich in einer neuen Farbe gezeichnet) und einen Indikator für die Vorverarbeitung von Netzwerken, die auf imagenet trainiert wurden. Letzteres dient dazu, die Pixelwerte von einem Intervall [0, 1] auf das Intervall [-1, 1] zu skalieren, das beim Training der mitgelieferten keras Modelle verwendet wurde.
Die externe Funktion enthält eine Typüberprüfung der Argumente, eine Tabelle data.table mit zufällig gemischten Zeilennummern aus samples_index und Batch-Nummern, einen Zähler und die maximale Anzahl von Batches sowie einen SQL-Ausdruck zum Abrufen von Daten aus der Datenbank. Zusätzlich haben wir eine schnelle analog zur Funktion keras::to_categorical()definiert. Für das Training haben wir fast alle Daten verwendet und nur 0,5 Prozent für die Validierung reserviert, weshalb die Größe der Epoche durch den Parameter steps_per_epoch beim Aufruf keras::fit_generator(), und die Bedingung if (i > max_i) wurde nur für den Validierungsiterator ausgelöst.
In der internen Funktion erfolgt die Auswahl der Zeilenindizes für den nächsten Batch, das Laden von Datensätzen aus der Datenbank mit steigendem Batch-Zähler, das Parsen von JSON-Daten (Funktion cpp_process_json_vector(), geschrieben in C++) und die Erstellung von Arrays, die den Bildern entsprechen. Anschließend werden One-Hot-Vektoren mit den Klassenlabels erstellt, Arrays mit Pixelwerten und Labels werden zu einer Liste kombiniert, die den Rückgabewert darstellt. Zur Beschleunigung wurde die Erstellung von Indizes in den Tabellen genutzt data.table und die Modifikation durch Referenz – ohne diese "Tricks" des Pakets data.table lässt sich eine effiziente Arbeit mit einigermaßen signifikanten Datenmengen in R kaum vorstellen.
Die Ergebnisse der Geschwindigkeitsmessung auf einem Laptop mit Core i5 sehen wie folgt aus:
Benchmark des Iterators
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]]
# Indizes für den Trainingsdatensatz
train_ind <- sample(ind, floor(length(ind) * 0.995))
# Indizes für den Validierungsdatensatz
val_ind <- ind[-train_ind]
rm(ind)
# Skalierungsfaktor
scale <- 0.5
# Durchführung der Messung
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
)
}
)
# Benchmark-Parameter
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("Medianzeit, s") +
theme_minimal()
DBI::dbDisconnect(con, shutdown = TRUE) 
Wenn ausreichend RAM vorhanden ist, kann die Leistung der Datenbank erheblich gesteigert werden, indem sie in diesen RAM verschoben wird (32 GB sind für unser Vorhaben ausreichend). Unter Linux wird standardmäßig eine Partition gemountet, /dev/shm, die bis zur Hälfte des RAMs einnimmt. Man kann sogar mehr zuweisen, indem man /etc/fstabanpasst, sodass eine Zeile wie folgt aussieht: tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Stellen Sie sicher, dass Sie neu starten und das Ergebnis mit dem Befehl überprüfen, df -h.
Der Iterator für die Testdaten sieht viel einfacher aus, da der gesamte Testdatensatz im RAM untergebracht ist:
Iterator für Testdaten
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. Auswahl der Modellarchitektur
Die erste der verwendeten Architekturen war , deren Besonderheiten in der Mitteilung erläutert werden. Sie ist in der Standardinstallation enthalten keras und somit im gleichnamigen Paket für R verfügbar. Bei dem Versuch, sie mit einkanaligen Bildern zu verwenden, stellte sich jedoch heraus, dass der Eingabetensor immer die Dimension haben muss (batch, height, width, 3)., das heißt, die Anzahl der Kanäle kann nicht geändert werden. In Python gibt es keine solche Einschränkung, deshalb haben wir uns beeilt und unsere eigene Implementierung dieser Architektur erstellt, basierend auf dem Originalartikel (ohne Dropout, das es in der Keras-Version gibt):
Architektur von 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)
}Die Nachteile eines solchen Ansatzes sind offensichtlich. Man möchte viele Modelle testen, aber es ist mühsam, jede Architektur manuell neu zu schreiben. Auch waren wir nicht in der Lage, Gewichte von Modellen zu verwenden, die zuvor auf ImageNet trainiert wurden. Wie gewohnt hat das Studium der Dokumentation geholfen. Die Funktion get_config() ermöglicht es, die Modellbeschreibung in einem bearbeitbaren Format zu erhalten (base_model_conf$layers — eine normale R-Liste), während die Funktion from_config() die Umwandlung zurück in ein Modellobjekt durchführt:
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)Jetzt ist es einfach, eine universelle Funktion zu schreiben, um jedes der mitgelieferten Modelle mit ImageNet-Gewichten oder ohne sie zu erhalten: keras Funktion zum Laden vorgefertigter Architekturen
Funktion zum Hochladen von fertigen Architekturen
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) {
# Überprüfung der Argumente
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)
# Objekt aus dem keras-Paket abrufen
model_fun <- get0(paste0("application_", name), envir = asNamespace("keras"))
# Überprüfen, ob das Objekt im Paket vorhanden ist
if (is.null(model_fun)) {
stop("Modell ", shQuote(name), " nicht gefunden.", call. = FALSE)
}
base_model <- model_fun(
input_shape = input_shape,
include_top = FALSE,
weights = weights,
pooling = pooling
)
# Wenn das Bild nicht farbig ist, ändern wir die Eingabedimensionen
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)
}Bei der Verwendung von ein-Kanal-Bildern werden die vortrainierten Gewichte nicht verwendet. Dies könnte behoben werden, indem man die Funktion get_weights() verwendet, um die Modellgewichte in Form einer Liste aus R-Arrays zu erhalten, die Dimension des ersten Elements dieser Liste anpasst (indem man einen Farbkanal auswählt oder alle drei mittelt) und dann die Gewichte mit der Funktion set_weights()wieder in das Modell lädt. Diese Funktionalität haben wir nicht hinzugefügt, da zu diesem Zeitpunkt bereits klar war, dass die Arbeit mit Farbbildern effektiver ist.
Die meisten unserer Experimente haben wir mit der Mobilenet Version 1 und 2 sowie ResNet34 durchgeführt. In diesem Wettbewerb haben modernere Architekturen wie SE-ResNeXt gut abgeschnitten. Leider lagen uns keine fertigen Implementierungen vor, und unsere eigenen haben wir noch nicht geschrieben (aber wir werden es auf jeden Fall tun).
5. Parametrisierung der Skripte
Zur Vereinfachung wurde der gesamte Code für den Trainingsstart in Form eines einzigen parametrisierten Skripts erstellt, das mithilfe von $ ../..../waf ...
doc <- '
Verwendung:
train_nn.R --help
train_nn.R --list-models
train_nn.R [Optionen]
Optionen:
-h --help Zeigt diese Nachricht an.
-l --list-models Listet verfügbare Modelle auf.
-m --model= Name des neuronalen Netzwerkmodells [Standard: mobilenet_v2].
-b --batch-size= Batch-Größe [Standard: 32].
-s --scale-factor= Skalierungsfaktor [Standard: 0.5].
-c --color Farbige Linien verwenden [Standard: FALSE].
-d --db-dir= Pfad zum Datenbankverzeichnis [Standard: Sys.getenv("db_dir")].
-r --validate-ratio= Validierungsprobenverhältnis [Standard: 0.995].
-n --n-gpu= Anzahl der GPUs [Standard: 1].
'
args <- docopt::docopt(doc)Das Paket docopt stellt eine Implementierung dar für R. Damit können Skripte mit einfachen Befehlen ausgeführt werden, wie zum Beispiel Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db oder ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, wenn die Datei train_nn.R ausführbar ist (dieser Befehl startet das Training des Modells resnet50 mit dreifarbigen Bildern in der Größe 128x128 Pixel. Die Datenbank sollte im Ordner /home/andrey/doodle_db). Es können Lernrate, Art des Optimierers und andere anpassbare Parameter hinzugefügt werden. Während der Vorbereitung der Veröffentlichung wurde festgestellt, dass die Architektur mobilenet_v2 aus der aktuellen Version keras in R verwendet werden kann aufgrund nicht berücksichtigter Änderungen im R-Paket — wir warten darauf, dass dies behoben wird.
Dieser Ansatz hat es uns ermöglicht, Experimente mit verschiedenen Modellen erheblich zu beschleunigen, im Vergleich zu einem eher traditionellen Ansatz mit der Ausführung von Skripten in RStudio (als mögliche Alternative erwähnen wir das Paket ). Der größte Vorteil liegt jedoch in der Möglichkeit, die Ausführung von Skripten in Docker oder einfach auf einem Server problemlos zu steuern, ohne RStudio installieren zu müssen.
6. Containerisierung von Skripten
Wir haben Docker verwendet, um eine portable Umgebung für das Training von Modellen zwischen den Teammitgliedern zu gewährleisten und um schnelle Bereitstellungen in der Cloud zu ermöglichen. Der Einstieg in dieses für R-Programmierer relativ unbekannte Werkzeug kann durch eine Serie von Veröffentlichungen oder durch einen .
erfolgen. Docker ermöglicht sowohl das Erstellen eigener Images "von Grund auf" als auch die Verwendung anderer Images als Grundlage für eigene Kreationen. Bei der Analyse der vorhandenen Optionen sind wir zu dem Schluss gekommen, dass die Installation von NVIDIA-Treibern, CUDA+cuDNN und Python-Bibliotheken einen recht umfangreichen Teil des Images ausmacht, und haben uns entschieden, das offizielle Image tensorflow/tensorflow:1.12.0-gpu, mit den erforderlichen R-Paketen zu erweitern.
Die resultierende Docker-Datei sieht folgendermaßen aus:
Dockerfile
VON tensorflow/tensorflow:1.12.0-gpu
VERANTWORTLICHER Artem Klevtsov <a.a.klevtsov@gmail.com>
SHELL ["/bin/bash", "-c"]
ARG LOCALE="de_DE.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
Zur Vereinfachung wurden die verwendeten Pakete in Variablen ausgelagert; der Hauptteil der geschriebenen Skripte wird während des Builds in die Container kopiert. Außerdem haben wir die Kommandozeile geändert auf /bin/bash um die Nutzung des Inhalts zu erleichtern /etc/os-release. Dadurch entfällt die Notwendigkeit, die OS-Version im Code anzugeben.
Zusätzlich wurde ein kleines Bash-Skript geschrieben, das das Starten von Containern mit verschiedenen Befehlen ermöglicht. Zum Beispiel können dies Skripte zum Trainieren von neuronalen Netzen sein, die zuvor im Container abgelegt wurden, oder eine Kommandozeile zur Fehlersuche und Überwachung des Containerbetriebs:
Skript zum Starten des Containers
#!/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}Wenn dieses Bash-Skript ohne Parameter gestartet wird, wird innerhalb des Containers das Skript train_nn.R mit Standardwerten aufgerufen; wenn das erste Positionsargument „bash“ ist, wird der Container im interaktiven Modus mit einer Kommandozeile gestartet. In allen anderen Fällen findet eine Ersetzung der Werte der Positionsargumente statt: CMD="Rscript /app/train_nn.R $@".
Es ist wichtig zu beachten, dass die Verzeichnisse mit den Quelldaten und der Datenbank sowie das Verzeichnis zur Speicherung der trainierten Modelle aus dem Host-System in den Container eingebunden werden. Dies ermöglicht den Zugriff auf die Ergebnisse der Skripte ohne zusätzliche Manipulationen.
7. Nutzung mehrerer GPUs in der Google Cloud
Eine der Besonderheiten des Wettbewerbs waren die sehr lauten Daten (siehe das Titelbild, das von @Leigh.plt aus dem ODS-Slack entnommen wurde). Um damit umzugehen, helfen große Batch-Größen, und nachdem wir auf einem PC mit 1 GPU experimentiert hatten, beschlossen wir, das Training von Modellen auf mehreren GPUs in der Cloud zu erlernen. Wir nutzen Google Cloud () aufgrund der großen Auswahl an verfügbaren Konfigurationen, akzeptablen Preisen und den Bonus von 300 $. Aus Gier wurde eine Instanz mit 4xV100, SSD und viel RAM bestellt, und das war ein großer Fehler. Solch eine Maschine verbraucht schnell Geld, bei Experimenten ohne ausgereiteten Pipeline kann man bankrottgehen. Für Lernzwecke besser K80 wählen. Der große RAM war jedoch nützlich, da die Cloud-SSD nicht mit ihrer Geschwindigkeit beeindruckte, sodass wir die Datenbank bei jedem Start der Instanz auf dev/shm.
Besonders interessant ist der Codeabschnitt, der für die Nutzung mehrerer GPUs verantwortlich ist. Zunächst wird das Modell auf der CPU erstellt, wobei ein Kontext-Manager verwendet wird, genau wie 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
)
})Anschließend wird das nicht kompilierten (das ist wichtig) Modell auf die angegebene Anzahl verfügbarer GPUs kopiert und erst danach kompiliert:
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)
)Der klassische Ansatz, alle Schichten bis auf die letzte zu frieren, die letzte Schicht zu trainieren, dann die Schicht zu entfrieren und das Modell vollständig für mehrere GPUs nachzubilden, konnte nicht umgesetzt werden.
Das Training wurde ohne die Verwendung von tensorboard, indem wir uns auf die Aufzeichnung von Protokollen und die Speicherung von Modellen mit informativen Namen nach jeder Epoche beschränkten.
Callbacks
# Шаблон имени файла лога
log_file_tmpl <- file.path("logs", sprintf(
"%s_%d_%dch_%s.csv",
model_name,
dim_size,
channels,
format(Sys.time(), "%Y%m%d%H%M%OS")
))
# Шаблон имени файла модели
model_file_tmpl <- file.path("models", sprintf(
"%s_%d_%dch_{epoch:02d}_{val_loss:.2f}.h5",
model_name,
dim_size,
channels
))
callbacks_list <- list(
keras::callback_csv_logger(
filename = log_file_tmpl
),
keras::callback_early_stopping(
monitor = "val_loss",
min_delta = 1e-4,
patience = 8,
verbose = 1,
mode = "min"
),
keras::callback_reduce_lr_on_plateau(
monitor = "val_loss",
factor = 0.5, # уменьшаем lr в 2 раза
patience = 4,
verbose = 1,
min_delta = 1e-4,
mode = "min"
),
keras::callback_model_checkpoint(
filepath = model_file_tmpl,
monitor = "val_loss",
save_best_only = FALSE,
save_weights_only = FALSE,
mode = "min"
)
)8. Statt eines Schlusses
Eine Reihe von Problemen, mit denen wir konfrontiert waren, konnten wir bisher nicht lösen:
- in keras es gibt keine fertige Funktion zur automatischen Optimierung der Lernrate (analog zu
lr_finderin der Bibliothek fast.ai); mit etwas Aufwand können Drittimplementierungen auf R portiert werden, beispielsweise, ; - Aufgrund des vorhergehenden Punktes gelang es nicht, die richtige Lernrate bei der Verwendung mehrerer GPUs zu finden;
- es fehlen moderne Architekturen von neuronalen Netzen, insbesondere die, die auf imagenet vortrainiert sind;
- es gibt keine One Cycle Policy und diskriminativen Lernraten (cosine annealing wurde auf unsere Anfrage hin , danke ).
Was nützlich aus diesem Wettbewerb mitgenommen werden konnte:
- Auf relativ schwacher Hardware kann man schmerzfrei mit erheblichen (weit über dem RAM-Volumen liegenden) Datenmengen arbeiten. Das Paket data.table spart Speicher durch in-place Modifikationen der Tabellen, was eine Kopie dieser vermeidet, und zeigt bei richtiger Nutzung seiner Möglichkeiten fast immer die höchste Geschwindigkeit unter den uns bekannten Werkzeugen für Skriptsprachen. Die Speicherung der Daten in einer DB ermöglicht es in vielen Fällen, sich gar nicht um die Notwendigkeit zu kümmern, den gesamten Datensatz in den RAM zu quetschen.
- Langsame Funktionen in R können durch schnelle in C++ mithilfe des Pakets ersetzt werden Rcpp. Wenn außerdem RcppThread oder RcppParallel, wir erhalten plattformübergreifende, multithreaded Implementierungen, daher ist das Parallelisieren des Codes auf R-Ebene nicht erforderlich.
- Paket Rcpp kann ohne tiefgehende Kenntnisse in C++ genutzt werden, das notwendige Minimum ist dargestellt. . Die Header-Dateien für eine Reihe beeindruckender C-Bibliotheken wie xtensor sind auf CRAN verfügbar, sodass eine Infrastruktur zur Umsetzung von Projekten geschaffen wird, die vorbereiteten Hochleistungscode in C++ in R integrieren. Ein zusätzlicher Vorteil ist die Syntax-Hervorhebung und der statische C++-Code-Analyzer in RStudio.
- docopt ermöglicht die Ausführung selbstständiger Skripte mit Parametern. Dies ist praktisch für die Nutzung auf einem entfernten Server, auch unter Docker. In RStudio lange Experimente mit dem Training von neuronalen Netzen durchzuführen, ist unpraktisch, und die Installation der IDE auf dem Server ist nicht immer sinnvoll.
- Docker gewährleistet die Portabilität des Codes und die Reproduzierbarkeit von Ergebnissen zwischen Entwicklern mit unterschiedlichen Betriebssystemversionen und Bibliotheken sowie eine einfache Ausführung auf Servern. Den gesamten Pipeline für das Training kann man mit nur einem Befehl starten.
- Google Cloud ist eine kostengünstige Möglichkeit, um auf teurer Hardware zu experimentieren, wobei die Konfigurationen sorgfältig gewählt werden müssen.
- Die Messung der Leistung einzelner Codefragmente ist sehr nützlich, insbesondere in Kombination mit R und C++, und mit dem Paket bench — ist es auch sehr einfach.
Insgesamt war diese Erfahrung sehr wertvoll, und wir arbeiten weiterhin an der Lösung einiger der genannten Probleme.
Quelle: habr.com
