Quick Draw Doodle Recognition: como fazer amizade com R, C++ e redes neurais

Quick Draw Doodle Recognition: como fazer amizade com R, C++ e redes neurais

Oi, Habr!

No outono passado, Kaggle organizou uma competição para classificar imagens desenhadas à mão, Quick Draw Doodle Recognition, na qual, entre outros, participou uma equipe de cientistas R: Artem Klevtsov, Gerente Filipa и Andrey Ogurtsov. Não descreveremos a competição em detalhes; isso já foi feito em publicação recente.

Desta vez não deu certo com o cultivo de medalhas, mas muita experiência valiosa foi adquirida, então gostaria de contar à comunidade sobre uma série de coisas mais interessantes e úteis no Kagle e no trabalho diário. Entre os temas discutidos: vida difícil sem OpenCV, análise JSON (estes exemplos examinam a integração do código C++ em scripts ou pacotes em R usando RCPP), parametrização de scripts e dockerização da solução final. Todo o código da mensagem em um formato adequado para execução está disponível em repositórios.

Conteúdo:

  1. Carregue dados CSV com eficiência no MonetDB
  2. Preparando lotes
  3. Iteradores para descarregar lotes do banco de dados
  4. Selecionando uma arquitetura de modelo
  5. Parametrização de script
  6. Dockerização de scripts
  7. Usando várias GPUs no Google Cloud
  8. Em vez de uma conclusão

1. Carregar dados CSV com eficiência no banco de dados MonetDB

Os dados desta competição são fornecidos não na forma de imagens prontas, mas na forma de 340 arquivos CSV (um arquivo para cada classe) contendo JSONs com coordenadas de pontos. Ao conectar esses pontos com linhas, obtemos uma imagem final medindo 256x256 pixels. Além disso, para cada registro há uma etiqueta indicando se a imagem foi corretamente reconhecida pelo classificador utilizado no momento da coleta do conjunto de dados, um código de duas letras do país de residência do autor da imagem, um identificador único, um carimbo de data/hora e um nome de classe que corresponda ao nome do arquivo. A versão simplificada dos dados originais pesa 7.4 GB no arquivo e aproximadamente 20 GB após a descompactação, os dados completos após a descompactação ocupam 240 GB. Os organizadores garantiram que ambas as versões reproduzissem os mesmos desenhos, o que significa que a versão completa era redundante. De qualquer forma, armazenar 50 milhões de imagens em arquivos gráficos ou na forma de arrays foi imediatamente considerado não lucrativo, e decidimos mesclar todos os arquivos CSV do arquivo trem_simplificado.zip no banco de dados com posterior geração de imagens do tamanho necessário “on the fly” para cada lote.

Um sistema comprovado foi escolhido como SGBD MonetDB, ou seja, uma implementação para R como um pacote MonetDBLite. O pacote inclui uma versão incorporada do servidor de banco de dados e permite que você pegue o servidor diretamente de uma sessão R e trabalhe com ele lá. A criação de um banco de dados e a conexão a ele são realizadas com um comando:

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

Precisaremos criar duas tabelas: uma para todos os dados e outra para informações de serviço sobre os arquivos baixados (útil se algo der errado e o processo precisar ser retomado após o download de vários arquivos):

Criando tabelas

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 maneira mais rápida de carregar dados no banco de dados era copiar diretamente os arquivos CSV usando o comando SQL COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTOnde tablename - nome da tabela e path - o caminho para o arquivo. Ao trabalhar com o arquivo, descobriu-se que a implementação integrada unzip em R não funciona corretamente com vários arquivos do arquivo, então usamos o sistema unzip (usando o parâmetro getOption("unzip")).

Função para escrever no banco de dados

#' @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 precisar transformar a tabela antes de gravá-la no banco de dados, basta passar o argumento preprocess função que transformará os dados.

Código para carregar dados sequencialmente no banco de dados:

Gravando dados no banco de dados

# Список файлов для записи
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 carregamento dos dados pode variar dependendo das características de velocidade do drive utilizado. No nosso caso, ler e gravar em um SSD ou de uma unidade flash (arquivo de origem) em um SSD (DB) leva menos de 10 minutos.

Demora mais alguns segundos para criar uma coluna com um rótulo de classe inteira e uma coluna de índice (ORDERED INDEX) com números de linha pelos quais as observações serão amostradas ao criar lotes:

Criação de colunas 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 criação de um lote dinamicamente, precisávamos atingir a velocidade máxima de extração de linhas aleatórias da tabela doodles. Para isso usamos 3 truques. A primeira foi reduzir a dimensionalidade do tipo que armazena o ID da observação. No conjunto de dados original, o tipo necessário para armazenar o ID é bigint, mas o número de observações permite encaixar seus identificadores, iguais ao número ordinal, no tipo int. A busca é muito mais rápida neste caso. O segundo truque foi usar ORDERED INDEX - chegamos a esta decisão empiricamente, tendo passado por todos os disponíveis opções. A terceira foi usar consultas parametrizadas. A essência do método é executar o comando uma vez PREPARE com posterior uso de uma expressão preparada ao criar várias consultas do mesmo tipo, mas na verdade há uma vantagem em comparação com uma simples SELECT acabou por estar dentro da faixa de erro estatístico.

O processo de upload de dados não consome mais de 450 MB de RAM. Ou seja, a abordagem descrita permite mover conjuntos de dados pesando dezenas de gigabytes em quase qualquer hardware de orçamento, incluindo alguns dispositivos de placa única, o que é muito legal.

Tudo o que resta é medir a velocidade de recuperação de dados (aleatórios) e avaliar o dimensionamento ao amostrar lotes de tamanhos diferentes:

Referência de banco de dados

library(ggplot2)

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

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

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

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

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

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

DBI::dbDisconnect(con, shutdown = TRUE)

Quick Draw Doodle Recognition: como fazer amizade com R, C++ e redes neurais

2. Preparando lotes

Todo o processo de preparação do lote consiste nas seguintes etapas:

  1. Analisando vários JSONs contendo vetores de strings com coordenadas de pontos.
  2. Desenhar linhas coloridas com base nas coordenadas dos pontos em uma imagem do tamanho necessário (por exemplo, 256×256 ou 128×128).
  3. Convertendo as imagens resultantes em um tensor.

Como parte da competição entre os kernels Python, o problema foi resolvido principalmente usando OpenCV. Um dos análogos mais simples e óbvios em R seria assim:

Implementando conversão de JSON para tensor em 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 desenho é executado usando ferramentas R padrão e salvo em um PNG temporário armazenado na RAM (no Linux, os diretórios R temporários estão localizados no diretório /tmp, montado na RAM). Este arquivo é então lido como uma matriz tridimensional com números variando de 0 a 1. Isso é importante porque um BMP mais convencional seria lido em uma matriz bruta com códigos de cores hexadecimais.

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

Quick Draw Doodle Recognition: como fazer amizade com R, C++ e redes neurais

O lote em si será formado da seguinte forma:

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 implementação nos pareceu abaixo do ideal, já que a formação de grandes lotes leva um tempo indecentemente longo, e decidimos aproveitar a experiência de nossos colegas usando uma poderosa biblioteca OpenCV. Naquela época não havia nenhum pacote pronto para R (não há nenhum agora), então uma implementação mínima da funcionalidade necessária foi escrita em C++ com integração ao código R usando RCPP.

Para resolver o problema, foram utilizados os seguintes pacotes e bibliotecas:

  1. OpenCV para trabalhar com imagens e desenhar linhas. Bibliotecas de sistema e arquivos de cabeçalho pré-instalados usados, bem como links dinâmicos.

  2. xtensor para trabalhar com matrizes e tensores multidimensionais. Usamos arquivos de cabeçalho incluídos no pacote R de mesmo nome. A biblioteca permite que você trabalhe com matrizes multidimensionais, tanto na ordem principal da linha quanto na ordem principal da coluna.

  3. djson para analisar JSON. Esta biblioteca é usada em xtensor automaticamente se estiver presente no projeto.

  4. RcppThread para organizar o processamento multithread de um vetor de JSON. Usou os arquivos de cabeçalho fornecidos por este pacote. Dos mais populares RcppParalelo O pacote, entre outras coisas, possui um mecanismo de interrupção de loop integrado.

Deve notar-se que xtensor acabou sendo uma dádiva de Deus: além de possuir ampla funcionalidade e alto desempenho, seus desenvolvedores se mostraram bastante receptivos e responderam às perguntas com rapidez e detalhes. Com a ajuda deles, foi possível implementar transformações de matrizes OpenCV em tensores xtensor, bem como uma forma de combinar tensores de imagem tridimensionais em um tensor quadridimensional da dimensão correta (o próprio 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 arquivos que utilizam arquivos de sistema e vinculação dinâmica com bibliotecas instaladas no sistema, utilizamos o mecanismo de plugin implementado no pacote RCPP. Para encontrar caminhos e sinalizadores automaticamente, usamos um utilitário Linux popular pacote-config.

Implementação do plugin Rcpp para utilização 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 do funcionamento do plugin, os seguintes valores serão substituídos durante o processo de compilação:

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 implementação para analisar JSON e gerar um lote para transmissão ao modelo é fornecido no spoiler. Primeiro, adicione um diretório de projeto local para procurar arquivos de cabeçalho (necessários para ndjson):

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

Implementação de JSON para conversão de tensor em 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 deve ser colocado no arquivo src/cv_xt.cpp e compile com o comando Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); também necessário para o trabalho nlohmann/json.hpp de repositório. O código está dividido em diversas funções:

  • to_xt — uma função modelo para transformar uma matriz de imagem (cv::Mat) para um tensor xt::xtensor;

  • parse_json — a função analisa uma string JSON, extrai as coordenadas dos pontos, empacotando-as em um vetor;

  • ocv_draw_lines — a partir do vetor de pontos resultante, desenha linhas multicoloridas;

  • process — combina as funções acima e também adiciona a capacidade de dimensionar a imagem resultante;

  • cpp_process_json_str - wrapper sobre a função process, que exporta o resultado para um objeto R (matriz multidimensional);

  • cpp_process_json_vector - wrapper sobre a função cpp_process_json_str, que permite processar um vetor de string no modo multithread.

Para desenhar linhas multicoloridas foi utilizado o modelo de cores HSV, seguido de conversão para RGB. Vamos testar o resultado:

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

Quick Draw Doodle Recognition: como fazer amizade com R, C++ e redes neurais
Comparação da velocidade de implementações em 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") 

Quick Draw Doodle Recognition: como fazer amizade com R, C++ e redes neurais

Como você pode ver, o aumento de velocidade acabou sendo muito significativo e não é possível acompanhar o código C++ paralelizando o código R.

3. Iteradores para descarregar lotes do banco de dados

R tem uma reputação merecida de processamento de dados que cabem na RAM, enquanto Python é mais caracterizado pelo processamento de dados iterativo, permitindo implementar de forma fácil e natural cálculos fora do núcleo (cálculos usando memória externa). Um exemplo clássico e relevante para nós no contexto do problema descrito são as redes neurais profundas treinadas pelo método gradiente descendente com aproximação do gradiente em cada etapa usando uma pequena porção de observações, ou minilote.

Estruturas de aprendizado profundo escritas em Python possuem classes especiais que implementam iteradores baseados em dados: tabelas, imagens em pastas, formatos binários, etc. Em R podemos aproveitar todos os recursos da biblioteca Python keras com seus diversos backends usando o pacote de mesmo nome, que por sua vez funciona em cima do pacote reticulado. Este último merece um longo artigo separado; ele não apenas permite executar código Python a partir de R, mas também permite transferir objetos entre sessões R e Python, executando automaticamente todas as conversões de tipo necessárias.

Nos livramos da necessidade de armazenar todos os dados na RAM usando o MonetDBLite, todo o trabalho de “rede neural” será realizado pelo código original em Python, bastando escrever um iterador sobre os dados, já que não há nada pronto para tal situação em R ou Python. Existem essencialmente apenas dois requisitos para isso: ele deve retornar lotes em um loop infinito e salvar seu estado entre as iterações (o último em R é implementado da maneira mais simples usando encerramentos). Anteriormente, era necessário converter explicitamente matrizes R em matrizes numpy dentro do iterador, mas a versão atual do pacote keras faz isso sozinha.

O iterador para dados de treinamento e validação ficou assim:

Iterador para dados de treinamento e validação

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 função toma como entrada uma variável com conexão ao banco de dados, o número de linhas utilizadas, o número de classes, tamanho do lote, escala (scale = 1 corresponde à renderização de imagens de 256x256 pixels, scale = 0.5 — 128x128 pixels), indicador de cor (color = FALSE especifica a renderização em escala de cinza quando usada color = TRUE cada traço é desenhado em uma nova cor) e um indicador de pré-processamento para redes pré-treinadas no imagenet. Este último é necessário para dimensionar os valores dos pixels do intervalo [0, 1] para o intervalo [-1, 1], que foi usado ao treinar o fornecido keras modelos.

A função externa contém verificação de tipo de argumento, uma tabela data.table com números de linha misturados aleatoriamente de samples_index e números de lote, contador e número máximo de lotes, além de expressão SQL para descarregamento de dados do banco de dados. Além disso, definimos um análogo rápido da função dentro keras::to_categorical(). Usamos quase todos os dados para treinamento, deixando meio por cento para validação, portanto o tamanho da época foi limitado pelo parâmetro steps_per_epoch ao ligar keras::fit_generator(), e a condição if (i > max_i) funcionou apenas para o iterador de validação.

Na função interna, os índices de linha são recuperados para o próximo lote, os registros são descarregados do banco de dados com o contador do lote aumentando, análise JSON (função cpp_process_json_vector(), escrito em C++) e criando arrays correspondentes a imagens. Em seguida, são criados vetores one-hot com rótulos de classe, matrizes com valores de pixel e rótulos são combinadas em uma lista, que é o valor de retorno. Para agilizar o trabalho, utilizamos a criação de índices em tabelas data.table e modificação através do link - sem esses “chips” de pacote Tabela de dados É muito difícil imaginar trabalhar de forma eficaz com qualquer quantidade significativa de dados em R.

Os resultados das medições de velocidade em um laptop Core i5 são os seguintes:

Referência 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)

Quick Draw Doodle Recognition: como fazer amizade com R, C++ e redes neurais

Se você tiver RAM suficiente, poderá acelerar seriamente o funcionamento do banco de dados transferindo-o para esta mesma RAM (32 GB são suficientes para nossa tarefa). No Linux, a partição é montada por padrão /dev/shm, ocupando até metade da capacidade da RAM. Você pode destacar mais editando /etc/fstabpara obter um registro como tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Certifique-se de reiniciar e verificar o resultado executando o comando df -h.

O iterador para dados de teste parece muito mais simples, já que o conjunto de dados de teste cabe inteiramente na RAM:

Iterador para dados de teste

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. Seleção da arquitetura do modelo

A primeira arquitetura utilizada foi rede móvel v1, cujas características são discutidas em esta mensagem. Está incluído como padrão keras e, portanto, está disponível no pacote de mesmo nome para R. Mas ao tentar usá-lo com imagens monocanal, aconteceu uma coisa estranha: o tensor de entrada deve sempre ter a dimensão (batch, height, width, 3), ou seja, o número de canais não pode ser alterado. Não existe tal limitação em Python, então nos apressamos e escrevemos nossa própria implementação dessa arquitetura, seguindo o artigo original (sem o dropout que está na versão keras):

Arquitetura 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 desvantagens desta abordagem são óbvias. Quero testar muitos modelos, mas pelo contrário, não quero reescrever cada arquitetura manualmente. Também fomos privados da oportunidade de utilizar os pesos de modelos pré-treinados no imagenet. Como sempre, estudar a documentação ajudou. Função get_config() permite obter uma descrição do modelo em um formato adequado para edição (base_model_conf$layers - uma lista R regular) e a função from_config() realiza a conversão reversa para um 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)

Agora não é difícil escrever uma função universal para obter qualquer um dos dados fornecidos keras modelos com ou sem pesos treinados no imagenet:

Função para carregar arquiteturas prontas

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

Ao usar imagens de canal único, nenhum peso pré-treinado é usado. Isso poderia ser corrigido: usando a função get_weights() obtenha os pesos do modelo na forma de uma lista de matrizes R, altere a dimensão do primeiro elemento desta lista (tomando um canal de cor ou calculando a média de todos os três) e, em seguida, carregue os pesos de volta no modelo com a função set_weights(). Nunca adicionamos essa funcionalidade, pois nesta fase já estava claro que era mais produtivo trabalhar com imagens coloridas.

Realizamos a maioria dos experimentos usando as versões 1 e 2 do mobilenet, bem como o resnet34. Arquiteturas mais modernas como SE-ResNeXt tiveram um bom desempenho nesta competição. Infelizmente, não tínhamos implementações prontas à nossa disposição e não escrevemos as nossas próprias (mas com certeza escreveremos).

5. Parametrização de scripts

Por conveniência, todo o código para iniciar o treinamento foi projetado como um único script, parametrizado usando documento da seguinte maneira:

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)

Pacote documento representa a implementação http://docopt.org/ para R. Com sua ajuda, os scripts são iniciados com comandos simples 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 é executável (este comando começará a treinar o modelo resnet50 em imagens tricolores medindo 128x128 pixels, o banco de dados deve estar localizado na pasta /home/andrey/doodle_db). Você pode adicionar velocidade de aprendizado, tipo de otimizador e quaisquer outros parâmetros personalizáveis ​​à lista. No processo de preparação da publicação, descobriu-se que a arquitetura mobilenet_v2 da versão atual keras em uso R não deve devido a alterações não levadas em consideração no pacote R, estamos aguardando que consertem.

Esta abordagem permitiu acelerar significativamente os experimentos com diferentes modelos em comparação com o lançamento mais tradicional de scripts no RStudio (notamos o pacote como uma possível alternativa tfruns). Mas a principal vantagem é a possibilidade de gerenciar facilmente o lançamento de scripts no Docker ou simplesmente no servidor, sem instalar o RStudio para isso.

6. Dockerização de scripts

Utilizamos o Docker para garantir a portabilidade do ambiente para treinamento de modelos entre membros da equipe e para rápida implantação na nuvem. Você pode começar a se familiarizar com esta ferramenta, que é relativamente incomum para um programador R, com este série de publicações ou curso de vídeo.

O Docker permite que você crie suas próprias imagens do zero e use outras imagens como base para criar as suas próprias. Ao analisar as opções disponíveis, chegamos à conclusão de que a instalação de drivers NVIDIA, CUDA+cuDNN e bibliotecas Python é uma parte bastante volumosa da imagem, e decidimos tomar a imagem oficial como base tensorflow/tensorflow:1.12.0-gpu, adicionando os pacotes R necessários lá.

O arquivo docker final ficou assim:

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 conveniência, os pacotes utilizados foram colocados em variáveis; a maior parte dos scripts escritos é copiada dentro dos contêineres durante a montagem. Também alteramos o shell de comando para /bin/bash para facilidade de uso do conteúdo /etc/os-release. Isso evitou a necessidade de especificar a versão do sistema operacional no código.

Além disso, foi escrito um pequeno script bash que permite iniciar um contêiner com vários comandos. Por exemplo, podem ser scripts para treinar redes neurais que foram previamente colocadas dentro do contêiner ou um shell de comando para depurar e monitorar a operação do contêiner:

Script para iniciar o contêiner

#!/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 for executado sem parâmetros, o script será chamado dentro do contêiner train_nn.R com valores padrão; se o primeiro argumento posicional for "bash", o contêiner iniciará interativamente com um shell de comando. Em todos os outros casos, os valores dos argumentos posicionais são substituídos: CMD="Rscript /app/train_nn.R $@".

Vale ressaltar que os diretórios com dados de origem e banco de dados, bem como o diretório para salvamento dos modelos treinados, são montados dentro do container do sistema host, o que permite acessar os resultados dos scripts sem manipulações desnecessárias.

7. Usando várias GPUs no Google Cloud

Uma das características da competição foram os dados muito barulhentos (veja a imagem do título, emprestada de @Leigh.plt do ODS slack). Lotes grandes ajudam a combater isso e, após experimentos em um PC com 1 GPU, decidimos dominar modelos de treinamento em várias GPUs na nuvem. GoogleCloud usado (bom guia para o básico) devido à grande variedade de configurações disponíveis, preços razoáveis ​​e bônus de US$ 300. Por ganância, encomendei uma instância 4xV100 com SSD e muita RAM, e isso foi um grande erro. Essa máquina consome dinheiro rapidamente; você pode ir à falência experimentando sem um pipeline comprovado. Para fins educacionais, é melhor levar o K80. Mas a grande quantidade de RAM foi útil - o SSD em nuvem não impressionou com seu desempenho, então o banco de dados foi transferido para dev/shm.

De maior interesse é o fragmento de código responsável pelo uso de múltiplas GPUs. Primeiro, o modelo é criado na CPU usando um gerenciador de contexto, assim como em 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
  )
})

Em seguida, o modelo não compilado (isso é importante) é copiado para um determinado número de GPUs disponíveis e somente depois é compilado:

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

A técnica clássica de congelar todas as camadas exceto a última, treinar a última camada, descongelar e retreinar todo o modelo para várias GPUs não pôde ser implementada.

O treinamento foi monitorado sem uso. placa tensora, limitando-nos a registrar logs e salvar modelos com nomes informativos após cada época:

Retornos 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. Em vez de uma conclusão

Vários problemas que encontramos ainda não foram superados:

  • в keras não existe uma função pronta para procurar automaticamente a taxa de aprendizagem ideal (analógico lr_finder na biblioteca rápido.ai); Com algum esforço é possível portar implementações de terceiros para R, por exemplo, este;
  • como consequência do ponto anterior, não foi possível selecionar a velocidade correta de treinamento ao utilizar várias GPUs;
  • faltam arquiteturas de redes neurais modernas, especialmente aquelas pré-treinadas em imagenet;
  • política de ciclo único e taxas de aprendizagem discriminativas (recozimento de cosseno foi a nosso pedido implementadoobrigado Skeydan).

Que coisas úteis foram aprendidas nesta competição:

  • Em hardware de consumo relativamente baixo, você pode trabalhar com volumes de dados decentes (muitas vezes o tamanho da RAM) sem problemas. Saco de plástico Tabela de dados economiza memória devido à modificação in-loco de tabelas, o que evita copiá-las e, quando usado corretamente, seus recursos quase sempre demonstram a maior velocidade entre todas as ferramentas que conhecemos para linguagens de script. Salvar dados em um banco de dados permite, em muitos casos, nem pensar na necessidade de comprimir todo o conjunto de dados na RAM.
  • Funções lentas em R podem ser substituídas por funções rápidas em C++ usando o pacote RCPP. Se além de usar RcppThread ou RcppParalelo, obtemos implementações multithread multiplataforma, portanto não há necessidade de paralelizar o código no nível R.
  • pacote RCPP pode ser usado sem conhecimento sério de C++, o mínimo exigido é descrito aqui. Arquivos de cabeçalho para várias bibliotecas C interessantes, como xtensor disponível no CRAN, ou seja, está sendo formada uma infraestrutura para a implementação de projetos que integrem código C++ pronto de alto desempenho em R. Conveniência adicional é o destaque de sintaxe e um analisador de código C++ estático no RStudio.
  • documento permite executar scripts independentes com parâmetros. Isto é conveniente para uso em um servidor remoto, incl. na janela de encaixe. No RStudio é inconveniente realizar muitas horas de experimentos com treinamento de redes neurais, e nem sempre se justifica a instalação do IDE no próprio servidor.
  • Docker garante portabilidade de código e reprodutibilidade de resultados entre desenvolvedores com diferentes versões de SO e bibliotecas, além de facilidade de execução em servidores. Você pode iniciar todo o pipeline de treinamento com apenas um comando.
  • O Google Cloud é uma maneira econômica de experimentar hardware caro, mas você precisa escolher as configurações com cuidado.
  • Medir a velocidade de fragmentos de código individuais é muito útil, especialmente ao combinar R e C++, e com o pacote Banco - também muito fácil.

No geral esta experiência foi muito gratificante e continuamos a trabalhar para resolver algumas das questões levantadas.

Fonte: habr.com

Adicionar um comentário