Quick Draw Doodle Recognition: Wie man sich mit R, C++ und neuronalen Netzen anfreundet

Quick Draw Doodle Recognition: Wie man sich mit R, C++ und neuronalen Netzen anfreundet

Hey Habr!

Im vergangenen Herbst veranstaltete Kaggle einen Wettbewerb zur Klassifizierung handgezeichneter Bilder, Quick Draw Doodle Recognition, an dem unter anderem ein Team von R-Wissenschaftlern teilnahm: Artem Klevtsova, Philippa-Managerin и Andrey Ogurtsov. Wir werden den Wettbewerb nicht im Detail beschreiben, das ist bereits geschehen neuere Veröffentlichung.

Dieses Mal hat es mit dem Medal Farming nicht geklappt, aber es wurden viele wertvolle Erfahrungen gesammelt, sodass ich der Community gerne einige der interessantesten und nützlichsten Dinge auf Kagle und im Arbeitsalltag erzählen möchte. Zu den besprochenen Themen gehört: schwieriges Leben ohne OpenCV, JSON-Parsing (diese Beispiele untersuchen die Integration von C++-Code in Skripte oder Pakete in R mithilfe von Rcpp), Parametrisierung von Skripten und Dockerisierung der endgültigen Lösung. Der gesamte Code aus der Nachricht ist in einer zur Ausführung geeigneten Form verfügbar in Lagerstätten.

Inhalt:

  1. Laden Sie Daten effizient aus CSV in MonetDB
  2. Chargen vorbereiten
  3. Iteratoren zum Entladen von Stapeln aus der Datenbank
  4. Auswahl einer Modellarchitektur
  5. Skriptparametrisierung
  6. Dockerisierung von Skripten
  7. Verwendung mehrerer GPUs in Google Cloud
  8. Statt einer Schlussfolgerung

1. Laden Sie Daten effizient aus CSV in die MonetDB-Datenbank

Die Daten in diesem Wettbewerb werden nicht in Form von vorgefertigten Bildern bereitgestellt, sondern in Form von 340 CSV-Dateien (eine Datei für jede Klasse), die JSONs mit Punktkoordinaten enthalten. Indem wir diese Punkte mit Linien verbinden, erhalten wir ein endgültiges Bild mit den Maßen 256 x 256 Pixel. Außerdem gibt es für jeden Datensatz eine Beschriftung, die angibt, ob das Bild von dem zum Zeitpunkt der Erfassung des Datensatzes verwendeten Klassifikator korrekt erkannt wurde, einen aus zwei Buchstaben bestehenden Code des Wohnsitzlandes des Autors des Bildes, eine eindeutige Kennung und einen Zeitstempel und einen Klassennamen, der dem Dateinamen entspricht. Eine vereinfachte Version der Originaldaten wiegt im Archiv 7.4 GB und nach dem Entpacken ca. 20 GB, die vollständigen Daten nach dem Entpacken belegen 240 GB. Die Organisatoren stellten sicher, dass beide Versionen die gleichen Zeichnungen wiedergaben, sodass die Vollversion überflüssig war. Auf jeden Fall wurde die Speicherung von 50 Millionen Bildern in Grafikdateien oder in Form von Arrays sofort als unrentabel angesehen und wir beschlossen, alle CSV-Dateien aus dem Archiv zusammenzuführen train_simplified.zip in die Datenbank mit anschließender Generierung von Bildern in der erforderlichen Größe „on the fly“ für jeden Stapel.

Als DBMS wurde ein bewährtes System gewählt Monet DB, nämlich eine Implementierung für R als Paket MonetDBLite. Das Paket beinhaltet eine eingebettete Version des Datenbankservers und ermöglicht es Ihnen, den Server direkt aus einer R-Sitzung abzurufen und dort damit zu arbeiten. Das Erstellen einer Datenbank und das Herstellen einer Verbindung zu dieser werden mit einem Befehl durchgeführt:

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

Wir müssen zwei Tabellen erstellen: eine für alle Daten, die andere für Serviceinformationen zu heruntergeladenen Dateien (nützlich, wenn etwas schief geht und der Vorgang nach dem Herunterladen 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, bestand darin, CSV-Dateien direkt mit dem SQL-Befehl zu kopieren COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTWo tablename - Tabellenname und path - der Pfad zur Datei. Bei der Arbeit mit dem Archiv wurde festgestellt, dass die integrierte Implementierung unzip in R funktioniert mit einigen Dateien aus dem Archiv nicht richtig, daher haben wir das System verwendet unzip (mit dem Parameter 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))
}

Wenn Sie die Tabelle transformieren müssen, bevor Sie sie in die Datenbank schreiben, reicht es aus, das Argument zu übergeben preprocess Funktion, die die Daten transformiert.

Code zum sequentiellen Laden von 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->SSD

Die Datenladezeit kann je nach Geschwindigkeitseigenschaften des verwendeten Laufwerks variieren. In unserem Fall dauert das Lesen und Schreiben innerhalb einer SSD oder von einem Flash-Laufwerk (Quelldatei) auf eine SSD (DB) weniger als 10 Minuten.

Es dauert noch ein paar Sekunden, eine Spalte mit einer ganzzahligen Klassenbezeichnung und einer Indexspalte zu erstellen (ORDERED INDEX) mit Zeilennummern, nach denen Beobachtungen beim Erstellen von Chargen abgetastet werden:

Erstellen zusätzlicher Spalten und Indexe

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

Um das Problem der spontanen Stapelerstellung zu lösen, mussten wir die maximale Geschwindigkeit beim Extrahieren zufälliger Zeilen aus der Tabelle erreichen doodles. Dafür haben wir 3 Tricks angewendet. Die erste bestand darin, die Dimensionalität des Typs zu reduzieren, der die Beobachtungs-ID speichert. Im Originaldatensatz ist der zum Speichern der ID erforderliche Typ bigint, aber die Anzahl der Beobachtungen ermöglicht es, ihre Bezeichner, die der Ordnungszahl entsprechen, in den Typ einzupassen int. Die Suche ist in diesem Fall viel schneller. Der zweite Trick war die Verwendung ORDERED INDEX — Zu dieser Entscheidung sind wir empirisch gekommen, nachdem wir alle verfügbaren Daten durchgesehen hatten Optionen. Die dritte bestand darin, parametrisierte Abfragen zu verwenden. Der Kern der Methode besteht darin, den Befehl einmal auszuführen PREPARE mit anschließender Verwendung eines vorbereiteten Ausdrucks beim Erstellen einer Reihe von Abfragen desselben Typs, aber tatsächlich gibt es einen Vorteil gegenüber einer einfachen SELECT Es stellte sich heraus, dass es im Bereich des statistischen Fehlers lag.

Das Hochladen der Daten verbraucht nicht mehr als 450 MB RAM. Das heißt, der beschriebene Ansatz ermöglicht es Ihnen, Datensätze mit einem Gewicht von mehreren zehn Gigabyte auf fast jeder Budget-Hardware zu verschieben, einschließlich einiger Single-Board-Geräte, was ziemlich cool ist.

Es bleibt nur noch, die Geschwindigkeit des Abrufs (zufälliger) Daten zu messen und die Skalierung bei der Stichprobenziehung unterschiedlich großer Chargen zu bewerten:

Datenbank-Benchmark

library(ggplot2)

set.seed(0)
# Подключение к базе данных
con <- DBI::dbConnect(MonetDBLite::MonetDBLite(), Sys.getenv("DBDIR"))

# Функция для подготовки запроса на стороне сервера
prep_sql <- function(batch_size) {
  sql <- sprintf("PREPARE SELECT id FROM doodles WHERE id IN (%s)",
                 paste(rep("?", batch_size), collapse = ","))
  res <- DBI::dbSendQuery(con, sql)
  return(res)
}

# Функция для извлечения данных
fetch_data <- function(rs, batch_size) {
  ids <- sample(seq_len(n), batch_size)
  res <- DBI::dbFetch(DBI::dbBind(rs, as.list(ids)))
  return(res)
}

# Проведение замера
res_bench <- bench::press(
  batch_size = 2^(4:10),
  {
    rs <- prep_sql(batch_size)
    bench::mark(
      fetch_data(rs, batch_size),
      min_iterations = 50L
    )
  }
)
# Параметры бенчмарка
cols <- c("batch_size", "min", "median", "max", "itr/sec", "total_time", "n_itr")
res_bench[, cols]

#   batch_size      min   median      max `itr/sec` total_time n_itr
#        <dbl> <bch:tm> <bch:tm> <bch:tm>     <dbl>   <bch:tm> <int>
# 1         16   23.6ms  54.02ms  93.43ms     18.8        2.6s    49
# 2         32     38ms  84.83ms 151.55ms     11.4       4.29s    49
# 3         64   63.3ms 175.54ms 248.94ms     5.85       8.54s    50
# 4        128   83.2ms 341.52ms 496.24ms     3.00      16.69s    50
# 5        256  232.8ms 653.21ms 847.44ms     1.58      31.66s    50
# 6        512  784.6ms    1.41s    1.98s     0.740       1.1m    49
# 7       1024  681.7ms    2.72s    4.06s     0.377      2.16m    49

ggplot(res_bench, aes(x = factor(batch_size), y = median, group = 1)) +
  geom_point() +
  geom_line() +
  ylab("median time, s") +
  theme_minimal()

DBI::dbDisconnect(con, shutdown = TRUE)

Quick Draw Doodle Recognition: Wie man sich mit R, C++ und neuronalen Netzen anfreundet

2. Chargen vorbereiten

Der gesamte Chargenvorbereitungsprozess besteht aus den folgenden Schritten:

  1. Parsen mehrerer JSONs, die Vektoren von Zeichenfolgen mit Punktkoordinaten enthalten.
  2. Zeichnen farbiger Linien basierend auf den Koordinaten von Punkten auf einem Bild der erforderlichen Größe (z. B. 256×256 oder 128×128).
  3. Konvertieren der resultierenden Bilder in einen Tensor.

Im Rahmen des Wettbewerbs der Python-Kernel wurde das Problem vor allem mit gelöst OpenCV. Eine der einfachsten und offensichtlichsten Analogien in R würde so aussehen:

Implementierung der JSON-zu-Tensor-Konvertierung 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)
}

Das Zeichnen wird mit Standard-R-Tools durchgeführt und in einem temporären PNG-Format im RAM gespeichert (unter Linux befinden sich temporäre R-Verzeichnisse im Verzeichnis). /tmp, im RAM gemountet). Diese Datei wird dann als dreidimensionales Array mit Zahlen im Bereich von 0 bis 1 gelesen. Dies ist wichtig, da ein konventionelleres BMP in ein Roharray mit Hex-Farbcodes eingelesen würde.

Testen wir das Ergebnis:

zip_file <- file.path("data", "train_simplified.zip")
csv_file <- "cat.csv"
unzip(zip_file, files = csv_file, exdir = tempdir(), 
      junkpaths = TRUE, unzip = getOption("unzip"))
tmp_data <- data.table::fread(file.path(tempdir(), csv_file), sep = ",", 
                              select = "drawing", nrows = 10000)
arr <- r_process_json_str(tmp_data[4, drawing])
dim(arr)
# [1] 256 256   3
plot(magick::image_read(arr))

Quick Draw Doodle Recognition: Wie man sich mit R, C++ und neuronalen Netzen anfreundet

Die Charge selbst wird wie folgt gebildet:

res <- r_process_json_vector(tmp_data[1:4, drawing], scale = 0.5)
str(res)
 # num [1:4, 1:128, 1:128, 1:3] 1 1 1 1 1 1 1 1 1 1 ...
 # - attr(*, "dimnames")=List of 4
 #  ..$ : NULL
 #  ..$ : NULL
 #  ..$ : NULL
 #  ..$ : NULL

Diese Implementierung erschien uns suboptimal, da die Bildung großer Chargen unangemessen lange dauert und wir beschlossen, die Erfahrungen unserer Kollegen durch den Einsatz einer leistungsstarken Bibliothek zu nutzen OpenCV. Zu diesem Zeitpunkt gab es kein fertiges Paket für R (heute gibt es keines mehr), daher wurde eine minimale Implementierung der erforderlichen Funktionalität in C++ geschrieben und in R-Code integriert Rcpp.

Um das Problem zu lösen, wurden folgende Pakete und Bibliotheken verwendet:

  1. OpenCV zum Arbeiten mit Bildern und Zeichnen von Linien. Verwendet vorinstallierte Systembibliotheken und Header-Dateien sowie dynamische Verknüpfungen.

  2. xtensor für die Arbeit mit mehrdimensionalen Arrays und Tensoren. Wir haben Header-Dateien verwendet, die im gleichnamigen R-Paket enthalten sind. Mit der Bibliothek können Sie mit mehrdimensionalen Arrays arbeiten, sowohl in Zeilen- als auch in Spaltenhauptreihenfolge.

  3. ndjson zum Parsen von JSON. Diese Bibliothek wird verwendet in xtensor automatisch, wenn es im Projekt vorhanden ist.

  4. RcppThread zum Organisieren der Multithread-Verarbeitung eines Vektors aus JSON. Verwendet die von diesem Paket bereitgestellten Header-Dateien. Von beliebter RcppParallel Das Paket verfügt unter anderem über einen integrierten Loop-Interrupt-Mechanismus.

Es sollte angemerkt werden, dass xtensor erwies sich als ein Geschenk des Himmels: Neben der Tatsache, dass es über umfangreiche Funktionalität und hohe Leistung verfügt, erwiesen sich seine Entwickler als sehr reaktionsschnell und beantworteten Fragen zeitnah und ausführlich. Mit ihrer Hilfe war es möglich, Transformationen von OpenCV-Matrizen in Xtensor-Tensoren zu implementieren sowie eine Möglichkeit, dreidimensionale Bildtensoren zu einem 3-dimensionalen Tensor der richtigen Dimension (dem Stapel selbst) zu kombinieren.

Materialien zum Erlernen von Rcpp, xtensor und RcppThread

https://thecoatlessprofessor.com/programming/unofficial-rcpp-api-documentation

https://docs.opencv.org/4.0.1/d7/dbd/group__imgproc.html

https://xtensor.readthedocs.io/en/latest/

https://xtensor.readthedocs.io/en/latest/file_loading.html#loading-json-data-into-xtensor

https://cran.r-project.org/web/packages/RcppThread/vignettes/RcppThread-vignette.pdf

Um Dateien zu kompilieren, die Systemdateien und dynamische Verknüpfungen mit auf dem System installierten Bibliotheken verwenden, haben wir den im Paket implementierten Plugin-Mechanismus verwendet Rcpp. Um Pfade und Flags automatisch zu finden, haben wir ein beliebtes Linux-Dienstprogramm verwendet pkg-config.

Implementierung des Rcpp-Plugins zur Nutzung der OpenCV-Bibliothek

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

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

Aufgrund des Plugin-Vorgangs werden während des Kompilierungsprozesses die folgenden Werte ersetzt:

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 Implementierungscode zum Parsen von JSON und zum Generieren eines Stapels zur Übertragung an das Modell wird im Spoiler angegeben. Fügen Sie zunächst ein lokales Projektverzeichnis hinzu, um nach Header-Dateien zu suchen (erforderlich für ndjson):

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

Implementierung der JSON-zu-Tensor-Konvertierung 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 eingefügt werden src/cv_xt.cpp und mit dem Befehl kompilieren Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); auch für die Arbeit erforderlich nlohmann/json.hpp von Repository. Der Code ist in mehrere Funktionen unterteilt:

  • to_xt – eine Vorlagenfunktion zum Transformieren einer Bildmatrix (cv::Mat) zu einem Tensor xt::xtensor;

  • parse_json — Die Funktion analysiert einen JSON-String, extrahiert die Koordinaten von Punkten und packt sie in einen Vektor.

  • ocv_draw_lines — Zeichnet aus dem resultierenden Punktvektor mehrfarbige Linien;

  • process – kombiniert die oben genannten Funktionen und fügt außerdem die Möglichkeit hinzu, das resultierende Bild zu skalieren;

  • cpp_process_json_str - Wrapper über der Funktion process, das das Ergebnis in ein R-Objekt (mehrdimensionales Array) exportiert;

  • cpp_process_json_vector - Wrapper über der Funktion cpp_process_json_str, mit dem Sie einen Zeichenfolgenvektor im Multithread-Modus verarbeiten können.

Zum Zeichnen mehrfarbiger Linien wurde das HSV-Farbmodell verwendet und anschließend in RGB konvertiert. Testen wir das Ergebnis:

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

Quick Draw Doodle Recognition: Wie man sich mit R, C++ und neuronalen Netzen anfreundet
Vergleich der Geschwindigkeit von 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
)
# Параметры бенчмарка
cols <- c("expression", "min", "median", "max", "itr/sec", "total_time", "n_itr")
res_bench[, cols]

#   expression                min     median       max `itr/sec` total_time  n_itr
#   <chr>                <bch:tm>   <bch:tm>  <bch:tm>     <dbl>   <bch:tm>  <int>
# 1 r_process_json_str     3.49ms     3.55ms    4.47ms      273.      490ms    134
# 2 cpp_process_json_str   1.94ms     2.02ms    5.32ms      489.      497ms    243

library(ggplot2)
# Проведение замера
res_bench <- bench::press(
  batch_size = 2^(4:10),
  {
    .data <- tmp_data[sample(seq_len(.N), batch_size), drawing]
    bench::mark(
      r_process_json_vector(.data, scale = 0.5),
      cpp_process_json_vector(.data,  scale = 0.5),
      min_iterations = 50,
      check = FALSE
    )
  }
)

res_bench[, cols]

#    expression   batch_size      min   median      max `itr/sec` total_time n_itr
#    <chr>             <dbl> <bch:tm> <bch:tm> <bch:tm>     <dbl>   <bch:tm> <int>
#  1 r                   16   50.61ms  53.34ms  54.82ms    19.1     471.13ms     9
#  2 cpp                 16    4.46ms   5.39ms   7.78ms   192.      474.09ms    91
#  3 r                   32   105.7ms 109.74ms 212.26ms     7.69        6.5s    50
#  4 cpp                 32    7.76ms  10.97ms  15.23ms    95.6     522.78ms    50
#  5 r                   64  211.41ms 226.18ms 332.65ms     3.85      12.99s    50
#  6 cpp                 64   25.09ms  27.34ms  32.04ms    36.0        1.39s    50
#  7 r                  128   534.5ms 627.92ms 659.08ms     1.61      31.03s    50
#  8 cpp                128   56.37ms  58.46ms  66.03ms    16.9        2.95s    50
#  9 r                  256     1.15s    1.18s    1.29s     0.851     58.78s    50
# 10 cpp                256  114.97ms 117.39ms 130.09ms     8.45       5.92s    50
# 11 r                  512     2.09s    2.15s    2.32s     0.463       1.8m    50
# 12 cpp                512  230.81ms  235.6ms 261.99ms     4.18      11.97s    50
# 13 r                 1024        4s    4.22s     4.4s     0.238       3.5m    50
# 14 cpp               1024  410.48ms 431.43ms 462.44ms     2.33      21.45s    50

ggplot(res_bench, aes(x = factor(batch_size), y = median, 
                      group =  expression, color = expression)) +
  geom_point() +
  geom_line() +
  ylab("median time, s") +
  theme_minimal() +
  scale_color_discrete(name = "", labels = c("cpp", "r")) +
  theme(legend.position = "bottom") 

Quick Draw Doodle Recognition: Wie man sich mit R, C++ und neuronalen Netzen anfreundet

Wie Sie sehen, erwies sich die Geschwindigkeitssteigerung als sehr erheblich, und es ist nicht möglich, mit C++-Code mitzuhalten, indem man R-Code parallelisiert.

3. Iteratoren zum Entladen von Stapeln aus der Datenbank

R hat einen wohlverdienten Ruf für die Verarbeitung von Daten, die in den RAM passen, während Python sich eher durch iterative Datenverarbeitung auszeichnet, sodass Sie Out-of-Core-Berechnungen (Berechnungen mit externem Speicher) einfach und natürlich implementieren können. Ein klassisches und für uns relevantes Beispiel im Kontext des beschriebenen Problems sind tiefe neuronale Netze, die mit der Gradientenabstiegsmethode trainiert werden, wobei der Gradient bei jedem Schritt unter Verwendung eines kleinen Teils der Beobachtungen oder eines Mini-Batches approximiert wird.

In Python geschriebene Deep-Learning-Frameworks verfügen über spezielle Klassen, die Iteratoren basierend auf Daten implementieren: Tabellen, Bilder in Ordnern, Binärformate usw. Sie können vorgefertigte Optionen verwenden oder eigene Optionen für bestimmte Aufgaben schreiben. In R können wir alle Funktionen der Python-Bibliothek nutzen keras mit seinen verschiedenen Backends, die das gleichnamige Paket verwenden, das wiederum auf dem Paket aufsetzt vernetzen. Letzteres verdient einen gesonderten langen Artikel; Damit können Sie nicht nur Python-Code aus R ausführen, sondern auch Objekte zwischen R- und Python-Sitzungen übertragen und dabei automatisch alle erforderlichen Typkonvertierungen durchführen.

Wir haben die Notwendigkeit, alle Daten im RAM zu speichern, durch die Verwendung von MonetDBLite beseitigt, die gesamte Arbeit des „neuronalen Netzwerks“ wird vom Originalcode in Python ausgeführt, wir müssen nur einen Iterator über die Daten schreiben, da nichts fertig ist für eine solche Situation entweder in R oder Python. Dafür gibt es im Wesentlichen nur zwei Anforderungen: Es muss Batches in einer Endlosschleife zurückgeben und seinen Zustand zwischen den Iterationen speichern (letzteres wird in R am einfachsten über Abschlüsse implementiert). Bisher war es erforderlich, R-Arrays innerhalb des Iterators explizit in Numpy-Arrays zu konvertieren, jedoch in der aktuellen Version des Pakets keras macht es selbst.

Der Iterator für Trainings- und Validierungsdaten stellte sich wie folgt heraus:

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 verwendet als Eingabe eine Variable mit einer Verbindung zur Datenbank, der Anzahl der verwendeten Zeilen, der Anzahl der Klassen, der Stapelgröße und dem Maßstab (scale = 1 entspricht der Darstellung von Bildern mit 256 x 256 Pixeln, scale = 0.5 — 128x128 Pixel), Farbanzeige (color = FALSE Gibt bei Verwendung die Darstellung in Graustufen an color = TRUE Jeder Strich wird in einer neuen Farbe gezeichnet) und ein Vorverarbeitungsindikator für Netzwerke, die auf Imagenet vorab trainiert wurden. Letzteres wird benötigt, um Pixelwerte vom Intervall [0, 1] auf das Intervall [-1, 1] zu skalieren, das beim Training der bereitgestellten Daten verwendet wurde keras Modelle.

Die externe Funktion enthält eine Argumenttypprüfung und eine Tabelle data.table mit zufällig gemischten Zeilennummern aus samples_index und Chargennummern, Zähler und maximale Chargenanzahl sowie ein SQL-Ausdruck zum Entladen von Daten aus der Datenbank. Zusätzlich haben wir ein schnelles Analogon der darin enthaltenen Funktion definiert keras::to_categorical(). Wir haben fast alle Daten für das Training verwendet und ein halbes Prozent für die Validierung übrig gelassen, sodass die Epochengröße durch den Parameter begrenzt war steps_per_epoch wenn angerufen keras::fit_generator(), und der Zustand if (i > max_i) funktionierte nur für den Validierungsiterator.

In der internen Funktion werden Zeilenindizes für den nächsten Batch abgerufen, Datensätze mit steigendem Batch-Zähler aus der Datenbank entladen, JSON-Parsing (Funktion cpp_process_json_vector(), geschrieben in C++) und das Erstellen von Arrays, die Bildern entsprechen. Anschließend werden One-Hot-Vektoren mit Klassenbeschriftungen erstellt und Arrays mit Pixelwerten und Beschriftungen zu einer Liste zusammengefasst, die den Rückgabewert darstellt. Um die Arbeit zu beschleunigen, haben wir die Erstellung von Indizes in Tabellen genutzt data.table und Modifikation über den Link - ohne diese Paket-„Chips“ Datentabelle Es ist ziemlich schwer vorstellbar, effektiv mit größeren Datenmengen in R zu arbeiten.

Die Ergebnisse der Geschwindigkeitsmessungen auf einem Core-i5-Laptop sind wie folgt:

Iterator-Benchmark

library(Rcpp)
library(keras)
library(ggplot2)

source("utils/rcpp.R")
source("utils/keras_iterator.R")

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

ind <- seq_len(DBI::dbGetQuery(con, "SELECT count(*) FROM doodles")[[1L]])
num_classes <- DBI::dbGetQuery(con, "SELECT max(label_int) + 1 FROM doodles")[[1L]]

# Индексы для обучающей выборки
train_ind <- sample(ind, floor(length(ind) * 0.995))
# Индексы для проверочной выборки
val_ind <- ind[-train_ind]
rm(ind)
# Коэффициент масштаба
scale <- 0.5

# Проведение замера
res_bench <- bench::press(
  batch_size = 2^(4:10),
  {
    it1 <- train_generator(
      db_connection = con,
      samples_index = train_ind,
      num_classes = num_classes,
      batch_size = batch_size,
      scale = scale
    )
    bench::mark(
      it1(),
      min_iterations = 50L
    )
  }
)
# Параметры бенчмарка
cols <- c("batch_size", "min", "median", "max", "itr/sec", "total_time", "n_itr")
res_bench[, cols]

#   batch_size      min   median      max `itr/sec` total_time n_itr
#        <dbl> <bch:tm> <bch:tm> <bch:tm>     <dbl>   <bch:tm> <int>
# 1         16     25ms  64.36ms   92.2ms     15.9       3.09s    49
# 2         32   48.4ms 118.13ms 197.24ms     8.17       5.88s    48
# 3         64   69.3ms 117.93ms 181.14ms     8.57       5.83s    50
# 4        128  157.2ms 240.74ms 503.87ms     3.85      12.71s    49
# 5        256  359.3ms 613.52ms 988.73ms     1.54       30.5s    47
# 6        512  884.7ms    1.53s    2.07s     0.674      1.11m    45
# 7       1024     2.7s    3.83s    5.47s     0.261      2.81m    44

ggplot(res_bench, aes(x = factor(batch_size), y = median, group = 1)) +
    geom_point() +
    geom_line() +
    ylab("median time, s") +
    theme_minimal()

DBI::dbDisconnect(con, shutdown = TRUE)

Quick Draw Doodle Recognition: Wie man sich mit R, C++ und neuronalen Netzen anfreundet

Wenn Sie über ausreichend RAM verfügen, können Sie den Betrieb der Datenbank erheblich beschleunigen, indem Sie sie in denselben RAM übertragen (32 GB reichen für unsere Aufgabe). Unter Linux ist die Partition standardmäßig gemountet /dev/shmund belegt bis zur Hälfte der RAM-Kapazität. Durch Bearbeiten können Sie mehr hervorheben /etc/fstabum eine Platte wie zu bekommen tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Stellen Sie sicher, dass Sie einen Neustart durchführen und das Ergebnis überprüfen, indem Sie den Befehl ausführen df -h.

Der Iterator für Testdaten sieht viel einfacher aus, da der Testdatensatz vollständig in den RAM passt:

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 verwendete Architektur war mobilenet v1, deren Merkmale in besprochen werden Dies Nachricht. Es ist standardmäßig im Lieferumfang enthalten keras und ist dementsprechend im gleichnamigen Paket für R verfügbar. Beim Versuch, es mit einkanaligen Bildern zu verwenden, stellte sich jedoch eine seltsame Sache heraus: Der Eingabetensor muss immer die Dimension haben (batch, height, width, 3), das heißt, die Anzahl der Kanäle kann nicht geändert werden. Da es in Python keine solche Einschränkung gibt, haben wir uns beeilt, unsere eigene Implementierung dieser Architektur zu schreiben und dabei dem Originalartikel zu folgen (ohne den Aussetzer, der in der Keras-Version enthalten ist):

Mobilenet v1-Architektur

library(keras)

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

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

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

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

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

  inputs <- layer_input(shape = input_shape)

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

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

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

    return(model)
}

Die Nachteile dieses Ansatzes liegen auf der Hand. Ich möchte viele Modelle testen, aber im Gegenteil, ich möchte nicht jede Architektur manuell neu schreiben. Außerdem wurde uns die Möglichkeit genommen, die Gewichte von Modellen zu verwenden, die auf Imagenet vorab trainiert wurden. Wie immer half das Studium der Dokumentation. Funktion get_config() ermöglicht es Ihnen, eine Beschreibung des Modells in einer für die Bearbeitung geeigneten Form zu erhalten (base_model_conf$layers - eine reguläre R-Liste) und die Funktion from_config() führt die umgekehrte Konvertierung in ein Modellobjekt durch:

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)

Nun ist es nicht schwierig, eine universelle Funktion zu schreiben, um eine der bereitgestellten Funktionen zu erhalten keras Auf Imagenet trainierte Modelle mit oder ohne Gewichte:

Funktion zum Laden vorgefertigter 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) {
  # Проверка аргументов
  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)
}

Bei der Verwendung von Einkanalbildern werden keine vorab trainierten Gewichte verwendet. Dies könnte behoben werden: mithilfe der Funktion get_weights() Holen Sie sich die Modellgewichte in Form einer Liste von R-Arrays, ändern Sie die Dimension des ersten Elements dieser Liste (indem Sie einen Farbkanal nehmen oder alle drei mitteln) und laden Sie dann die Gewichte mit der Funktion zurück in das Modell set_weights(). Wir haben diese Funktionalität nie hinzugefügt, da zu diesem Zeitpunkt bereits klar war, dass es produktiver ist, mit Farbbildern zu arbeiten.

Die meisten Experimente haben wir mit den Mobilenet-Versionen 1 und 2 sowie resnet34 durchgeführt. Modernere Architekturen wie SE-ResNeXt schnitten in diesem Wettbewerb gut ab. Leider standen uns keine fertigen Implementierungen zur Verfügung und wir haben keine eigenen geschrieben (wir werden aber auf jeden Fall schreiben).

5. Parametrisierung von Skripten

Der Einfachheit halber wurde der gesamte Code zum Starten des Trainings als einzelnes Skript entworfen und mit parametrisiert Arzt следующим обрахом:

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)

Package Arzt stellt die Umsetzung dar http://docopt.org/ für R. Mit seiner Hilfe werden Skripte mit einfachen Befehlen wie gestartet 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 Datei train_nn.R ist ausführbar (dieser Befehl startet das Training des Modells). resnet50 Bei dreifarbigen Bildern mit einer Größe von 128 x 128 Pixeln muss sich die Datenbank im Ordner befinden /home/andrey/doodle_db). Sie können der Liste Lerngeschwindigkeit, Optimierertyp und andere anpassbare Parameter hinzufügen. Bei der Vorbereitung der Veröffentlichung stellte sich heraus, dass die Architektur mobilenet_v2 aus der aktuellen Version keras in R verwenden darf nicht Aufgrund von Änderungen, die im R-Paket nicht berücksichtigt wurden, warten wir darauf, dass sie das Problem beheben.

Dieser Ansatz ermöglichte es, Experimente mit verschiedenen Modellen im Vergleich zum traditionelleren Start von Skripten in RStudio erheblich zu beschleunigen (wir erwähnen das Paket als mögliche Alternative). tfruns). Der Hauptvorteil ist jedoch die Möglichkeit, den Start von Skripten in Docker oder einfach auf dem Server einfach zu verwalten, ohne dafür RStudio installieren zu müssen.

6. Dockerisierung von Skripten

Wir haben Docker verwendet, um die Portabilität der Umgebung für das Training von Modellen zwischen Teammitgliedern und für eine schnelle Bereitstellung in der Cloud sicherzustellen. Sie können beginnen, sich mit diesem für einen R-Programmierer relativ ungewöhnlichen Tool vertraut zu machen diese Schriftenreihe bzw Videokurs.

Mit Docker können Sie sowohl eigene Bilder von Grund auf erstellen als auch andere Bilder als Grundlage für die Erstellung eigener Bilder verwenden. Bei der Analyse der verfügbaren Optionen kamen wir zu dem Schluss, dass die Installation von NVIDIA-, CUDA+cuDNN-Treibern und Python-Bibliotheken einen ziemlich umfangreichen Teil des Images darstellt, und haben uns entschieden, das offizielle Image als Grundlage zu nehmen tensorflow/tensorflow:1.12.0-gpu, und fügt dort die notwendigen R-Pakete hinzu.

Die endgültige Docker-Datei sah so aus:

Dockerfile

FROM tensorflow/tensorflow:1.12.0-gpu

MAINTAINER Artem Klevtsov <[email protected]>

SHELL ["/bin/bash", "-c"]

ARG LOCALE="en_US.UTF-8"
ARG APT_PKG="libopencv-dev r-base r-base-dev littler"
ARG R_BIN_PKG="futile.logger checkmate data.table rcpp rapidjsonr dbi keras jsonlite curl digest remotes"
ARG R_SRC_PKG="xtensor RcppThread docopt MonetDBLite"
ARG PY_PIP_PKG="keras"
ARG DIRS="/db /app /app/data /app/models /app/logs"

RUN source /etc/os-release && 
    echo "deb https://cloud.r-project.org/bin/linux/ubuntu ${UBUNTU_CODENAME}-cran35/" > /etc/apt/sources.list.d/cran35.list && 
    apt-key adv --keyserver keyserver.ubuntu.com --recv-keys E084DAB9 && 
    add-apt-repository -y ppa:marutter/c2d4u3.5 && 
    add-apt-repository -y ppa:timsc/opencv-3.4 && 
    apt-get update && 
    apt-get install -y locales && 
    locale-gen ${LOCALE} && 
    apt-get install -y --no-install-recommends ${APT_PKG} && 
    ln -s /usr/lib/R/site-library/littler/examples/install.r /usr/local/bin/install.r && 
    ln -s /usr/lib/R/site-library/littler/examples/install2.r /usr/local/bin/install2.r && 
    ln -s /usr/lib/R/site-library/littler/examples/installGithub.r /usr/local/bin/installGithub.r && 
    echo 'options(Ncpus = parallel::detectCores())' >> /etc/R/Rprofile.site && 
    echo 'options(repos = c(CRAN = "https://cloud.r-project.org"))' >> /etc/R/Rprofile.site && 
    apt-get install -y $(printf "r-cran-%s " ${R_BIN_PKG}) && 
    install.r ${R_SRC_PKG} && 
    pip install ${PY_PIP_PKG} && 
    mkdir -p ${DIRS} && 
    chmod 777 ${DIRS} && 
    rm -rf /tmp/downloaded_packages/ /tmp/*.rds && 
    rm -rf /var/lib/apt/lists/*

COPY utils /app/utils
COPY src /app/src
COPY tests /app/tests
COPY bin/*.R /app/

ENV DBDIR="/db"
ENV CUDA_HOME="/usr/local/cuda"
ENV PATH="/app:${PATH}"

WORKDIR /app

VOLUME /db
VOLUME /app

CMD bash

Der Einfachheit halber wurden die verwendeten Pakete in Variablen abgelegt; Der Großteil der geschriebenen Skripte wird während der Montage in die Container kopiert. Wir haben auch die Befehlsshell geändert /bin/bash für eine einfache Nutzung der Inhalte /etc/os-release. Dadurch entfällt die Notwendigkeit, die Betriebssystemversion im Code anzugeben.

Zusätzlich wurde ein kleines Bash-Skript geschrieben, das es ermöglicht, einen Container mit verschiedenen Befehlen zu starten. Dies können beispielsweise Skripte zum Training neuronaler Netze sein, die zuvor im Container platziert wurden, oder eine Befehlsshell zum Debuggen und Überwachen des Betriebs des Containers:

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 ausgeführt wird, wird das Skript innerhalb des Containers aufgerufen train_nn.R mit Standardwerten; Wenn das erste Positionsargument „bash“ ist, wird der Container interaktiv mit einer Befehlsshell gestartet. In allen anderen Fällen werden die Werte von Positionsargumenten ersetzt: CMD="Rscript /app/train_nn.R $@".

Es ist erwähnenswert, dass die Verzeichnisse mit Quelldaten und Datenbank sowie das Verzeichnis zum Speichern trainierter Modelle vom Hostsystem aus im Container bereitgestellt werden, sodass Sie ohne unnötige Manipulationen auf die Ergebnisse der Skripte zugreifen können.

7. Verwendung mehrerer GPUs in Google Cloud

Eines der Merkmale des Wettbewerbs waren die sehr verrauschten Daten (siehe Titelbild, entlehnt von @Leigh.plt von ODS Slack). Große Chargen helfen, dem entgegenzuwirken, und nach Experimenten auf einem PC mit 1 GPU haben wir beschlossen, die Trainingsmodelle auf mehreren GPUs in der Cloud zu meistern. Benutzte GoogleCloud (Guter Leitfaden für die Grundlagen) aufgrund der großen Auswahl an verfügbaren Konfigurationen, angemessenen Preisen und einem Bonus von 300 $. Aus Gier habe ich eine 4xV100-Instanz mit einer SSD und einer Menge RAM bestellt, und das war ein großer Fehler. Eine solche Maschine frisst schnell Geld; ohne eine bewährte Pipeline kann man beim Experimentieren pleite gehen. Aus Bildungsgründen ist es besser, den K80 zu nehmen. Doch der große Arbeitsspeicher erwies sich als praktisch – die Cloud-SSD konnte mit ihrer Leistung nicht überzeugen, daher wurde die Datenbank dorthin übertragen dev/shm.

Von größtem Interesse ist das Codefragment, das für die Verwendung mehrerer GPUs verantwortlich ist. Zunächst wird das Modell mithilfe eines Kontextmanagers auf der CPU erstellt, 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
  )
})

Dann wird das unkompilierte (das ist wichtig) Modell auf eine bestimmte 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)
)

Die klassische Technik, alle Schichten bis auf die letzte einzufrieren, die letzte Schicht zu trainieren, das gesamte Modell für mehrere GPUs aufzutauen und neu zu trainieren, konnte nicht umgesetzt werden.

Das Training wurde ohne Einsatz überwacht. Tensorboard, wobei wir uns darauf beschränken, Protokolle aufzuzeichnen und Modelle mit aussagekräftigen Namen nach jeder Epoche zu speichern:

Rückrufe

# Шаблон имени файла лога
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. Anstelle einer Schlussfolgerung

Eine Reihe von Problemen, auf die wir gestoßen sind, konnten noch nicht gelöst werden:

  • в keras Es gibt keine vorgefertigte Funktion zur automatischen Suche nach der optimalen Lernrate (analog). lr_finder in der Bibliothek schnell.ai); Mit einigem Aufwand ist es beispielsweise möglich, Implementierungen von Drittanbietern auf R zu portieren. diese;
  • Aufgrund des vorherigen Punktes war es bei Verwendung mehrerer GPUs nicht möglich, die richtige Trainingsgeschwindigkeit auszuwählen;
  • Es mangelt an modernen neuronalen Netzwerkarchitekturen, insbesondere solchen, die auf Imagenet vorab trainiert wurden.
  • Keine Zykluspolitik und diskriminierende Lernraten (Cosinus-Annealing war auf unsere Anfrage zurückzuführen). implementiert, Danke skeydan).

Welche nützlichen Dinge wurden aus diesem Wettbewerb gelernt:

  • Auf Hardware mit relativ geringem Stromverbrauch können Sie problemlos mit anständigen Datenmengen (ein Vielfaches der Größe des Arbeitsspeichers) arbeiten. Plastiktüte Datentabelle Spart Speicher durch direkte Modifikation von Tabellen, wodurch deren Kopieren vermieden wird, und weist bei korrekter Verwendung fast immer die höchste Geschwindigkeit unter allen uns bekannten Tools für Skriptsprachen auf. Durch das Speichern von Daten in einer Datenbank müssen Sie in vielen Fällen überhaupt nicht darüber nachdenken, den gesamten Datensatz in den RAM zu quetschen.
  • Langsame Funktionen in R können mit dem Paket durch schnelle in C++ ersetzt werden Rcpp. Wenn zusätzlich zu verwenden RcppThread oder RcppParallelerhalten wir plattformübergreifende Multithread-Implementierungen, sodass keine Parallelisierung des Codes auf R-Ebene erforderlich ist.
  • Per Paket Rcpp kann ohne ernsthafte C++-Kenntnisse verwendet werden, das erforderliche Minimum ist aufgeführt hier. Header-Dateien für eine Reihe cooler C-Bibliotheken wie xtensor auf CRAN verfügbar, d. h. es wird eine Infrastruktur für die Umsetzung von Projekten gebildet, die vorgefertigten Hochleistungs-C++-Code in R integrieren. Zusätzlicher Komfort ist die Syntaxhervorhebung und ein statischer C++-Codeanalysator in RStudio.
  • Arzt ermöglicht Ihnen die Ausführung eigenständiger Skripte mit Parametern. Dies ist praktisch für die Verwendung auf einem Remote-Server, inkl. unter Docker. In RStudio ist es umständlich, stundenlange Experimente mit dem Training neuronaler Netze durchzuführen, und die Installation der IDE auf dem Server selbst ist nicht immer gerechtfertigt.
  • Docker gewährleistet die Portabilität des Codes und die Reproduzierbarkeit der Ergebnisse zwischen Entwicklern mit unterschiedlichen Versionen des Betriebssystems und der Bibliotheken sowie eine einfache Ausführung auf Servern. Sie können die gesamte Trainingspipeline mit nur einem Befehl starten.
  • Google Cloud ist eine budgetfreundliche Möglichkeit, mit teurer Hardware zu experimentieren, Sie müssen die Konfigurationen jedoch sorgfältig auswählen.
  • Das Messen der Geschwindigkeit einzelner Codefragmente ist besonders bei der Kombination von R und C++ sowie mit dem Paket sehr nützlich Bank - auch ganz einfach.

Insgesamt war diese Erfahrung sehr bereichernd und wir arbeiten weiterhin daran, einige der angesprochenen Probleme zu lösen.

Source: habr.com

Kommentar hinzufügen