Recoñecemento rápido de Doodle: como facer amigos con R, C++ e redes neuronais

Recoñecemento rápido de Doodle: como facer amigos con R, C++ e redes neuronais

Ola Habr!

O pasado outono, Kaggle organizou un concurso para clasificar imaxes debuxadas a man, Quick Draw Doodle Recognition, no que, entre outros, participou un equipo de científicos R: Artem Klevtsova, Gerente Philippa и Andrei Ogurtsov. Non imos describir a competición en detalle; iso xa se fixo publicación recente.

Esta vez non funcionou co cultivo de medallas, pero gañou moita experiencia valiosa, polo que gustaríame contarlle á comunidade unha serie de cousas máis interesantes e útiles en Kagle e no traballo diario. Entre os temas tratados: vida difícil sen OpenCV, análise JSON (estes exemplos examinan a integración do código C++ en scripts ou paquetes en R usando Rcpp), parametrización de scripts e dockerización da solución final. Todo o código da mensaxe nun formulario axeitado para a execución está dispoñible en repositorios.

Contido:

  1. Carga de forma eficiente os datos de CSV en MonetDB
  2. Preparación de lotes
  3. Iteradores para descargar lotes da base de datos
  4. Selección dun modelo de arquitectura
  5. Parametrización de scripts
  6. Dockerización de scripts
  7. Usando varias GPU en Google Cloud
  8. En vez de unha conclusión

1. Cargue de forma eficiente os datos de CSV na base de datos de MonetDB

Os datos deste concurso non se ofrecen en forma de imaxes preparadas, senón en forma de ficheiros CSV de 340 (un ficheiro para cada clase) que conteñen JSON con coordenadas de puntos. Ao conectar estes puntos con liñas, obtemos unha imaxe final que mide 256 x 256 píxeles. Ademais, para cada rexistro hai unha etiqueta que indica se a imaxe foi recoñecida correctamente polo clasificador utilizado no momento en que se recolleu o conxunto de datos, un código de dúas letras do país de residencia do autor da imaxe, un identificador único, unha marca de tempo e un nome de clase que coincida co nome do ficheiro. Unha versión simplificada dos datos orixinais pesa 7.4 GB no arquivo e aproximadamente 20 GB despois de desempaquetar, os datos completos despois de desempaquetar ocupan 240 GB. Os organizadores aseguraron que ambas versións reproducían os mesmos debuxos, o que significa que a versión completa era redundante. En calquera caso, o almacenamento de 50 millóns de imaxes en ficheiros gráficos ou en forma de matrices foi inmediatamente considerado non rendible e decidimos combinar todos os ficheiros CSV do arquivo. tren_simplificado.zip na base de datos coa xeración posterior de imaxes do tamaño necesario "sobre a marcha" para cada lote.

Elixiuse un sistema ben probado como DBMS MonetDB, é dicir, unha implementación para R como paquete MonetDBLite. O paquete inclúe unha versión integrada do servidor de base de datos e permítelle coller o servidor directamente dunha sesión R e traballar con el. A creación dunha base de datos e a conexión a ela realízanse cun só comando:

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

Teremos que crear dúas táboas: unha para todos os datos, a outra para a información do servizo sobre os ficheiros descargados (útil se algo sae mal e hai que retomar o proceso despois de descargar varios ficheiros):

Creación de táboas

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

A forma máis rápida de cargar datos na base de datos era copiar directamente ficheiros CSV usando o comando SQL COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTonde tablename - nome da táboa e path - o camiño ao ficheiro. Mentres traballaba co arquivo, descubriuse que a implementación integrada unzip en R non funciona correctamente con varios ficheiros do arquivo, polo que usamos o sistema unzip (usando o parámetro getOption("unzip")).

Función para escribir na 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))
}

Se precisa transformar a táboa antes de escribila na base de datos, abonda con pasar o argumento preprocess función que transformará os datos.

Código para cargar datos secuencialmente na base de datos:

Escribir datos na 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

O tempo de carga de datos pode variar dependendo das características de velocidade da unidade utilizada. No noso caso, ler e escribir nun SSD ou desde unha unidade flash (ficheiro fonte) a un SSD (DB) leva menos de 10 minutos.

Leva uns segundos máis crear unha columna cunha etiqueta de clase enteira e unha columna de índice (ORDERED INDEX) con números de liña polos que se mostrarán as observacións ao crear lotes:

Creación de columnas e índices adicionais

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 o problema de crear un lote sobre a marcha, necesitabamos acadar a máxima velocidade de extracción de filas aleatorias da táboa doodles. Para iso utilizamos 3 trucos. O primeiro foi reducir a dimensionalidade do tipo que almacena o ID de observación. No conxunto de datos orixinal, o tipo necesario para almacenar o ID é bigint, pero o número de observacións permite axustar os seus identificadores, iguais ao número ordinal, no tipo int. A busca é moito máis rápida neste caso. O segundo truco foi usar ORDERED INDEX — chegamos a esta decisión empíricamente, despois de analizar todos os dispoñibles opcións. O terceiro foi utilizar consultas parametrizadas. A esencia do método é executar o comando unha vez PREPARE co uso posterior dunha expresión preparada ao crear un grupo de consultas do mesmo tipo, pero de feito hai unha vantaxe en comparación cunha sinxela SELECT resultou estar dentro do intervalo de erro estatístico.

O proceso de carga de datos non consome máis de 450 MB de RAM. É dicir, o enfoque descrito permítelle mover conxuntos de datos que pesan decenas de gigabytes en case calquera hardware económico, incluídos algúns dispositivos de placa única, o que é bastante xenial.

Só resta medir a velocidade de recuperación de datos (aleatorios) e avaliar o escalado cando se mostren lotes de diferentes tamaños:

Base de datos de referencia

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)

Recoñecemento rápido de Doodle: como facer amigos con R, C++ e redes neuronais

2. Preparación de lotes

Todo o proceso de preparación do lote consta dos seguintes pasos:

  1. Analizando varios JSON que conteñan vectores de cadeas con coordenadas de puntos.
  2. Debuxar liñas de cores en función das coordenadas dos puntos nunha imaxe do tamaño necesario (por exemplo, 256×256 ou 128×128).
  3. Converter as imaxes resultantes nun tensor.

Como parte da competencia entre os núcleos de Python, o problema resolveuse principalmente usando OpenCV. Un dos análogos máis simples e obvios en R sería así:

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

O debuxo realízase usando ferramentas R estándar e gárdase nun PNG temporal almacenado na RAM (en Linux, os directorios R temporais están situados no directorio /tmp, montado en RAM). Este ficheiro é entón lido como unha matriz tridimensional con números que van de 0 a 1. Isto é importante porque un BMP máis convencional sería lido nunha matriz en bruto con códigos de cores hexadecimales.

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

Recoñecemento rápido de Doodle: como facer amigos con R, C++ e redes neuronais

O propio lote formarase do seguinte xeito:

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 pareceunos subóptima, xa que a formación de grandes lotes leva un tempo indecentemente longo, e decidimos aproveitar a experiencia dos nosos compañeiros empregando unha potente biblioteca. OpenCV. Nese momento non había ningún paquete preparado para R (agora non hai ningún), polo que se escribiu unha implementación mínima da funcionalidade necesaria en C++ con integración no código R usando Rcpp.

Para resolver o problema utilizáronse os seguintes paquetes e bibliotecas:

  1. OpenCV para traballar con imaxes e trazar liñas. Utilizáronse bibliotecas de sistema preinstaladas e ficheiros de cabeceira, así como ligazóns dinámicas.

  2. xtensor para traballar con matrices e tensores multidimensionais. Usamos ficheiros de cabeceira incluídos no paquete R do mesmo nome. A biblioteca permítelle traballar con matrices multidimensionais, tanto na orde principal das filas como das columnas.

  3. ndjson para analizar JSON. Esta biblioteca úsase en xtensor automaticamente se está presente no proxecto.

  4. RcppThread para organizar o procesamento multiproceso dun vector desde JSON. Utilizou os ficheiros de cabeceira proporcionados por este paquete. De máis popular RcppParallel O paquete, entre outras cousas, ten un mecanismo de interrupción de bucle incorporado.

Debe notarse que xtensor resultou ser unha bendición do Deus: ademais de ter unha ampla funcionalidade e un alto rendemento, os seus desenvolvedores resultaron ser bastante sensibles e responderon ás preguntas con prontitude e detalle. Coa súa axuda, foi posible implementar transformacións de matrices OpenCV en tensores xtensores, así como unha forma de combinar tensores de imaxe tridimensionais nun tensor de 3 dimensións da dimensión correcta (o propio lote).

Materiais para aprender Rcpp, xtensor e 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 ficheiros que usan ficheiros do sistema e ligazóns dinámicas coas bibliotecas instaladas no sistema, usamos o mecanismo de complementos implementado no paquete Rcpp. Para atopar automaticamente camiños e bandeiras, usamos unha popular utilidade de Linux pkg-config.

Implementación do complemento Rcpp para o uso da 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 da operación do complemento, substituiranse os seguintes valores durante o 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"

O código de implementación para analizar JSON e xerar un lote para a súa transmisión ao modelo aparece baixo o spoiler. Primeiro, engade un directorio de proxecto local para buscar ficheiros de cabeceira (necesario 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 no ficheiro src/cv_xt.cpp e compilar co comando Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); tamén necesario para traballar nlohmann/json.hpp de repositorio. O código divídese en varias funcións:

  • to_xt — unha función modelo para transformar unha matriz de imaxe (cv::Mat) a un tensor xt::xtensor;

  • parse_json — a función analiza unha cadea JSON, extrae as coordenadas dos puntos, empaquetándoas nun vector;

  • ocv_draw_lines — a partir do vector de puntos resultante, debuxa liñas multicolores;

  • process — combina as funcións anteriores e tamén engade a capacidade de escalar a imaxe resultante;

  • cpp_process_json_str - envoltorio sobre a función process, que exporta o resultado a un obxecto R (matriz multidimensional);

  • cpp_process_json_vector - envoltorio sobre a función cpp_process_json_str, que permite procesar un vector de cadea en modo multiproceso.

Para debuxar liñas multicolores, utilizouse o modelo de cor HSV, seguido da conversión a RGB. Probamos o resultado:

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

Recoñecemento rápido de Doodle: como facer amigos con R, C++ e redes neuronais
Comparación da velocidade de implementacións en R e 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") 

Recoñecemento rápido de Doodle: como facer amigos con R, C++ e redes neuronais

Como podes ver, o aumento da velocidade resultou ser moi significativo e non é posible poñerse ao día co código C++ paralelizando o código R.

3. Iteradores para descargar lotes da base de datos

R ten unha merecida reputación de procesar datos que encaixan na memoria RAM, mentres que Python caracterízase máis polo procesamento iterativo de datos, o que lle permite implementar de xeito sinxelo e natural cálculos fóra do núcleo (cálculos mediante memoria externa). Un exemplo clásico e relevante para nós no contexto do problema descrito son as redes neuronais profundas adestradas polo método de descenso de gradientes coa aproximación do gradiente en cada paso utilizando unha pequena parte de observacións ou mini-lote.

Os marcos de aprendizaxe profunda escritos en Python teñen clases especiais que implementan iteradores baseados en datos: táboas, imaxes en cartafoles, formatos binarios, etc. Podes usar opcións preparadas ou escribir as túas propias para tarefas específicas. En R podemos aproveitar todas as funcións da biblioteca de Python keras cos seus distintos backends usando o paquete do mesmo nome, que á súa vez funciona enriba do paquete reticular. Este último merece un artigo longo aparte; non só permite executar código Python desde R, senón que tamén permite transferir obxectos entre sesións R e Python, realizando automaticamente todas as conversións de tipos necesarias.

Desfixémonos da necesidade de almacenar todos os datos na RAM usando MonetDBlite, todo o traballo de "rede neuronal" realizarase polo código orixinal en Python, só temos que escribir un iterador sobre os datos, xa que non hai nada preparado. para tal situación en R ou Python. Esencialmente só hai dous requisitos para iso: debe devolver lotes nun bucle interminable e gardar o seu estado entre iteracións (este último en R implícase da forma máis sinxela mediante peches). Anteriormente, era necesario converter explícitamente matrices R en matrices numpy dentro do iterador, pero a versión actual do paquete keras faino ela mesma.

O iterador de datos de adestramento e validación resultou ser o seguinte:

Iterador de datos de formación e 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)
  }
}

A función toma como entrada unha variable cunha conexión á base de datos, o número de liñas empregadas, o número de clases, o tamaño do lote, a escala (scale = 1 corresponde a renderizar imaxes de 256 x 256 píxeles, scale = 0.5 — 128x128 píxeles), indicador de cor (color = FALSE especifica a representación en escala de grises cando se usa color = TRUE cada trazo está debuxado nunha nova cor) e un indicador de preprocesamento para redes adestradas previamente en imagenet. Este último é necesario para escalar os valores de píxeles desde o intervalo [0, 1] ata o intervalo [-1, 1], que se utilizou ao adestrar o proporcionado. keras modelos.

A función externa contén a comprobación do tipo de argumento, unha táboa data.table con números de liña mesturados aleatoriamente de samples_index e números de lote, contador e número máximo de lotes, así como unha expresión SQL para descargar datos da base de datos. Ademais, definimos un análogo rápido da función dentro keras::to_categorical(). Usamos case todos os datos para o adestramento, deixando medio por cento para a validación, polo que o tamaño da época estaba limitado polo parámetro steps_per_epoch cando se chama keras::fit_generator(), e a condición if (i > max_i) só funcionou para o iterador de validación.

Na función interna, os índices de filas son recuperados para o seguinte lote, os rexistros son descargados da base de datos co contador de lote aumentando, análise JSON (función cpp_process_json_vector(), escrito en C++) e creando matrices correspondentes a imaxes. Despois créanse vectores únicos con etiquetas de clase, as matrices con valores de píxeles e etiquetas combínanse nunha lista, que é o valor de retorno. Para axilizar o traballo, utilizamos a creación de índices en táboas data.table e modificación a través da ligazón - sen estes paquetes "chips" datos.táboa É bastante difícil imaxinar traballar de forma eficaz con calquera cantidade significativa de datos en R.

Os resultados das medicións de velocidade nun portátil Core i5 son os seguintes:

Punto de referencia do 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)

Recoñecemento rápido de Doodle: como facer amigos con R, C++ e redes neuronais

Se tes unha cantidade suficiente de RAM, podes acelerar seriamente o funcionamento da base de datos transfiríndoa a esta mesma RAM (32 GB son suficientes para a nosa tarefa). En Linux, a partición está montada por defecto /dev/shm, ocupando ata a metade da capacidade da memoria RAM. Podes destacar máis editando /etc/fstabpara conseguir un disco como tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Asegúrese de reiniciar e comprobar o resultado executando o comando df -h.

O iterador de datos de proba parece moito máis sinxelo, xa que o conxunto de datos de proba encaixa completamente na RAM:

Iterador de datos de proba

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 da arquitectura do modelo

A primeira arquitectura utilizada foi mobilenet v1, cuxas características se comentan en isto mensaxe. Está incluído como estándar keras e, en consecuencia, está dispoñible no paquete do mesmo nome para R. Pero ao tentar usalo con imaxes dunha soa canle, resultou unha cousa estraña: o tensor de entrada sempre debe ter a dimensión (batch, height, width, 3), é dicir, non se pode cambiar o número de canles. Non hai tal limitación en Python, polo que apresuramos e escribimos a nosa propia implementación desta arquitectura, seguindo o artigo orixinal (sen o abandono que hai na 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)
}

As desvantaxes deste enfoque son obvias. Quero probar moitos modelos, pero, pola contra, non quero reescribir cada arquitectura manualmente. Tamén nos privou da posibilidade de utilizar os pesos de modelos previamente adestrados en imagenet. Como é habitual, o estudo da documentación axudou. Función get_config() permítelle obter unha descrición do modelo nun formulario axeitado para editar (base_model_conf$layers - unha lista R normal), e a función from_config() realiza a conversión inversa a un obxecto 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)

Agora non é difícil escribir unha función universal para obter calquera das subministradas keras modelos con ou sen pesas adestrados en imagenet:

Función para cargar arquitecturas preparadas

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

Cando se usan imaxes dunha soa canle, non se usan pesos adestrados previamente. Isto podería solucionarse: usando a función get_weights() obtén os pesos do modelo en forma de lista de matrices R, cambia a dimensión do primeiro elemento desta lista (collendo unha canle de cor ou facendo unha media dos tres) e despois carga os pesos de novo no modelo coa función set_weights(). Nunca engadimos esta funcionalidade, porque nesta fase xa estaba claro que era máis produtivo traballar con imaxes en cor.

Realizamos a maioría dos experimentos utilizando as versións 1 e 2 de mobilenet, así como resnet34. Arquitecturas máis modernas como SE-ResNeXt tiveron un bo desempeño nesta competición. Desafortunadamente, non tiñamos implementacións preparadas á nosa disposición e non escribimos as nosas propias (pero definitivamente escribiremos).

5. Parametrización de scripts

Por comodidade, todo o código para comezar a formación foi deseñado como un único script, parametrizado usando docopt do seguinte xeito:

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 docopt representa a implementación http://docopt.org/ para R. Coa súa axuda, lánzanse scripts con comandos sinxelos como 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, se arquivo train_nn.R é executable (este comando comezará a adestrar o modelo resnet50 en imaxes de tres cores de 128x128 píxeles, a base de datos debe estar situada no cartafol /home/andrey/doodle_db). Podes engadir a velocidade de aprendizaxe, o tipo de optimizador e calquera outro parámetro personalizable á lista. No proceso de preparación da publicación, resultou que a arquitectura mobilenet_v2 a partir da versión actual keras en uso R non pode debido a cambios non tidos en conta no paquete R, estamos á espera de que o solucionen.

Este enfoque permitiu acelerar significativamente os experimentos con diferentes modelos en comparación co lanzamento máis tradicional de scripts en RStudio (notamos o paquete como unha posible alternativa tfruns). Pero a principal vantaxe é a posibilidade de xestionar facilmente o lanzamento de scripts en Docker ou simplemente no servidor, sen instalar RStudio para iso.

6. Dockerización de guións

Usamos Docker para garantir a portabilidade do ambiente para os modelos de adestramento entre membros do equipo e para unha rápida implantación na nube. Podes comezar a familiarizarte con esta ferramenta, que é relativamente inusual para un programador R isto serie de publicacións ou video curso.

Docker permítelle crear as súas propias imaxes desde cero e usar outras imaxes como base para crear as súas propias. Ao analizar as opcións dispoñibles, chegamos á conclusión de que a instalación de controladores NVIDIA, CUDA+cuDNN e bibliotecas Python é unha parte bastante voluminosa da imaxe, e decidimos tomar a imaxe oficial como base. tensorflow/tensorflow:1.12.0-gpu, engadindo alí os paquetes R necesarios.

O ficheiro docker final tiñ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 comodidade, os paquetes utilizados foron colocados en variables; o groso dos guións escritos cópiase dentro dos contedores durante a montaxe. Tamén cambiamos o shell de comandos a /bin/bash para facilitar o uso do contido /etc/os-release. Isto evitou a necesidade de especificar a versión do SO no código.

Ademais, escribiuse un pequeno script bash que che permite lanzar un contedor con varios comandos. Por exemplo, estes poden ser scripts para adestrar redes neuronais que se colocaron previamente dentro do contedor ou un shell de comandos para depurar e supervisar o funcionamento do contedor:

Script para iniciar o contedor

#!/bin/sh

DBDIR=${PWD}/db
LOGSDIR=${PWD}/logs
MODELDIR=${PWD}/models
DATADIR=${PWD}/data
ARGS="--runtime=nvidia --rm -v ${DBDIR}:/db -v ${LOGSDIR}:/app/logs -v ${MODELDIR}:/app/models -v ${DATADIR}:/app/data"

if [ -z "$1" ]; then
    CMD="Rscript /app/train_nn.R"
elif [ "$1" = "bash" ]; then
    ARGS="${ARGS} -ti"
else
    CMD="Rscript /app/train_nn.R $@"
fi

docker run ${ARGS} doodles-tf ${CMD}

Se este script bash se executa sen parámetros, chamarase ao script dentro do contedor train_nn.R con valores predeterminados; se o primeiro argumento posicional é "bash", entón o contedor comezará de forma interactiva cun shell de comandos. En todos os demais casos, substitúense os valores dos argumentos posicionais: CMD="Rscript /app/train_nn.R $@".

Cabe destacar que os directorios con datos de orixe e base de datos, así como o directorio para gardar modelos adestrados, están montados dentro do contedor desde o sistema host, o que permite acceder aos resultados dos scripts sen manipulacións innecesarias.

7. Usando varias GPU en Google Cloud

Unha das características da competición eran os datos moi ruidosos (ver a imaxe do título, tomada de @Leigh.plt de ODS slack). Grandes lotes axudan a combater isto e, despois de realizar experimentos nun PC con 1 GPU, decidimos dominar modelos de adestramento en varias GPU na nube. GoogleCloud usado (boa guía para o básico) debido á gran selección de configuracións dispoñibles, prezos razoables e bonificación de 300 dólares. Por avaricia, pedín unha instancia 4xV100 cun SSD e unha tonelada de RAM, e iso foi un gran erro. Tal máquina come diñeiro rapidamente; podes ir experimentando sen unha canalización comprobada. Para fins educativos, é mellor levar o K80. Pero a gran cantidade de RAM foi útil: o SSD na nube non impresionou co seu rendemento, polo que a base de datos foi transferida a dev/shm.

De maior interese é o fragmento de código responsable do uso de varias GPU. En primeiro lugar, o modelo créase na CPU usando un xestor 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
  )
})

A continuación, o modelo sen compilar (isto é importante) cópiase nun número determinado de GPU dispoñibles e só despois compílase:

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

Non se puido implementar a técnica clásica de conxelar todas as capas excepto a última, adestrar a última capa, desconxelar e reciclar todo o modelo para varias GPU.

Monitorizouse o adestramento sen uso. placa tensor, limitándonos a gravar rexistros e gardar modelos con nomes informativos despois de cada época:

Devolucións de chamada

# Шаблон имени файла лога
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. No canto dunha conclusión

Unha serie de problemas que atopamos aínda non foron superados:

  • в keras non hai ningunha función preparada para buscar automaticamente a taxa de aprendizaxe óptima (analóxica lr_finder na biblioteca rápido.ai); Con certo esforzo, é posible portar implementacións de terceiros a R, por exemplo, isto;
  • como consecuencia do punto anterior, non foi posible seleccionar a velocidade de adestramento correcta ao utilizar varias GPU;
  • faltan arquitecturas de redes neuronais modernas, especialmente aquelas previamente adestradas en imagenet;
  • política de ciclo único e taxas de aprendizaxe discriminativas (o recocido de coseno foi a nosa solicitude implementado, grazas skeydan).

Que cousas útiles se aprenderon deste concurso:

  • En hardware de baixo consumo, pode traballar con volumes de datos decentes (moitas veces o tamaño da RAM) sen dor. Bolsa de plástico datos.táboa aforra memoria debido á modificación in situ das táboas, o que evita copialas, e cando se usan correctamente, as súas capacidades case sempre demostran a maior velocidade entre todas as ferramentas que coñecemos para linguaxes de script. Gardar datos nunha base de datos permítelle, en moitos casos, non pensar en absoluto na necesidade de espremer todo o conxunto de datos na memoria RAM.
  • As funcións lentas en R pódense substituír por outras rápidas en C++ usando o paquete Rcpp. Se ademais de usar RcppThread ou RcppParallel, obtemos implementacións multiplataforma multiproceso, polo que non hai necesidade de paralelizar o código no nivel R.
  • Paquete Rcpp pode usarse sen coñecementos serios de C++, descríbese o mínimo necesario aquí. Ficheiros de cabeceira para unha serie de bibliotecas C interesantes como xtensor dispoñible en CRAN, é dicir, estase formando unha infraestrutura para a implementación de proxectos que integren código C++ de alto rendemento preparado en R. A conveniencia adicional é o resaltado de sintaxe e un analizador de código C++ estático en RStudio.
  • docopt permítelle executar scripts autónomos con parámetros. Isto é conveniente para usar nun servidor remoto, incl. baixo docker. En RStudio, é inconveniente realizar moitas horas de experimentos coa formación de redes neuronais, e non sempre se xustifica a instalación do IDE no propio servidor.
  • Docker garante a portabilidade do código e a reproducibilidade dos resultados entre desenvolvedores con diferentes versións do SO e bibliotecas, así como a facilidade de execución nos servidores. Podes iniciar todo o proceso de adestramento cun só comando.
  • Google Cloud é un xeito económico de experimentar con hardware caro, pero cómpre escoller as configuracións con coidado.
  • Medir a velocidade de fragmentos de código individuais é moi útil, especialmente cando se combinan R e C++, e co paquete base - tamén moi fácil.

En xeral esta experiencia foi moi gratificante e seguimos traballando para resolver algunhas das cuestións plantexadas.

Fonte: www.habr.com

Engadir un comentario