Quick Draw Doodle Recognition : comment se lier d'amitié avec R, C++ et les réseaux de neurones

Quick Draw Doodle Recognition : comment se lier d'amitié avec R, C++ et les réseaux de neurones

Hé Habr !

L'automne dernier, Kaggle a organisé un concours pour classer les images dessinées à la main, Quick Draw Doodle Recognition, auquel a participé, entre autres, une équipe de R-scientifiques : Artem Klevtsova, Philippa Gérante и Andreï Ogourtsov. Nous ne décrirons pas la compétition en détail ; cela a déjà été fait dans publication récente.

Cette fois, cela n'a pas fonctionné avec l'élevage de médailles, mais beaucoup d'expériences précieuses ont été acquises, j'aimerais donc parler à la communauté d'un certain nombre de choses les plus intéressantes et utiles sur Kagle et dans le travail quotidien. Parmi les sujets abordés : la vie difficile sans OpenCV, analyse JSON (ces exemples examinent l'intégration de code C++ dans des scripts ou des packages dans R en utilisant Rcpp), paramétrage des scripts et dockerisation de la solution finale. Tout le code du message sous une forme adaptée à l'exécution est disponible dans référentiels.

Table des matières:

  1. Chargez efficacement les données de CSV dans MonetDB
  2. Préparation des lots
  3. Itérateurs pour décharger des lots de la base de données
  4. Sélection d'une architecture modèle
  5. Paramétrage des scripts
  6. Dockérisation des scripts
  7. Utiliser plusieurs GPU sur Google Cloud
  8. Au lieu d'une conclusion

1. Chargez efficacement les données du CSV dans la base de données MonetDB

Les données de ce concours ne sont pas fournies sous forme d'images toutes faites, mais sous forme de 340 fichiers CSV (un fichier pour chaque classe) contenant des JSON avec des coordonnées de points. En reliant ces points par des lignes, on obtient une image finale mesurant 256x256 pixels. Il y a également pour chaque enregistrement une étiquette indiquant si l'image a été correctement reconnue par le classificateur utilisé au moment de la collecte de l'ensemble de données, un code à deux lettres du pays de résidence de l'auteur de l'image, un identifiant unique, un horodatage et un nom de classe qui correspond au nom du fichier. Une version simplifiée des données originales pèse 7.4 Go dans l'archive et environ 20 Go après déballage, les données complètes après déballage occupent 240 Go. Les organisateurs ont veillé à ce que les deux versions reproduisent les mêmes dessins, ce qui signifie que la version complète était redondante. Dans tous les cas, stocker 50 millions d'images dans des fichiers graphiques ou sous forme de tableaux a été immédiatement considéré comme non rentable, et nous avons décidé de fusionner tous les fichiers CSV de l'archive. train_simplified.zip dans la base de données avec génération ultérieure d'images de la taille requise « à la volée » pour chaque lot.

Un système éprouvé a été choisi comme SGBD MonetDB, à savoir une implémentation pour R en tant que package MonetDBLite. Le package comprend une version intégrée du serveur de base de données et vous permet de récupérer le serveur directement à partir d'une session R et de l'utiliser là-bas. La création d'une base de données et la connexion à celle-ci s'effectuent avec une seule commande :

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

Nous devrons créer deux tableaux : un pour toutes les données, l'autre pour les informations de service sur les fichiers téléchargés (utile si quelque chose ne va pas et que le processus doit être repris après le téléchargement de plusieurs fichiers) :

Création de tableaux

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

Le moyen le plus rapide de charger des données dans la base de données consistait à copier directement des fichiers CSV à l'aide de la commande SQL. COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTtablename - le nom de la table et path - le chemin d'accès au fichier. En travaillant avec l'archive, il a été découvert que l'implémentation intégrée unzip dans R ne fonctionne pas correctement avec un certain nombre de fichiers de l'archive, nous avons donc utilisé le système unzip (en utilisant le paramètre getOption("unzip")).

Fonction d'écriture dans la base de données

#' @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))
}

Si vous devez transformer la table avant de l'écrire dans la base de données, il suffit de passer l'argument preprocess fonction qui transformera les données.

Code pour charger séquentiellement des données dans la base de données :

Écrire des données dans la base de données

# Список файлов для записи
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

Le temps de chargement des données peut varier en fonction des caractéristiques de vitesse du lecteur utilisé. Dans notre cas, la lecture et l'écriture au sein d'un SSD ou d'un lecteur flash (fichier source) vers un SSD (DB) prennent moins de 10 minutes.

Il faut encore quelques secondes pour créer une colonne avec une étiquette de classe entière et une colonne d'index (ORDERED INDEX) avec les numéros de ligne par lesquels les observations seront échantillonnées lors de la création de lots :

Création de colonnes et d'index supplémentaires

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

Pour résoudre le problème de la création d'un lot à la volée, nous devions atteindre la vitesse maximale d'extraction de lignes aléatoires de la table. doodles. Pour cela nous avons utilisé 3 astuces. La première consistait à réduire la dimensionnalité du type qui stocke l’ID d’observation. Dans l'ensemble de données d'origine, le type requis pour stocker l'ID est bigint, mais le nombre d'observations permet de faire rentrer leurs identifiants, égaux au nombre ordinal, dans le type int. La recherche est beaucoup plus rapide dans ce cas. La deuxième astuce consistait à utiliser ORDERED INDEX — nous sommes arrivés à cette décision empiriquement, après avoir examiné tous les les options. La troisième consistait à utiliser des requêtes paramétrées. L'essence de la méthode est d'exécuter la commande une fois PREPARE avec utilisation ultérieure d'une expression préparée lors de la création d'un tas de requêtes du même type, mais en fait il y a un avantage par rapport à une simple SELECT s'est avéré être dans la plage d'erreur statistique.

Le processus de téléchargement de données ne consomme pas plus de 450 Mo de RAM. Autrement dit, l'approche décrite vous permet de déplacer des ensembles de données pesant des dizaines de gigaoctets sur presque tous les matériels économiques, y compris certains appareils monocartes, ce qui est plutôt cool.

Il ne reste plus qu'à mesurer la vitesse de récupération des données (aléatoires) et à évaluer la mise à l'échelle lors de l'échantillonnage de lots de différentes tailles :

Référence de base de données

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 : comment se lier d'amitié avec R, C++ et les réseaux de neurones

2. Préparation des lots

L'ensemble du processus de préparation des lots comprend les étapes suivantes :

  1. Analyse de plusieurs JSON contenant des vecteurs de chaînes avec des coordonnées de points.
  2. Tracer des lignes colorées en fonction des coordonnées des points sur une image de la taille requise (par exemple, 256×256 ou 128×128).
  3. Conversion des images résultantes en tenseur.

Dans le cadre de la concurrence entre les noyaux Python, le problème a été résolu principalement en utilisant OpenCV. L’un des analogues les plus simples et les plus évidents de R ressemblerait à ceci :

Implémentation de la conversion JSON en Tensor dans 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)
}

Le dessin est effectué à l'aide des outils R standard et enregistré dans un PNG temporaire stocké dans la RAM (sous Linux, les répertoires R temporaires se trouvent dans le répertoire /tmp, monté en RAM). Ce fichier est ensuite lu comme un tableau tridimensionnel avec des nombres allant de 0 à 1. Ceci est important car un BMP plus conventionnel serait lu dans un tableau brut avec des codes de couleur hexadécimaux.

Testons le résultat :

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 : comment se lier d'amitié avec R, C++ et les réseaux de neurones

Le lot lui-même sera constitué comme suit :

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

Cette implémentation nous a semblé sous-optimale, car la formation de gros lots prend un temps indécemment long, et nous avons décidé de profiter de l'expérience de nos confrères en utilisant une bibliothèque puissante OpenCV. À cette époque, il n'existait pas de package prêt à l'emploi pour R (il n'y en a pas maintenant), donc une implémentation minimale des fonctionnalités requises a été écrite en C++ avec intégration dans le code R à l'aide de Rcpp.

Pour résoudre le problème, les packages et bibliothèques suivants ont été utilisés :

  1. OpenCV pour travailler avec des images et dessiner des lignes. Bibliothèques système et fichiers d'en-tête préinstallés utilisés, ainsi que des liens dynamiques.

  2. xtenseur pour travailler avec des tableaux et des tenseurs multidimensionnels. Nous avons utilisé des fichiers d'en-tête inclus dans le package R du même nom. La bibliothèque vous permet de travailler avec des tableaux multidimensionnels, à la fois dans l'ordre principal des lignes et des colonnes.

  3. ndjson pour analyser JSON. Cette bibliothèque est utilisée dans xtenseur automatiquement s'il est présent dans le projet.

  4. Fil de discussion RCPP pour organiser le traitement multithread d'un vecteur à partir de JSON. Utilisé les fichiers d'en-tête fournis par ce package. Du plus populaire RcppParallèle Le package, entre autres choses, dispose d'un mécanisme d'interruption de boucle intégré.

Il convient de noter que xtenseur s'est avéré être une aubaine : en plus du fait qu'il dispose de fonctionnalités étendues et de performances élevées, ses développeurs se sont avérés assez réactifs et ont répondu aux questions rapidement et en détail. Avec leur aide, il a été possible d'implémenter des transformations de matrices OpenCV en tenseurs xtenseurs, ainsi qu'un moyen de combiner des tenseurs d'images tridimensionnels en un tenseur quadridimensionnel de la dimension correcte (le lot lui-même).

Matériel pour apprendre Rcpp, xtensor et 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

Pour compiler des fichiers qui utilisent des fichiers système et des liens dynamiques avec les bibliothèques installées sur le système, nous avons utilisé le mécanisme de plugin implémenté dans le package Rcpp. Pour trouver automatiquement les chemins et les indicateurs, nous avons utilisé un utilitaire Linux populaire pkg-config.

Implémentation du plugin Rcpp pour utiliser la bibliothèque OpenCV

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

Suite au fonctionnement du plugin, les valeurs suivantes seront substituées lors du processus de compilation :

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"

Le code d'implémentation pour analyser JSON et générer un lot à transmettre au modèle est donné sous le spoiler. Tout d’abord, ajoutez un répertoire de projet local pour rechercher les fichiers d’en-tête (nécessaire pour ndjson) :

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

Implémentation de la conversion JSON en tenseur en 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;
}

Ce code doit être placé dans le fichier src/cv_xt.cpp et compilez avec la commande Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); également requis pour le travail nlohmann/json.hpp de dépôt. Le code est divisé en plusieurs fonctions :

  • to_xt — une fonction modélisée pour transformer une matrice d'image (cv::Mat) à un tenseur xt::xtensor;

  • parse_json — la fonction analyse une chaîne JSON, extrait les coordonnées des points et les regroupe dans un vecteur ;

  • ocv_draw_lines — à partir du vecteur de points résultant, trace des lignes multicolores ;

  • process — combine les fonctions ci-dessus et ajoute également la possibilité de redimensionner l'image résultante ;

  • cpp_process_json_str - wrapper sur la fonction process, qui exporte le résultat vers un objet R (tableau multidimensionnel) ;

  • cpp_process_json_vector - wrapper sur la fonction cpp_process_json_str, qui vous permet de traiter un vecteur chaîne en mode multithread.

Pour dessiner des lignes multicolores, le modèle de couleur HSV a été utilisé, suivi d'une conversion en RVB. Testons le résultat :

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

Quick Draw Doodle Recognition : comment se lier d'amitié avec R, C++ et les réseaux de neurones
Comparaison de la vitesse d'implémentation en R et 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 : comment se lier d'amitié avec R, C++ et les réseaux de neurones

Comme vous pouvez le constater, l'augmentation de la vitesse s'est avérée très significative, et il n'est pas possible de rattraper le code C++ en parallélisant le code R.

3. Itérateurs pour décharger les lots de la base de données

R a une réputation bien méritée pour le traitement de données qui tiennent dans la RAM, tandis que Python se caractérise davantage par un traitement de données itératif, permettant de mettre en œuvre facilement et naturellement des calculs hors cœur (calculs utilisant la mémoire externe). Un exemple classique et pertinent pour nous dans le contexte du problème décrit est celui des réseaux de neurones profonds entraînés par la méthode de descente de gradient avec approximation du gradient à chaque étape à l'aide d'une petite partie d'observations, ou mini-batch.

Les frameworks d'apprentissage profond écrits en Python ont des classes spéciales qui implémentent des itérateurs basés sur des données : tableaux, images dans des dossiers, formats binaires, etc. Vous pouvez utiliser des options prêtes à l'emploi ou écrire les vôtres pour des tâches spécifiques. En R, nous pouvons profiter de toutes les fonctionnalités de la bibliothèque Python keras avec ses différents backends utilisant le package du même nom, qui à son tour fonctionne au-dessus du package réticulé. Ce dernier mérite un long article séparé ; il vous permet non seulement d'exécuter du code Python à partir de R, mais vous permet également de transférer des objets entre les sessions R et Python, en effectuant automatiquement toutes les conversions de type nécessaires.

Nous nous sommes débarrassés de la nécessité de stocker toutes les données dans la RAM en utilisant MonetDBLite, tout le travail du « réseau neuronal » sera effectué par le code original en Python, il suffit d'écrire un itérateur sur les données, car il n'y a rien de prêt pour une telle situation en R ou Python. Il n'a essentiellement que deux exigences : il doit renvoyer des lots dans une boucle sans fin et sauvegarder son état entre les itérations (ce dernier dans R est implémenté de la manière la plus simple à l'aide de fermetures). Auparavant, il était nécessaire de convertir explicitement les tableaux R en tableaux numpy à l'intérieur de l'itérateur, mais la version actuelle du package keras le fait elle-même.

L'itérateur pour les données de formation et de validation s'est avéré être le suivant :

Itérateur pour les données de formation et de validation

train_generator <- function(db_connection = con,
                            samples_index,
                            num_classes = 340,
                            batch_size = 32,
                            scale = 1,
                            color = FALSE,
                            imagenet_preproc = FALSE) {
  # Проверка аргументов
  checkmate::assert_class(con, "DBIConnection")
  checkmate::assert_integerish(samples_index)
  checkmate::assert_count(num_classes)
  checkmate::assert_count(batch_size)
  checkmate::assert_number(scale, lower = 0.001, upper = 5)
  checkmate::assert_flag(color)
  checkmate::assert_flag(imagenet_preproc)

  # Перемешиваем, чтобы брать и удалять использованные индексы батчей по порядку
  dt <- data.table::data.table(id = sample(samples_index))
  # Проставляем номера батчей
  dt[, batch := (.I - 1L) %/% batch_size + 1L]
  # Оставляем только полные батчи и индексируем
  dt <- dt[, if (.N == batch_size) .SD, keyby = batch]
  # Устанавливаем счётчик
  i <- 1
  # Количество батчей
  max_i <- dt[, max(batch)]

  # Подготовка выражения для выгрузки
  sql <- sprintf(
    "PREPARE SELECT drawing, label_int FROM doodles WHERE id IN (%s)",
    paste(rep("?", batch_size), collapse = ",")
  )
  res <- DBI::dbSendQuery(con, sql)

  # Аналог keras::to_categorical
  to_categorical <- function(x, num) {
    n <- length(x)
    m <- numeric(n * num)
    m[x * n + seq_len(n)] <- 1
    dim(m) <- c(n, num)
    return(m)
  }

  # Замыкание
  function() {
    # Начинаем новую эпоху
    if (i > max_i) {
      dt[, id := sample(id)]
      data.table::setkey(dt, batch)
      # Сбрасываем счётчик
      i <<- 1
      max_i <<- dt[, max(batch)]
    }

    # ID для выгрузки данных
    batch_ind <- dt[batch == i, id]
    # Выгрузка данных
    batch <- DBI::dbFetch(DBI::dbBind(res, as.list(batch_ind)), n = -1)

    # Увеличиваем счётчик
    i <<- i + 1

    # Парсинг JSON и подготовка массива
    batch_x <- cpp_process_json_vector(batch$drawing, scale = scale, color = color)
    if (imagenet_preproc) {
      # Шкалирование c интервала [0, 1] на интервал [-1, 1]
      batch_x <- (batch_x - 0.5) * 2
    }

    batch_y <- to_categorical(batch$label_int, num_classes)
    result <- list(batch_x, batch_y)
    return(result)
  }
}

La fonction prend en entrée une variable avec une connexion à la base de données, le nombre de lignes utilisées, le nombre de classes, la taille du lot, l'échelle (scale = 1 correspond à un rendu d'images de 256x256 pixels, scale = 0.5 — 128x128 pixels), indicateur de couleur (color = FALSE spécifie le rendu en niveaux de gris lorsqu'il est utilisé color = TRUE chaque trait est dessiné dans une nouvelle couleur) et un indicateur de prétraitement pour les réseaux pré-entraînés sur imagenet. Ce dernier est nécessaire pour mettre à l'échelle les valeurs de pixels de l'intervalle [0, 1] à l'intervalle [-1, 1], qui a été utilisé lors de la formation du fichier fourni. keras modèles.

La fonction externe contient une vérification du type d'argument, une table data.table avec des numéros de ligne mélangés aléatoirement à partir de samples_index et les numéros de lots, le compteur et le nombre maximum de lots, ainsi qu'une expression SQL pour décharger les données de la base de données. De plus, nous avons défini un analogue rapide de la fonction à l'intérieur keras::to_categorical(). Nous avons utilisé presque toutes les données pour la formation, en laissant un demi pour cent pour la validation, la taille de l'époque était donc limitée par le paramètre steps_per_epoch lorsqu'il est appelé keras::fit_generator(), et l'état if (i > max_i) n'a fonctionné que pour l'itérateur de validation.

Dans la fonction interne, les index de lignes sont récupérés pour le lot suivant, les enregistrements sont déchargés de la base de données avec le compteur de lots augmentant, l'analyse JSON (fonction cpp_process_json_vector(), écrit en C++) et en créant des tableaux correspondant aux images. Ensuite, des vecteurs uniques avec des étiquettes de classe sont créés, des tableaux avec des valeurs de pixels et des étiquettes sont combinés dans une liste, qui est la valeur de retour. Pour accélérer le travail, nous avons utilisé la création d'index dans des tables data.table et modification via le lien - sans ces "puces" du package données.table Il est assez difficile d’imaginer travailler efficacement avec une quantité importante de données dans R.

Les résultats des mesures de vitesse sur un ordinateur portable Core i5 sont les suivants :

Référence de l'itérateur

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 : comment se lier d'amitié avec R, C++ et les réseaux de neurones

Si vous disposez d'une quantité de RAM suffisante, vous pouvez sérieusement accélérer le fonctionnement de la base de données en la transférant dans cette même RAM (32 Go suffisent pour notre tâche). Sous Linux, la partition est montée par défaut /dev/shm, occupant jusqu'à la moitié de la capacité de la RAM. Vous pouvez en mettre davantage en évidence en éditant /etc/fstabpour obtenir un record comme tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Assurez-vous de redémarrer et de vérifier le résultat en exécutant la commande df -h.

L'itérateur pour les données de test semble beaucoup plus simple, puisque l'ensemble de données de test s'intègre entièrement dans la RAM :

Itérateur pour les données de test

test_generator <- function(dt,
                           batch_size = 32,
                           scale = 1,
                           color = FALSE,
                           imagenet_preproc = FALSE) {

  # Проверка аргументов
  checkmate::assert_data_table(dt)
  checkmate::assert_count(batch_size)
  checkmate::assert_number(scale, lower = 0.001, upper = 5)
  checkmate::assert_flag(color)
  checkmate::assert_flag(imagenet_preproc)

  # Проставляем номера батчей
  dt[, batch := (.I - 1L) %/% batch_size + 1L]
  data.table::setkey(dt, batch)
  i <- 1
  max_i <- dt[, max(batch)]

  # Замыкание
  function() {
    batch_x <- cpp_process_json_vector(dt[batch == i, drawing], 
                                       scale = scale, color = color)
    if (imagenet_preproc) {
      # Шкалирование c интервала [0, 1] на интервал [-1, 1]
      batch_x <- (batch_x - 0.5) * 2
    }
    result <- list(batch_x)
    i <<- i + 1
    return(result)
  }
}

4. Sélection de l'architecture modèle

La première architecture utilisée était réseau mobile v1, dont les caractéristiques sont discutées dans Cette message. Il est inclus en standard keras et, par conséquent, est disponible dans le package du même nom pour R. Mais en essayant de l'utiliser avec des images monocanal, une chose étrange s'est produite : le tenseur d'entrée doit toujours avoir la dimension (batch, height, width, 3), c'est-à-dire que le nombre de canaux ne peut pas être modifié. Il n'y a pas une telle limitation en Python, nous nous sommes donc précipités et avons écrit notre propre implémentation de cette architecture, en suivant l'article original (sans l'abandon qui se trouve dans la version keras) :

Architecture 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 <- 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)
}

Les inconvénients de cette approche sont évidents. Je souhaite tester beaucoup de modèles, mais au contraire, je ne souhaite pas réécrire chaque architecture manuellement. Nous avons également été privés de la possibilité d'utiliser les poids de modèles pré-entraînés sur imagenet. Comme d'habitude, l'étude de la documentation a été utile. Fonction get_config() permet d'obtenir une description du modèle sous une forme adaptée à l'édition (base_model_conf$layers - une liste R régulière), et la fonction from_config() effectue la conversion inverse en un objet modèle :

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)

Maintenant, il n'est pas difficile d'écrire une fonction universelle pour obtenir l'un des éléments fournis keras modèles avec ou sans poids entraînés sur imagenet :

Fonction de chargement d'architectures prêtes à l'emploi

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

Lors de l'utilisation d'images monocanal, aucun poids pré-entraîné n'est utilisé. Cela pourrait être corrigé : en utilisant la fonction get_weights() obtenez les poids du modèle sous la forme d'une liste de tableaux R, modifiez la dimension du premier élément de cette liste (en prenant un canal de couleur ou en faisant la moyenne des trois), puis chargez à nouveau les poids dans le modèle avec la fonction set_weights(). Nous n'avons jamais ajouté cette fonctionnalité, car à ce stade, il était déjà clair qu'il était plus productif de travailler avec des images en couleur.

Nous avons réalisé la plupart des expériences en utilisant les versions 1 et 2 de mobilenet, ainsi que resnet34. Les architectures plus modernes telles que SE-ResNeXt ont bien performé dans cette compétition. Malheureusement, nous n'avions pas d'implémentations toutes faites à notre disposition et nous n'avons pas écrit les nôtres (mais nous l'écrirons certainement).

5. Paramétrage des scripts

Pour plus de commodité, tout le code permettant de démarrer la formation a été conçu comme un seul script, paramétré à l'aide de documenter comme suit:

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)

Forfait documenter représente la mise en œuvre http://docopt.org/ pour R. Avec son aide, les scripts sont lancés avec des commandes simples comme Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db ou ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, si fichier train_nn.R est exécutable (cette commande commencera à entraîner le modèle resnet50 sur les images tricolores mesurant 128x128 pixels, la base de données doit se situer dans le dossier /home/andrey/doodle_db). Vous pouvez ajouter la vitesse d'apprentissage, le type d'optimiseur et tout autre paramètre personnalisable à la liste. Au cours de la préparation de la publication, il s'est avéré que l'architecture mobilenet_v2 de la version actuelle keras en utilisation R ne doit pas en raison de changements non pris en compte dans le package R, nous attendons qu'ils le corrigent.

Cette approche a permis d'accélérer considérablement les expérimentations avec différents modèles par rapport au lancement plus traditionnel de scripts dans RStudio (on note le package comme alternative possible tfruns). Mais le principal avantage est la possibilité de gérer facilement le lancement des scripts dans Docker ou simplement sur le serveur, sans installer RStudio pour cela.

6. Dockerisation des scripts

Nous avons utilisé Docker pour garantir la portabilité de l'environnement pour les modèles de formation entre les membres de l'équipe et pour un déploiement rapide dans le cloud. Vous pouvez commencer à vous familiariser avec cet outil, relativement inhabituel pour un programmeur R, avec cette série de publications ou cours vidéo.

Docker vous permet à la fois de créer vos propres images à partir de zéro et d'utiliser d'autres images comme base pour créer les vôtres. En analysant les options disponibles, nous sommes arrivés à la conclusion que l'installation des pilotes NVIDIA, CUDA+cuDNN et des bibliothèques Python est une partie assez volumineuse de l'image, et nous avons décidé de nous baser sur l'image officielle tensorflow/tensorflow:1.12.0-gpu, en y ajoutant les packages R nécessaires.

Le fichier Docker final ressemblait à ceci :

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

Pour plus de commodité, les packages utilisés ont été placés dans des variables ; la majeure partie des scripts écrits est copiée à l'intérieur des conteneurs lors de l'assemblage. Nous avons également modifié le shell de commande en /bin/bash pour faciliter l'utilisation du contenu /etc/os-release. Cela évitait d'avoir à spécifier la version du système d'exploitation dans le code.

De plus, un petit script bash a été écrit pour vous permettre de lancer un conteneur avec diverses commandes. Par exemple, il peut s'agir de scripts pour entraîner les réseaux de neurones préalablement placés à l'intérieur du conteneur, ou d'un shell de commande pour déboguer et surveiller le fonctionnement du conteneur :

Script pour lancer le conteneur

#!/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}

Si ce script bash est exécuté sans paramètres, le script sera appelé à l'intérieur du conteneur train_nn.R avec des valeurs par défaut ; si le premier argument de position est "bash", alors le conteneur démarrera de manière interactive avec un shell de commande. Dans tous les autres cas, les valeurs des arguments positionnels sont substituées : CMD="Rscript /app/train_nn.R $@".

Il convient de noter que les répertoires contenant les données sources et la base de données, ainsi que le répertoire de sauvegarde des modèles entraînés, sont montés à l'intérieur du conteneur à partir du système hôte, ce qui vous permet d'accéder aux résultats des scripts sans manipulations inutiles.

7. Utilisation de plusieurs GPU sur Google Cloud

L'une des caractéristiques du concours était les données très bruitées (voir l'image du titre, empruntée à @Leigh.plt d'ODS Slack). De gros lots permettent de lutter contre cela, et après des expérimentations sur un PC avec 1 GPU, nous avons décidé de maîtriser des modèles d'entraînement sur plusieurs GPU dans le cloud. GoogleCloud utilisé (bon guide des bases) grâce au grand choix de configurations disponibles, aux prix raisonnables et au bonus de 300 $. Par cupidité, j'ai commandé une instance 4xV100 avec un SSD et une tonne de RAM, et ce fut une grosse erreur. Une telle machine consomme rapidement de l’argent ; vous pouvez faire faillite en expérimentant sans un pipeline éprouvé. A des fins pédagogiques, il vaut mieux prendre le K80. Mais la grande quantité de RAM s'est avérée utile - le SSD cloud n'a pas impressionné par ses performances, la base de données a donc été transférée vers dev/shm.

Le fragment de code responsable de l’utilisation de plusieurs GPU est le plus intéressant. Tout d'abord, le modèle est créé sur le CPU à l'aide d'un gestionnaire de contexte, tout comme en 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
  )
})

Ensuite, le modèle non compilé (c'est important) est copié sur un nombre donné de GPU disponibles, et seulement après cela, il est compilé :

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

La technique classique consistant à geler toutes les couches sauf la dernière, à entraîner la dernière couche, à débloquer et à recycler l'ensemble du modèle pour plusieurs GPU n'a pas pu être mise en œuvre.

L'entraînement a été surveillé sans utilisation. tenseur, en nous limitant à enregistrer des journaux et à sauvegarder des modèles avec des noms informatifs après chaque époque :

Rappels

# Шаблон имени файла лога
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. Au lieu d'une conclusion

Un certain nombre de problèmes que nous avons rencontrés ne sont pas encore résolus :

  • в keras il n'existe pas de fonction toute faite pour rechercher automatiquement le taux d'apprentissage optimal (analogique lr_finder dans la bibliothèque rapide.ai); Avec quelques efforts, il est possible de porter des implémentations tierces vers R, par exemple, cette;
  • en conséquence du point précédent, il n'était pas possible de sélectionner la bonne vitesse d'entraînement lors de l'utilisation de plusieurs GPU ;
  • il y a un manque d'architectures de réseaux neuronaux modernes, en particulier celles pré-formées sur imagenet ;
  • pas de politique de cycle unique et taux d'apprentissage discriminants (le recuit cosinus était à notre demande mis en œuvreMerci skeydan).

Quelles choses utiles ont été apprises de ce concours :

  • Sur du matériel relativement peu gourmand en énergie, vous pouvez travailler sans problème avec des volumes de données décents (plusieurs fois la taille de la RAM). Sac plastique données.table économise de la mémoire grâce à la modification sur place des tables, ce qui évite de les copier, et lorsqu'elles sont utilisées correctement, ses capacités démontrent presque toujours la vitesse la plus élevée parmi tous les outils que nous connaissons pour les langages de script. L'enregistrement des données dans une base de données vous permet, dans de nombreux cas, de ne pas penser du tout à la nécessité de compresser l'intégralité de l'ensemble de données dans la RAM.
  • Les fonctions lentes en R peuvent être remplacées par des fonctions rapides en C++ à l'aide du package Rcpp. Si en plus d'utiliser Fil de discussion RCPP ou RcppParallèle, nous obtenons des implémentations multi-thread multiplateformes, il n'est donc pas nécessaire de paralléliser le code au niveau R.
  • Par colis Rcpp peut être utilisé sans connaissance approfondie du C++, le minimum requis est indiqué ici. Fichiers d'en-tête pour un certain nombre de bibliothèques C intéressantes comme xtenseur disponible sur CRAN, c'est-à-dire qu'une infrastructure est en cours de formation pour la mise en œuvre de projets intégrant du code C++ hautes performances prêt à l'emploi dans R. Une commodité supplémentaire est la coloration syntaxique et un analyseur de code C++ statique dans RStudio.
  • documenter vous permet d'exécuter des scripts autonomes avec des paramètres. Ceci est pratique pour une utilisation sur un serveur distant, incl. sous Docker. Dans RStudio, il n'est pas pratique de mener de nombreuses heures d'expériences avec la formation des réseaux de neurones, et l'installation de l'IDE sur le serveur lui-même n'est pas toujours justifiée.
  • Docker garantit la portabilité du code et la reproductibilité des résultats entre développeurs avec différentes versions de l'OS et des bibliothèques, ainsi qu'une facilité d'exécution sur les serveurs. Vous pouvez lancer l’intégralité du pipeline de formation avec une seule commande.
  • Google Cloud est un moyen économique d'expérimenter sur du matériel coûteux, mais vous devez choisir les configurations avec soin.
  • Mesurer la vitesse de fragments de code individuels est très utile, en particulier lors de la combinaison de R et C++, et avec le package banc - aussi très facile.

Dans l'ensemble, cette expérience a été très enrichissante et nous continuons à travailler pour résoudre certains des problèmes soulevés.

Source: habr.com

Ajouter un commentaire