Reconocimiento Quick Draw Doodle: cómo hacerse amigo de R, C++ y redes neuronales

Reconocimiento Quick Draw Doodle: cómo hacerse amigo de R, C++ y redes neuronales

¡Hola, Habr!

El otoño pasado, Kaggle organizó un concurso para clasificar imágenes dibujadas a mano, Quick Draw Doodle Recognition, en el que, entre otros, participó un equipo de científicos R: Artem Klevtsova, felipa gerente и Andrei Ogurtsov. No describiremos el concurso en detalle; eso ya se ha hecho en publicación reciente.

Esta vez no funcionó con la obtención de medallas, pero se adquirió mucha experiencia valiosa, por lo que me gustaría contarle a la comunidad algunas de las cosas más interesantes y útiles en Kagle y en el trabajo diario. Entre los temas tratados: la vida difícil sin OpenCV, análisis JSON (estos ejemplos examinan la integración del código C++ en scripts o paquetes en R usando rcpp), parametrización de scripts y dockerización de la solución final. Todo el código del mensaje en un formato adecuado para su ejecución está disponible en repositorios.

Contenido:

  1. Cargue datos de forma eficiente desde CSV a MonetDB
  2. Preparando lotes
  3. Iteradores para descargar lotes de la base de datos.
  4. Seleccionar una arquitectura modelo
  5. Parametrización de scripts
  6. Dockerización de scripts
  7. Usando múltiples GPU en Google Cloud
  8. En lugar de una conclusión

1. Cargue datos de manera eficiente desde CSV en la base de datos MonetDB

Los datos de este concurso no se proporcionan en forma de imágenes ya preparadas, sino en forma de 340 archivos CSV (un archivo para cada clase) que contienen JSON con coordenadas de puntos. Al conectar estos puntos con líneas, obtenemos una imagen final que mide 256x256 píxeles. Además, para cada registro hay una etiqueta que indica si la imagen fue reconocida correctamente por el clasificador utilizado en el momento en que se recopiló el conjunto de datos, un código de dos letras del país de residencia del autor de la imagen, un identificador único, una marca de tiempo. y un nombre de clase que coincida con el nombre del archivo. Una versión simplificada de los datos originales pesa 7.4 GB en el archivo y aproximadamente 20 GB después de descomprimirlos, los datos completos después de descomprimirlos ocupan 240 GB. Los organizadores se aseguraron de que ambas versiones reprodujeran los mismos dibujos, por lo que la versión completa era redundante. En cualquier caso, almacenar 50 millones de imágenes en archivos gráficos o en forma de matrices inmediatamente se consideró no rentable y decidimos fusionar todos los archivos CSV del archivo. tren_simplificado.zip en la base de datos con la posterior generación de imágenes del tamaño requerido "sobre la marcha" para cada lote.

Se eligió un sistema bien probado como DBMS MonetDB, es decir, una implementación para R como paquete MonetDBLite. El paquete incluye una versión integrada del servidor de base de datos y le permite seleccionar el servidor directamente desde una sesión de R y trabajar con él allí. La creación de una base de datos y la conexión a ella se realizan con un comando:

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

Necesitaremos crear dos tablas: una para todos los datos y otra para la información del servicio sobre los archivos descargados (útil si algo sale mal y el proceso debe reanudarse después de descargar varios archivos):

Creación de la tabla

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

La forma más rápida de cargar datos en la base de datos era copiar directamente archivos CSV usando SQL - comando COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTDonde tablename - nombre de la tabla y path - la ruta al archivo. Mientras trabajaba con el archivo, se descubrió que la implementación incorporada unzip en R no funciona correctamente con varios archivos del archivo, por lo que utilizamos el sistema unzip (usando el parámetro getOption("unzip")).

Función para escribir en la base de datos.

#' @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 necesita transformar la tabla antes de escribirla en la base de datos, basta con pasar el argumento preprocess función que transformará los datos.

Código para cargar datos secuencialmente en la base de datos:

Escribir datos en la base de datos.

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

El tiempo de carga de datos puede variar dependiendo de las características de velocidad de la unidad utilizada. En nuestro caso, leer y escribir dentro de un SSD o desde una unidad flash (archivo fuente) en un SSD (DB) lleva menos de 10 minutos.

Se necesitan unos segundos más para crear una columna con una etiqueta de clase entera y una columna de índice (ORDERED INDEX) con números de línea mediante los cuales se muestrearán las observaciones al crear lotes:

Crear columnas e índice adicionales

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

Para resolver el problema de crear un lote sobre la marcha, necesitábamos alcanzar la velocidad máxima de extracción de filas aleatorias de la tabla. doodles. Para ello utilizamos 3 trucos. El primero fue reducir la dimensionalidad del tipo que almacena el ID de observación. En el conjunto de datos original, el tipo requerido para almacenar la ID es bigint, pero el número de observaciones permite encajar sus identificadores, igual al número ordinal, en el tipo int. La búsqueda es mucho más rápida en este caso. El segundo truco fue utilizar ORDERED INDEX — llegamos a esta decisión empíricamente, después de haber examinado todos los disponibles Opciones. El tercero fue utilizar consultas parametrizadas. La esencia del método es ejecutar el comando una vez. PREPARE con el uso posterior de una expresión preparada al crear un montón de consultas del mismo tipo, pero de hecho hay una ventaja en comparación con una simple SELECT resultó estar dentro del rango de error estadístico.

El proceso de carga de datos no consume más de 450 MB de RAM. Es decir, el enfoque descrito le permite mover conjuntos de datos que pesan decenas de gigabytes en casi cualquier hardware económico, incluidos algunos dispositivos de placa única, lo cual es bastante bueno.

Todo lo que queda es medir la velocidad de recuperación de datos (aleatorios) y evaluar el escalado al muestrear lotes de diferentes tamaños:

Punto de referencia de la base de datos

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)

Reconocimiento Quick Draw Doodle: cómo hacerse amigo de R, C++ y redes neuronales

2. Preparar lotes

Todo el proceso de preparación del lote consta de los siguientes pasos:

  1. Analizando varios JSON que contienen vectores de cadenas con coordenadas de puntos.
  2. Dibujar líneas de colores basadas en las coordenadas de puntos en una imagen del tamaño requerido (por ejemplo, 256×256 o 128×128).
  3. Convirtiendo las imágenes resultantes en un tensor.

Como parte de la competencia entre los núcleos de Python, el problema se resolvió principalmente usando OpenCV. Uno de los análogos más simples y obvios en R sería el siguiente:

Implementación de la conversión de JSON a tensor en 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)
}

El dibujo se realiza utilizando herramientas estándar de R y se guarda en un PNG temporal almacenado en la RAM (en Linux, los directorios temporales de R se encuentran en el directorio /tmp, montado en RAM). Luego, este archivo se lee como una matriz tridimensional con números que van del 0 al 1. Esto es importante porque un BMP más convencional se leería en una matriz sin formato con códigos de colores hexadecimales.

Probemos el resultado:

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

Reconocimiento Quick Draw Doodle: cómo hacerse amigo de R, C++ y redes neuronales

El lote en sí quedará formado de la siguiente manera:

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

Esta implementación no nos pareció óptima, ya que la formación de lotes grandes lleva un tiempo indecentemente largo, y decidimos aprovechar la experiencia de nuestros colegas utilizando una potente biblioteca. OpenCV. En ese momento no había ningún paquete listo para R (no hay ninguno ahora), por lo que se escribió una implementación mínima de la funcionalidad requerida en C++ con integración en el código R usando rcpp.

Para solucionar el problema se utilizaron los siguientes paquetes y bibliotecas:

  1. OpenCV para trabajar con imágenes y dibujar líneas. Se utilizaron bibliotecas del sistema y archivos de encabezado preinstalados, así como enlaces dinámicos.

  2. xtensor para trabajar con matrices multidimensionales y tensores. Usamos archivos de encabezado incluidos en el paquete R del mismo nombre. La biblioteca le permite trabajar con matrices multidimensionales, tanto en orden de fila como de columna.

  3. ndjson para analizar JSON. Esta biblioteca se utiliza en xtensor automáticamente si está presente en el proyecto.

  4. hilo rcpp para organizar el procesamiento multiproceso de un vector desde JSON. Usó los archivos de encabezado proporcionados por este paquete. De más popular RcppParalelo El paquete, entre otras cosas, tiene un mecanismo de interrupción de bucle incorporado.

Vale la pena señalar que xtensor resultó ser una bendición: además de tener una amplia funcionalidad y un alto rendimiento, sus desarrolladores resultaron ser bastante receptivos y respondieron las preguntas con rapidez y detalle. Con su ayuda, fue posible implementar transformaciones de matrices OpenCV en tensores xtensoriales, así como una forma de combinar tensores de imágenes tridimensionales en un tensor de 3 dimensiones de la dimensión correcta (el lote en sí).

Materiales para aprender Rcpp, xtensor y 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

Para compilar archivos que utilizan archivos del sistema y enlaces dinámicos con bibliotecas instaladas en el sistema, utilizamos el mecanismo de complemento implementado en el paquete. rcpp. Para encontrar rutas e indicadores automáticamente, utilizamos una popular utilidad de Linux. paquete-config.

Implementación del complemento Rcpp para usar la biblioteca 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)
  ))
})

Como resultado del funcionamiento del complemento, los siguientes valores se sustituirán durante el proceso de compilación:

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"

El código de implementación para analizar JSON y generar un lote para transmitirlo al modelo se proporciona en el spoiler. Primero, agregue un directorio de proyecto local para buscar archivos de encabezado (necesarios para ndjson):

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

Implementación de conversión de JSON a tensor 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;
}

Este código debe colocarse en el archivo. src/cv_xt.cpp y compilar con el comando Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); también requerido para el trabajo nlohmann/json.hpp de repositorio. El código se divide en varias funciones:

  • to_xt — una función con plantilla para transformar una matriz de imagen (cv::Mat) a un tensor xt::xtensor;

  • parse_json — la función analiza una cadena JSON, extrae las coordenadas de los puntos y los empaqueta en un vector;

  • ocv_draw_lines — a partir del vector de puntos resultante, dibuja líneas multicolores;

  • process — combina las funciones anteriores y también agrega la capacidad de escalar la imagen resultante;

  • cpp_process_json_str - contenedor sobre la función process, que exporta el resultado a un objeto R (matriz multidimensional);

  • cpp_process_json_vector - contenedor sobre la función cpp_process_json_str, que le permite procesar un vector de cadena en modo multiproceso.

Para dibujar líneas multicolores, se utilizó el modelo de color HSV, seguido de la conversión a RGB. Probemos el resultado:

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

Reconocimiento Quick Draw Doodle: cómo hacerse amigo de R, C++ y redes neuronales
Comparación de la velocidad de implementaciones en R y 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") 

Reconocimiento Quick Draw Doodle: cómo hacerse amigo de R, C++ y redes neuronales

Como puede ver, el aumento de velocidad resultó ser muy significativo y no es posible ponerse al día con el código C++ al paralelizar el código R.

3. Iteradores para descargar lotes de la base de datos.

R tiene una merecida reputación por procesar datos que caben en la RAM, mientras que Python se caracteriza más por el procesamiento de datos iterativo, lo que le permite implementar de forma fácil y natural cálculos fuera del núcleo (cálculos que utilizan memoria externa). Un ejemplo clásico y relevante para nosotros en el contexto del problema descrito son las redes neuronales profundas entrenadas mediante el método de descenso de gradiente con aproximación del gradiente en cada paso utilizando una pequeña porción de observaciones, o mini-lote.

Los marcos de aprendizaje profundo escritos en Python tienen clases especiales que implementan iteradores basados ​​en datos: tablas, imágenes en carpetas, formatos binarios, etc. Puede usar opciones ya preparadas o escribir las suyas propias para tareas específicas. En R podemos aprovechar todas las características de la biblioteca de Python keras con sus diversos backends usando el paquete del mismo nombre, que a su vez funciona encima del paquete reticular. Esto último merece un artículo extenso aparte; no solo le permite ejecutar código Python desde R, sino que también le permite transferir objetos entre sesiones de R y Python, realizando automáticamente todas las conversiones de tipos necesarias.

Nos deshicimos de la necesidad de almacenar todos los datos en RAM usando MonetDBLite, todo el trabajo de la “red neuronal” lo realizará el código original en Python, solo tenemos que escribir un iterador sobre los datos, ya que no hay nada listo. para tal situación en R o Python. Básicamente, solo existen dos requisitos para ello: debe devolver lotes en un bucle sin fin y guardar su estado entre iteraciones (esto último en R se implementa de la forma más sencilla mediante cierres). Anteriormente, era necesario convertir explícitamente matrices R en matrices numpy dentro del iterador, pero la versión actual del paquete keras lo hace ella misma.

El iterador para los datos de entrenamiento y validación resultó ser el siguiente:

Iterador para datos de entrenamiento y validación.

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 función toma como entrada una variable con una conexión a la base de datos, el número de líneas utilizadas, el número de clases, el tamaño del lote, la escala (scale = 1 corresponde a renderizar imágenes de 256x256 píxeles, scale = 0.5 — 128x128 píxeles), indicador de color (color = FALSE especifica la representación en escala de grises cuando se usa color = TRUE cada trazo se dibuja en un nuevo color) y un indicador de preprocesamiento para redes previamente entrenadas en imagenet. Esto último es necesario para escalar los valores de píxeles del intervalo [0, 1] al intervalo [-1, 1], que se utilizó al entrenar el suministrado keras modelos.

La función externa contiene verificación del tipo de argumento, una tabla data.table con números de línea mezclados aleatoriamente de samples_index y números de lote, contador y número máximo de lotes, así como una expresión SQL para descargar datos de la base de datos. Además, definimos un análogo rápido de la función dentro keras::to_categorical(). Usamos casi todos los datos para el entrenamiento, dejando medio por ciento para la validación, por lo que el tamaño de la época estuvo limitado por el parámetro. steps_per_epoch cuando se llama keras::fit_generator(), y la condición if (i > max_i) solo funcionó para el iterador de validación.

En la función interna, los índices de filas se recuperan para el siguiente lote, los registros se descargan de la base de datos con el contador de lotes aumentando, el análisis JSON (función cpp_process_json_vector(), escrito en C++) y creando matrices correspondientes a imágenes. Luego se crean vectores one-hot con etiquetas de clase, las matrices con valores de píxeles y etiquetas se combinan en una lista, que es el valor de retorno. Para acelerar el trabajo, utilizamos la creación de índices en tablas. data.table y modificación a través del enlace - sin estos "chips" del paquete tabla de datos Es bastante difícil imaginar trabajar eficazmente con una cantidad significativa de datos en R.

Los resultados de las mediciones de velocidad en una computadora portátil Core i5 son los siguientes:

Punto de referencia del iterador

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)

Reconocimiento Quick Draw Doodle: cómo hacerse amigo de R, C++ y redes neuronales

Si tiene suficiente RAM, puede acelerar seriamente el funcionamiento de la base de datos transfiriéndola a esta misma RAM (32 GB son suficientes para nuestra tarea). En Linux, la partición está montada por defecto. /dev/shm, ocupando hasta la mitad de la capacidad de RAM. Puedes resaltar más editando /etc/fstabpara obtener un registro como tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Asegúrese de reiniciar y verificar el resultado ejecutando el comando df -h.

El iterador de datos de prueba parece mucho más simple, ya que el conjunto de datos de prueba cabe completamente en la RAM:

Iterador para datos de prueba.

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. Selección de arquitectura modelo.

La primera arquitectura utilizada fue red móvil v1, cuyas características se analizan en Este mensaje. Viene incluido de serie keras y, en consecuencia, está disponible en el paquete del mismo nombre para R. Pero al intentar usarlo con imágenes de un solo canal, resultó algo extraño: el tensor de entrada siempre debe tener la dimensión (batch, height, width, 3), es decir, el número de canales no se puede cambiar. No existe tal limitación en Python, por lo que nos apresuramos y escribimos nuestra propia implementación de esta arquitectura, siguiendo el artículo original (sin el abandono que hay en la versión de keras):

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

Las desventajas de este enfoque son obvias. Quiero probar muchos modelos, pero por el contrario, no quiero reescribir cada arquitectura manualmente. También nos privaron de la oportunidad de utilizar los pesos de modelos previamente entrenados en imagenet. Como siempre, me ayudó estudiar la documentación. Función get_config() le permite obtener una descripción del modelo en una forma adecuada para editar (base_model_conf$layers - una lista R normal), y la función from_config() realiza la conversión inversa a un objeto modelo:

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)

Ahora bien, no es difícil escribir una función universal para obtener cualquiera de las funciones proporcionadas. keras modelos con o sin pesas entrenados en imagenet:

Función para cargar arquitecturas listas para usar.

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

Cuando se utilizan imágenes de un solo canal, no se utilizan pesos previamente entrenados. Esto podría solucionarse: usando la función get_weights() obtenga los pesos del modelo en forma de una lista de matrices R, cambie la dimensión del primer elemento de esta lista (tomando un canal de color o promediando los tres) y luego cargue los pesos nuevamente en el modelo con la función set_weights(). Nunca agregamos esta funcionalidad, porque en esta etapa ya estaba claro que era más productivo trabajar con imágenes en color.

Llevamos a cabo la mayoría de los experimentos utilizando las versiones 1 y 2 de mobilenet, así como resnet34. Arquitecturas más modernas como SE-ResNeXt obtuvieron buenos resultados en esta competencia. Desafortunadamente, no teníamos implementaciones listas para usar a nuestra disposición y no escribimos las nuestras (pero definitivamente las escribiremos).

5. Parametrización de scripts

Para mayor comodidad, todo el código para iniciar el entrenamiento se diseñó como un único script, parametrizado mediante docoptar следующим обрахом:

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)

Paquete docoptar representa la implementación http://docopt.org/ para R. Con su ayuda, los scripts se inician con comandos simples como Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db o ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, si archivo train_nn.R es ejecutable (este comando comenzará a entrenar el modelo resnet50 en imágenes tricolores de 128x128 píxeles, la base de datos debe estar ubicada en la carpeta /home/andrey/doodle_db). Puede agregar velocidad de aprendizaje, tipo de optimizador y cualquier otro parámetro personalizable a la lista. En el proceso de preparación de la publicación, resultó que la arquitectura mobilenet_v2 de la versión actual keras en uso R no debe debido a cambios no tomados en cuenta en el paquete R, estamos esperando que lo solucionen.

Este enfoque hizo posible acelerar significativamente los experimentos con diferentes modelos en comparación con el lanzamiento más tradicional de scripts en RStudio (notamos el paquete como una posible alternativa tfruns). Pero la principal ventaja es la capacidad de gestionar fácilmente el lanzamiento de scripts en Docker o simplemente en el servidor, sin instalar RStudio para ello.

6. Dockerización de scripts

Usamos Docker para garantizar la portabilidad del entorno para modelos de capacitación entre miembros del equipo y para una implementación rápida en la nube. Puedes empezar a familiarizarte con esta herramienta, que es relativamente inusual para un programador de R, con este serie de publicaciones o curso en vídeo.

Docker le permite crear sus propias imágenes desde cero y utilizar otras imágenes como base para crear las suyas propias. Al analizar las opciones disponibles, llegamos a la conclusión de que instalar NVIDIA, los controladores CUDA+cuDNN y las bibliotecas de Python es una parte bastante voluminosa de la imagen, y decidimos tomar como base la imagen oficial. tensorflow/tensorflow:1.12.0-gpu, agregando allí los paquetes R necesarios.

El archivo acoplable final tenía este aspecto:

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

Por conveniencia, los paquetes utilizados se colocaron en variables; la mayor parte de los guiones escritos se copian dentro de los contenedores durante el montaje. También cambiamos el shell de comandos a /bin/bash para facilitar el uso del contenido /etc/os-release. Esto evitó la necesidad de especificar la versión del sistema operativo en el código.

Además, se escribió un pequeño script bash que le permite iniciar un contenedor con varios comandos. Por ejemplo, estos podrían ser scripts para entrenar redes neuronales que se colocaron previamente dentro del contenedor, o un shell de comandos para depurar y monitorear el funcionamiento del contenedor:

Script para lanzar el contenedor

#!/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 este script bash se ejecuta sin parámetros, el script se llamará dentro del contenedor train_nn.R con valores predeterminados; Si el primer argumento posicional es "bash", entonces el contenedor se iniciará interactivamente con un shell de comandos. En todos los demás casos, se sustituyen los valores de los argumentos posicionales: CMD="Rscript /app/train_nn.R $@".

Vale la pena señalar que los directorios con los datos de origen y la base de datos, así como el directorio para guardar los modelos entrenados, están montados dentro del contenedor del sistema host, lo que le permite acceder a los resultados de los scripts sin manipulaciones innecesarias.

7. Usar múltiples GPU en Google Cloud

Una de las características de la competencia fueron los datos muy ruidosos (ver la imagen del título, tomada de @Leigh.plt de ODS slack). Los lotes grandes ayudan a combatir esto y, después de experimentar en una PC con 1 GPU, decidimos dominar los modelos de entrenamiento en varias GPU en la nube. GoogleCloud usado (buena guía de lo básico) debido a la gran selección de configuraciones disponibles, precios razonables y un bono de $300. Por codicia, pedí una instancia 4xV100 con un SSD y una tonelada de RAM, y eso fue un gran error. Una máquina así consume dinero rápidamente; uno puede arruinarse experimentando sin una tubería probada. Para fines educativos, es mejor tomar el K80. Pero la gran cantidad de RAM fue útil: el SSD en la nube no impresionó con su rendimiento, por lo que la base de datos se transfirió a dev/shm.

De mayor interés es el fragmento de código responsable del uso de múltiples GPU. Primero, el modelo se crea en la CPU usando un administrador de contexto, como 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
  )
})

Luego, el modelo no compilado (esto es importante) se copia a una cantidad determinada de GPU disponibles, y solo después se compila:

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

No se pudo implementar la técnica clásica de congelar todas las capas excepto la última, entrenar la última capa, descongelar y volver a entrenar todo el modelo para varias GPU.

El entrenamiento fue monitoreado sin uso. tablero de tensor, limitándonos a grabar logs y guardar modelos con nombres informativos después de cada época:

Devoluciones de llamada

# Шаблон имени файла лога
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. En lugar de una conclusión

Una serie de problemas que hemos encontrado aún no se han solucionado:

  • в keras no existe una función lista para usar para buscar automáticamente la tasa de aprendizaje óptima (analógica lr_finder en la biblioteca rápido.ai); Con algo de esfuerzo, es posible portar implementaciones de terceros a R, por ejemplo, este;
  • como consecuencia del punto anterior, no era posible seleccionar la velocidad de entrenamiento correcta al utilizar varias GPU;
  • faltan arquitecturas de redes neuronales modernas, especialmente aquellas previamente entrenadas en imagenet;
  • política de ningún ciclo y tasas de aprendizaje discriminativas (el recocido de coseno fue a petición nuestra) implementado, Gracias skeydan).

Qué cosas útiles se aprendieron de esta competencia:

  • En hardware de consumo relativamente bajo, puede trabajar con volúmenes de datos decentes (muchas veces el tamaño de la RAM) sin problemas. Bolsa de plastico tabla de datos ahorra memoria debido a la modificación de tablas en el lugar, lo que evita copiarlas y, cuando se usa correctamente, sus capacidades casi siempre demuestran la velocidad más alta entre todas las herramientas que conocemos para lenguajes de scripting. Guardar datos en una base de datos le permite, en muchos casos, no pensar en absoluto en la necesidad de comprimir todo el conjunto de datos en la RAM.
  • Las funciones lentas en R se pueden reemplazar por funciones rápidas en C++ usando el paquete rcpp. Si además de usar hilo rcpp o RcppParalelo, obtenemos implementaciones multiproceso multiplataforma, por lo que no es necesario paralelizar el código en el nivel R.
  • Por paquete rcpp se puede utilizar sin conocimientos serios de C++, se describe el mínimo requerido aquí. Archivos de encabezado para varias bibliotecas C interesantes como xtensor disponible en CRAN, es decir, se está formando una infraestructura para la implementación de proyectos que integran código C++ de alto rendimiento ya preparado en R. Una conveniencia adicional es el resaltado de sintaxis y un analizador de código C++ estático en RStudio.
  • docoptar le permite ejecutar scripts autónomos con parámetros. Esto es conveniente para usar en un servidor remoto, incl. debajo de la ventana acoplable. En RStudio, es inconveniente realizar muchas horas de experimentos con el entrenamiento de redes neuronales y la instalación del IDE en el servidor no siempre está justificada.
  • Docker garantiza la portabilidad del código y la reproducibilidad de los resultados entre desarrolladores con diferentes versiones del sistema operativo y bibliotecas, así como la facilidad de ejecución en los servidores. Puede iniciar todo el proceso de capacitación con un solo comando.
  • Google Cloud es una forma económica de experimentar con hardware costoso, pero es necesario elegir las configuraciones con cuidado.
  • Medir la velocidad de fragmentos de código individuales es muy útil, especialmente cuando se combinan R y C++, y con el paquete banco - también muy fácil.

En general, esta experiencia fue muy gratificante y seguimos trabajando para resolver algunas de las cuestiones planteadas.

Fuente: habr.com

Añadir un comentario