Reconeixement de dibuixos ràpids: com fer amistat amb R, C++ i xarxes neuronals

Reconeixement de dibuixos ràpids: com fer amistat amb R, C++ i xarxes neuronals

Hola Habr!

La tardor passada, Kaggle va organitzar un concurs per classificar imatges dibuixades a mà, Quick Draw Doodle Recognition, en el qual, entre d'altres, va participar un equip de científics R: Artem Klevtsova, Gerent Philippa и Andrei Ogurtsov. No descriurem la competició en detall; això ja s'ha fet publicació recent.

Aquesta vegada no va funcionar amb el cultiu de medalles, però es va adquirir una gran experiència valuosa, així que m'agradaria explicar a la comunitat algunes de les coses més interessants i útils de Kagle i del treball quotidià. Entre els temes tractats: vida difícil sense OpenCV, anàlisi JSON (aquests exemples examinen la integració del codi C++ en scripts o paquets en R mitjançant Rcpp), parametrització dels scripts i acoblament de la solució final. Tot el codi del missatge en una forma adequada per a l'execució està disponible a repositoris.

Contingut:

  1. Carregueu de manera eficient les dades de CSV a MonetDB
  2. Preparació de lots
  3. Iteradors per descarregar lots de la base de dades
  4. Selecció d'un model d'arquitectura
  5. Parametrització d'scripts
  6. Dockerització de scripts
  7. Ús de diverses GPU a Google Cloud
  8. En lloc d'una conclusió

1. Carregueu de manera eficient les dades de CSV a la base de dades de MonetDB

Les dades d'aquest concurs no es proporcionen en forma d'imatges ja fetes, sinó en forma de 340 fitxers CSV (un fitxer per a cada classe) que contenen JSON amb coordenades puntuals. En connectar aquests punts amb línies, obtenim una imatge final que mesura 256x256 píxels. També per a cada registre hi ha una etiqueta que indica si la imatge va ser reconeguda correctament pel classificador utilitzat en el moment en què es va recollir el conjunt de dades, un codi de dues lletres del país de residència de l'autor de la imatge, un identificador únic, una marca de temps. i un nom de classe que coincideixi amb el nom del fitxer. Una versió simplificada de les dades originals pesa 7.4 GB a l'arxiu i aproximadament 20 GB després de desempaquetar, les dades completes després de desempaquetar ocupen 240 GB. Els organitzadors van assegurar que ambdues versions reproduïssin els mateixos dibuixos, la qual cosa significa que la versió completa era redundant. En qualsevol cas, emmagatzemar 50 milions d'imatges en fitxers gràfics o en forma de matrius es va considerar immediatament poc rendible i vam decidir fusionar tots els fitxers CSV de l'arxiu. train_simplified.zip a la base de dades amb la generació posterior d'imatges de la mida requerida "sobre la marxa" per a cada lot.

Es va triar un sistema ben provat com a SGBD MonetDB, és a dir, una implementació per a R com a paquet MonetDBLite. El paquet inclou una versió incrustada del servidor de bases de dades i us permet recollir el servidor directament des d'una sessió R i treballar-hi. La creació d'una base de dades i la connexió a ella es fan amb una ordre:

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

Haurem de crear dues taules: una per a totes les dades, l'altra per a la informació del servei sobre els fitxers descarregats (útil si alguna cosa va malament i el procés s'ha de reprendre després de descarregar diversos fitxers):

Creació de taules

if (!DBI::dbExistsTable(con, "doodles")) {
  DBI::dbCreateTable(
    con = con,
    name = "doodles",
    fields = c(
      "countrycode" = "char(2)",
      "drawing" = "text",
      "key_id" = "bigint",
      "recognized" = "bool",
      "timestamp" = "timestamp",
      "word" = "text"
    )
  )
}

if (!DBI::dbExistsTable(con, "upload_log")) {
  DBI::dbCreateTable(
    con = con,
    name = "upload_log",
    fields = c(
      "id" = "serial",
      "file_name" = "text UNIQUE",
      "uploaded" = "bool DEFAULT false"
    )
  )
}

La manera més ràpida de carregar dades a la base de dades era copiar directament fitxers CSV mitjançant l'ordre SQL COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTOn tablename - nom de la taula i path - la ruta al fitxer. Mentre treballava amb l'arxiu, es va descobrir que la implementació integrada unzip en R no funciona correctament amb una sèrie de fitxers de l'arxiu, per la qual cosa hem utilitzat el sistema unzip (utilitzant el paràmetre getOption("unzip")).

Funció per escriure a la base de dades

#' @title Извлечение и загрузка файлов
#'
#' @description
#' Извлечение CSV-файлов из ZIP-архива и загрузка их в базу данных
#'
#' @param con Объект подключения к базе данных (класс `MonetDBEmbeddedConnection`).
#' @param tablename Название таблицы в базе данных.
#' @oaram zipfile Путь к ZIP-архиву.
#' @oaram filename Имя файла внури ZIP-архива.
#' @param preprocess Функция предобработки, которая будет применена извлечённому файлу.
#'   Должна принимать один аргумент `data` (объект `data.table`).
#'
#' @return `TRUE`.
#'
upload_file <- function(con, tablename, zipfile, filename, preprocess = NULL) {
  # Проверка аргументов
  checkmate::assert_class(con, "MonetDBEmbeddedConnection")
  checkmate::assert_string(tablename)
  checkmate::assert_string(filename)
  checkmate::assert_true(DBI::dbExistsTable(con, tablename))
  checkmate::assert_file_exists(zipfile, access = "r", extension = "zip")
  checkmate::assert_function(preprocess, args = c("data"), null.ok = TRUE)

  # Извлечение файла
  path <- file.path(tempdir(), filename)
  unzip(zipfile, files = filename, exdir = tempdir(), 
        junkpaths = TRUE, unzip = getOption("unzip"))
  on.exit(unlink(file.path(path)))

  # Применяем функция предобработки
  if (!is.null(preprocess)) {
    .data <- data.table::fread(file = path)
    .data <- preprocess(data = .data)
    data.table::fwrite(x = .data, file = path, append = FALSE)
    rm(.data)
  }

  # Запрос к БД на импорт CSV
  sql <- sprintf(
    "COPY OFFSET 2 INTO %s FROM '%s' USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORT",
    tablename, path
  )
  # Выполнение запроса к БД
  DBI::dbExecute(con, sql)

  # Добавление записи об успешной загрузке в служебную таблицу
  DBI::dbExecute(con, sprintf("INSERT INTO upload_log(file_name, uploaded) VALUES('%s', true)",
                              filename))

  return(invisible(TRUE))
}

Si necessiteu transformar la taula abans d'escriure-la a la base de dades, n'hi ha prou amb passar l'argument preprocess funció que transformarà les dades.

Codi per carregar dades seqüencialment a la base de dades:

Escriptura de dades a la base de dades

# Список файлов для записи
files <- unzip(zipfile, list = TRUE)$Name

# Список исключений, если часть файлов уже была загружена
to_skip <- DBI::dbGetQuery(con, "SELECT file_name FROM upload_log")[[1L]]
files <- setdiff(files, to_skip)

if (length(files) > 0L) {
  # Запускаем таймер
  tictoc::tic()
  # Прогресс бар
  pb <- txtProgressBar(min = 0L, max = length(files), style = 3)
  for (i in seq_along(files)) {
    upload_file(con = con, tablename = "doodles", 
                zipfile = zipfile, filename = files[i])
    setTxtProgressBar(pb, i)
  }
  close(pb)
  # Останавливаем таймер
  tictoc::toc()
}

# 526.141 sec elapsed - копирование SSD->SSD
# 558.879 sec elapsed - копирование USB->SSD

El temps de càrrega de dades pot variar en funció de les característiques de velocitat de la unitat utilitzada. En el nostre cas, llegir i escriure dins d'un SSD o des d'una unitat flaix (fitxer font) a un SSD (DB) triga menys de 10 minuts.

Es triga uns quants segons més a crear una columna amb una etiqueta de classe entera i una columna d'índex (ORDERED INDEX) amb números de línia pels quals es mostren les observacions en crear lots:

Creació de columnes i índexs addicionals

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

Per resoldre el problema de crear un lot sobre la marxa, havíem d'aconseguir la màxima velocitat d'extracció de files aleatòries de la taula doodles. Per a això hem utilitzat 3 trucs. El primer va ser reduir la dimensionalitat del tipus que emmagatzema l'identificador d'observació. Al conjunt de dades original, el tipus necessari per emmagatzemar l'ID és bigint, però el nombre d'observacions permet ajustar els seus identificadors, iguals al nombre ordinal, en el tipus int. La recerca és molt més ràpida en aquest cas. El segon truc va ser utilitzar ORDERED INDEX — Vam prendre aquesta decisió empíricament, després d'haver revisat tots els disponibles opcions. El tercer va ser utilitzar consultes parametritzades. L'essència del mètode és executar l'ordre una vegada PREPARE amb l'ús posterior d'una expressió preparada en crear un munt de consultes del mateix tipus, però de fet hi ha un avantatge en comparació amb una de simple SELECT va resultar estar dins del rang d'error estadístic.

El procés de càrrega de dades no consumeix més de 450 MB de RAM. És a dir, l'enfocament descrit us permet moure conjunts de dades que pesen desenes de gigabytes en gairebé qualsevol maquinari econòmic, inclosos alguns dispositius d'una sola placa, la qual cosa és força genial.

Només queda mesurar la velocitat de recuperació de dades (aleatòries) i avaluar l'escala quan es mostren lots de diferents mides:

Base de dades de referència

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)

Reconeixement de dibuixos ràpids: com fer amistat amb R, C++ i xarxes neuronals

2. Preparació de lots

Tot el procés de preparació del lot consta dels passos següents:

  1. Analitzant diversos JSON que contenen vectors de cadenes amb coordenades de punts.
  2. Dibuix línies de colors a partir de les coordenades dels punts d'una imatge de la mida requerida (per exemple, 256×256 o 128×128).
  3. Convertir les imatges resultants en un tensor.

Com a part de la competència entre els nuclis de Python, el problema es va resoldre principalment utilitzant OpenCV. Un dels anàlegs més senzills i evidents de R seria així:

Implementació de la conversió de JSON a tensor a R

r_process_json_str <- function(json, line.width = 3, 
                               color = TRUE, scale = 1) {
  # Парсинг JSON
  coords <- jsonlite::fromJSON(json, simplifyMatrix = FALSE)
  tmp <- tempfile()
  # Удаляем временный файл по завершению функции
  on.exit(unlink(tmp))
  png(filename = tmp, width = 256 * scale, height = 256 * scale, pointsize = 1)
  # Пустой график
  plot.new()
  # Размер окна графика
  plot.window(xlim = c(256 * scale, 0), ylim = c(256 * scale, 0))
  # Цвета линий
  cols <- if (color) rainbow(length(coords)) else "#000000"
  for (i in seq_along(coords)) {
    lines(x = coords[[i]][[1]] * scale, y = coords[[i]][[2]] * scale, 
          col = cols[i], lwd = line.width)
  }
  dev.off()
  # Преобразование изображения в 3-х мерный массив
  res <- png::readPNG(tmp)
  return(res)
}

r_process_json_vector <- function(x, ...) {
  res <- lapply(x, r_process_json_str, ...)
  # Объединение 3-х мерных массивов картинок в 4-х мерный в тензор
  res <- do.call(abind::abind, c(res, along = 0))
  return(res)
}

El dibuix es realitza amb eines R estàndard i es desa en un PNG temporal emmagatzemat a la RAM (a Linux, els directoris R temporals es troben al directori). /tmp, muntat a la memòria RAM). A continuació, aquest fitxer es llegeix com una matriu tridimensional amb números que van de 0 a 1. Això és important perquè un BMP més convencional es llegiria en una matriu en brut amb codis de color hexadecimals.

Anem a provar el resultat:

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

Reconeixement de dibuixos ràpids: com fer amistat amb R, C++ i xarxes neuronals

El propi lot es formarà de la següent manera:

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

Aquesta implementació ens va semblar subòptima, ja que la formació de grans lots triga un temps indecentment llarg, i vam decidir aprofitar l'experiència dels nostres companys utilitzant una biblioteca potent. OpenCV. En aquell moment no hi havia cap paquet preparat per a R (ara no n'hi ha cap), de manera que es va escriure una implementació mínima de la funcionalitat requerida en C++ amb integració al codi R mitjançant Rcpp.

Per resoldre el problema, s'han utilitzat els paquets i biblioteques següents:

  1. OpenCV per treballar amb imatges i dibuixar línies. S'han utilitzat biblioteques de sistema preinstal·lades i fitxers de capçalera, així com enllaços dinàmics.

  2. xtensor per treballar amb matrius i tensors multidimensionals. Hem utilitzat fitxers de capçalera inclosos al paquet R del mateix nom. La biblioteca us permet treballar amb matrius multidimensionals, tant en fila principal com en ordre principal de columna.

  3. ndjson per analitzar JSON. Aquesta biblioteca s'utilitza a xtensor automàticament si està present al projecte.

  4. RcppThread per organitzar el processament multifil d'un vector de JSON. S'han utilitzat els fitxers de capçalera proporcionats per aquest paquet. De més popular RcppParal·lel El paquet, entre altres coses, té un mecanisme d'interrupció de bucle integrat.

Cal assenyalar que xtensor va resultar ser només un regal del Déu: a més del fet que té una àmplia funcionalitat i un alt rendiment, els seus desenvolupadors van resultar ser bastant sensibles i van respondre les preguntes ràpidament i amb detall. Amb la seva ajuda, va ser possible implementar transformacions de matrius OpenCV en tensors xtensor, així com una manera de combinar tensors d'imatge tridimensionals en un tensor de 3 dimensions de la dimensió correcta (el mateix lot).

Materials per a l'aprenentatge de Rcpp, xtensor i 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

Per compilar fitxers que utilitzen fitxers del sistema i enllaços dinàmics amb biblioteques instal·lades al sistema, hem utilitzat el mecanisme de connectors implementat al paquet Rcpp. Per trobar automàticament camins i banderes, hem utilitzat una popular utilitat Linux pkg-config.

Implementació del connector Rcpp per utilitzar la biblioteca OpenCV

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

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

Com a resultat de l'operació del connector, els valors següents es substituiran durant el procés de compilació:

Rcpp:::.plugins$opencv()$env

# $PKG_CXXFLAGS
# [1] "-I/usr/include/opencv"
#
# $PKG_LIBS
# [1] "-lopencv_shape -lopencv_stitching -lopencv_superres -lopencv_videostab -lopencv_aruco -lopencv_bgsegm -lopencv_bioinspired -lopencv_ccalib -lopencv_datasets -lopencv_dpm -lopencv_face -lopencv_freetype -lopencv_fuzzy -lopencv_hdf -lopencv_line_descriptor -lopencv_optflow -lopencv_video -lopencv_plot -lopencv_reg -lopencv_saliency -lopencv_stereo -lopencv_structured_light -lopencv_phase_unwrapping -lopencv_rgbd -lopencv_viz -lopencv_surface_matching -lopencv_text -lopencv_ximgproc -lopencv_calib3d -lopencv_features2d -lopencv_flann -lopencv_xobjdetect -lopencv_objdetect -lopencv_ml -lopencv_xphoto -lopencv_highgui -lopencv_videoio -lopencv_imgcodecs -lopencv_photo -lopencv_imgproc -lopencv_core"

El codi d'implementació per analitzar JSON i generar un lot per a la transmissió al model es dóna a l'spoiler. Primer, afegiu un directori de projecte local per cercar fitxers de capçalera (necessaris per a ndjson):

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

Implementació de la conversió 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;
}

Aquest codi s'ha de col·locar al fitxer src/cv_xt.cpp i compilar amb l'ordre Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); també necessari per treballar nlohmann/json.hpp d' repositori. El codi es divideix en diverses funcions:

  • to_xt — una funció de plantilla per transformar una matriu d'imatge (cv::Mat) a un tensor xt::xtensor;

  • parse_json — la funció analitza una cadena JSON, extreu les coordenades dels punts, empaquetant-les en un vector;

  • ocv_draw_lines — a partir del vector de punts resultant, dibuixa línies multicolors;

  • process — combina les funcions anteriors i també afegeix la capacitat d'escalar la imatge resultant;

  • cpp_process_json_str - embolcall sobre la funció process, que exporta el resultat a un objecte R (matriu multidimensional);

  • cpp_process_json_vector - embolcall sobre la funció cpp_process_json_str, que us permet processar un vector de cadena en mode multifil.

Per dibuixar línies multicolors, es va utilitzar el model de color HSV, seguit de la conversió a RGB. Anem a provar el resultat:

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

Reconeixement de dibuixos ràpids: com fer amistat amb R, C++ i xarxes neuronals
Comparació de la velocitat de les implementacions en R i 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") 

Reconeixement de dibuixos ràpids: com fer amistat amb R, C++ i xarxes neuronals

Com podeu veure, l'augment de velocitat va resultar ser molt significatiu i no és possible posar-se al dia amb el codi C++ paral·lelitzant el codi R.

3. Iteradors per descarregar lots de la base de dades

R té una merescuda reputació per processar dades que s'ajusten a la memòria RAM, mentre que Python es caracteritza més pel processament de dades iteratiu, que us permet implementar de manera fàcil i natural càlculs fora del nucli (càlculs amb memòria externa). Un exemple clàssic i rellevant per a nosaltres en el context del problema descrit són les xarxes neuronals profundes entrenades pel mètode de descens del gradient amb aproximació del gradient a cada pas mitjançant una petita part d'observacions, o mini-lot.

Els marcs d'aprenentatge profund escrits en Python tenen classes especials que implementen iteradors basats en dades: taules, imatges en carpetes, formats binaris, etc. Podeu utilitzar opcions ja fetes o escriure les vostres pròpies per a tasques específiques. A R podem aprofitar totes les característiques de la biblioteca Python keras amb els seus diferents backends utilitzant el paquet del mateix nom, que al seu torn funciona a la part superior del paquet reticular. Aquest últim mereix un article llarg a part; no només us permet executar codi Python des de R, sinó que també us permet transferir objectes entre sessions R i Python, realitzant automàticament totes les conversions de tipus necessàries.

Ens vam desfer de la necessitat d'emmagatzemar totes les dades a la memòria RAM utilitzant MonetDBlite, tot el treball de la "xarxa neuronal" el farà el codi original en Python, només hem d'escriure un iterador sobre les dades, ja que no hi ha res preparat. per a aquesta situació a R o Python. Bàsicament només hi ha dos requisits: ha de retornar lots en un bucle sense fi i desar el seu estat entre iteracions (aquest últim a R s'implementa de la manera més senzilla mitjançant tancaments). Anteriorment, es requeria convertir explícitament les matrius R en matrius numpy dins de l'iterador, però la versió actual del paquet keras ho fa ella mateixa.

L'iterador de dades de formació i validació va resultar ser el següent:

Iterador de dades d'entrenament i validació

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

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

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

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

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

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

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

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

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

La funció pren com a entrada una variable amb connexió a la base de dades, el nombre de línies utilitzades, el nombre de classes, la mida del lot, l'escala (scale = 1 correspon a la representació d'imatges de 256x256 píxels, scale = 0.5 — 128x128 píxels), indicador de color (color = FALSE especifica la representació en escala de grisos quan s'utilitza color = TRUE cada traç es dibuixa amb un color nou) i un indicador de preprocessament per a xarxes preformades a imagenet. Aquest últim és necessari per escalar els valors de píxels des de l'interval [0, 1] fins a l'interval [-1, 1], que es va utilitzar en entrenar el subministrat. keras models.

La funció externa conté la comprovació del tipus d'argument, una taula data.table amb números de línia barrejats aleatòriament de samples_index i números de lot, comptador i nombre màxim de lots, així com una expressió SQL per descarregar dades de la base de dades. A més, hem definit un anàleg ràpid de la funció a l'interior keras::to_categorical(). Hem utilitzat gairebé totes les dades per a l'entrenament, deixant mig percentatge per a la validació, de manera que la mida de l'època estava limitada pel paràmetre steps_per_epoch en trucar keras::fit_generator(), i la condició if (i > max_i) només funcionava per a l'iterador de validació.

A la funció interna, els índexs de fila es recuperen per al següent lot, els registres es descarreguen de la base de dades amb el comptador de lots augmentant, anàlisi JSON (funció cpp_process_json_vector(), escrit en C++) i creant matrius corresponents a imatges. A continuació, es creen vectors únics amb etiquetes de classe, matrius amb valors de píxels i etiquetes es combinen en una llista, que és el valor de retorn. Per agilitzar el treball, hem utilitzat la creació d'índexs en taules data.table i modificació mitjançant l'enllaç - sense aquests paquets "xips" dades.taula És bastant difícil imaginar-se treballant eficaçment amb una quantitat significativa de dades a R.

Els resultats de les mesures de velocitat en un ordinador portàtil Core i5 són els següents:

Referent de l'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)

Reconeixement de dibuixos ràpids: com fer amistat amb R, C++ i xarxes neuronals

Si teniu una quantitat suficient de RAM, podeu accelerar seriosament el funcionament de la base de dades transferint-la a aquesta mateixa RAM (32 GB són suficients per a la nostra tasca). A Linux, la partició es munta per defecte /dev/shm, ocupant fins a la meitat de la capacitat de la memòria RAM. Podeu ressaltar-ne més editant /etc/fstabper aconseguir un disc like tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Assegureu-vos de reiniciar i comprovar el resultat executant l'ordre df -h.

L'iterador de dades de prova sembla molt més senzill, ja que el conjunt de dades de prova s'adapta completament a la memòria RAM:

Iterador de dades de prova

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ó del model d'arquitectura

La primera arquitectura utilitzada va ser mobilenet v1, les característiques del qual es comenten a això missatge. S'inclou de sèrie keras i, en conseqüència, està disponible al paquet del mateix nom per a R. Però quan es va intentar utilitzar-lo amb imatges d'un sol canal, va resultar una cosa estranya: el tensor d'entrada ha de tenir sempre la dimensió (batch, height, width, 3), és a dir, no es pot canviar el nombre de canals. No hi ha aquesta limitació a Python, així que ens vam precipitar i vam escriure la nostra pròpia implementació d'aquesta arquitectura, seguint l'article original (sense l'abandonament que hi ha a la versió 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)
}

Els desavantatges d'aquest enfocament són evidents. Vull provar molts models, però al contrari, no vull reescriure cada arquitectura manualment. També ens van privar de l'oportunitat d'utilitzar els pesos de models pre-entrenats a imagenet. Com és habitual, l'estudi de la documentació va ajudar. Funció get_config() us permet obtenir una descripció del model en un format adequat per a l'edició (base_model_conf$layers - una llista R normal), i la funció from_config() realitza la conversió inversa a un objecte model:

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)

Ara no és difícil escriure una funció universal per obtenir qualsevol de les subministrades keras models amb o sense pesos entrenats a imagenet:

Funció per carregar arquitectures ja fetes

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

Quan s'utilitzen imatges d'un sol canal, no s'utilitzen pesos prèviament entrenats. Això es podria solucionar: utilitzant la funció get_weights() obteniu els pesos del model en forma d'una llista de matrius R, canvieu la dimensió del primer element d'aquesta llista (prenent un canal de color o fent la mitjana dels tres) i, a continuació, torneu a carregar els pesos al model amb la funció set_weights(). Mai hem afegit aquesta funcionalitat, perquè en aquesta etapa ja estava clar que era més productiu treballar amb imatges en color.

Hem realitzat la majoria dels experiments utilitzant les versions 1 i 2 de mobilenet, així com resnet34. Les arquitectures més modernes com SE-ResNeXt van tenir un bon rendiment en aquesta competició. Malauradament, no teníem implementacions ja fetes a la nostra disposició i no vam escriure les nostres (però segur que escriurem).

5. Parametrització de scripts

Per comoditat, tot el codi per iniciar l'entrenament es va dissenyar com un sol script, parametritzat mitjançant docpt de la manera següent:

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)

Paquet docpt representa la implementació http://docopt.org/ per a R. Amb la seva ajuda, els scripts es llancen amb ordres senzilles com Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db o ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, si fitxer train_nn.R és executable (aquesta ordre començarà a entrenar el model resnet50 en imatges de tres colors de 128x128 píxels, la base de dades s'ha d'ubicar a la carpeta /home/andrey/doodle_db). Podeu afegir a la llista la velocitat d'aprenentatge, el tipus d'optimitzador i qualsevol altre paràmetre personalitzable. En el procés de preparació de la publicació, va resultar que l'arquitectura mobilenet_v2 de la versió actual keras en l'ús de R no pot a causa de canvis que no es tenen en compte al paquet R, estem esperant que ho solucionin.

Aquest enfocament va permetre accelerar significativament els experiments amb diferents models en comparació amb el llançament més tradicional de scripts a RStudio (observem el paquet com una possible alternativa tfruns). Però el principal avantatge és la possibilitat de gestionar fàcilment el llançament d'scripts a Docker o simplement al servidor, sense instal·lar RStudio per a això.

6. Dockerització de scripts

Hem utilitzat Docker per garantir la portabilitat de l'entorn per a models de formació entre membres de l'equip i per a un desplegament ràpid al núvol. Podeu començar a familiaritzar-vos amb aquesta eina, que és relativament inusual per a un programador R això sèrie de publicacions o videocurs.

Docker us permet crear les vostres pròpies imatges des de zero i utilitzar altres imatges com a base per crear-ne les vostres. En analitzar les opcions disponibles, vam arribar a la conclusió que la instal·lació de controladors NVIDIA, CUDA+cuDNN i biblioteques Python és una part força voluminosa de la imatge i vam decidir prendre la imatge oficial com a base. tensorflow/tensorflow:1.12.0-gpu, afegint-hi els paquets R necessaris.

El fitxer docker final tenia aquest aspecte:

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

Per comoditat, els paquets utilitzats es van posar en variables; la major part dels guions escrits es copien dins dels contenidors durant el muntatge. També hem canviat l'intèrpret d'ordres a /bin/bash per facilitar l'ús del contingut /etc/os-release. Això va evitar la necessitat d'especificar la versió del sistema operatiu al codi.

A més, es va escriure un petit script bash que us permet llançar un contenidor amb diverses ordres. Per exemple, aquests podrien ser scripts per entrenar xarxes neuronals que es col·locaven prèviament dins del contenidor, o un intèrpret d'ordres per depurar i supervisar el funcionament del contenidor:

Script per llançar el contenidor

#!/bin/sh

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

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

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

Si aquest script bash s'executa sense paràmetres, l'script es cridarà dins del contenidor train_nn.R amb valors per defecte; si el primer argument posicional és "bash", el contenidor s'iniciarà de manera interactiva amb un intèrpret d'ordres. En tots els altres casos, es substitueixen els valors dels arguments posicionals: CMD="Rscript /app/train_nn.R $@".

Val la pena assenyalar que els directoris amb dades font i base de dades, així com el directori per desar models entrenats, es munten dins del contenidor des del sistema amfitrió, la qual cosa permet accedir als resultats dels scripts sense manipulacions innecessàries.

7. Ús de diverses GPU a Google Cloud

Una de les característiques de la competició eren les dades molt sorolloses (vegeu la imatge del títol, agafada de @Leigh.plt d'ODS slack). Lots grans ajuden a combatre-ho i, després d'experiments en un ordinador amb 1 GPU, vam decidir dominar els models d'entrenament en diverses GPU al núvol. GoogleCloud utilitzat (bona guia de les bases) a causa de la gran selecció de configuracions disponibles, preus raonables i bonificació de 300 dòlars. Per cobdícia, vaig demanar una instància 4xV100 amb un SSD i un munt de memòria RAM, i va ser un gran error. Aquesta màquina consumeix diners ràpidament; podeu anar a trencar experimentant sense un pipeline provat. Amb finalitats educatives, és millor prendre el K80. Però la gran quantitat de RAM va ser útil: el SSD al núvol no va impressionar amb el seu rendiment, de manera que la base de dades es va transferir a dev/shm.

El més interessant és el fragment de codi responsable d'utilitzar diverses GPU. Primer, el model es crea a la CPU mitjançant un gestor de context, igual que a 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ó, el model no compilat (això és important) es copia a un nombre determinat de GPU disponibles i només després es compila:

model <- keras::multi_gpu_model(model_cpu, gpus = n_gpu)
keras::compile(
  object = model,
  optimizer = keras::optimizer_adam(lr = 0.0004),
  loss = "categorical_crossentropy",
  metrics = c(top_3_categorical_accuracy)
)

La tècnica clàssica de congelar totes les capes excepte l'última, entrenar l'última capa, descongelar i tornar a entrenar tot el model per a diverses GPU no es va poder implementar.

La formació es va controlar sense ús. tauler tensor, limitant-nos a registrar registres i desar models amb noms informatius després de cada època:

Devolució de trucades

# Шаблон имени файла лога
log_file_tmpl <- file.path("logs", sprintf(
  "%s_%d_%dch_%s.csv",
  model_name,
  dim_size,
  channels,
  format(Sys.time(), "%Y%m%d%H%M%OS")
))
# Шаблон имени файла модели
model_file_tmpl <- file.path("models", sprintf(
  "%s_%d_%dch_{epoch:02d}_{val_loss:.2f}.h5",
  model_name,
  dim_size,
  channels
))

callbacks_list <- list(
  keras::callback_csv_logger(
    filename = log_file_tmpl
  ),
  keras::callback_early_stopping(
    monitor = "val_loss",
    min_delta = 1e-4,
    patience = 8,
    verbose = 1,
    mode = "min"
  ),
  keras::callback_reduce_lr_on_plateau(
    monitor = "val_loss",
    factor = 0.5, # уменьшаем lr в 2 раза
    patience = 4,
    verbose = 1,
    min_delta = 1e-4,
    mode = "min"
  ),
  keras::callback_model_checkpoint(
    filepath = model_file_tmpl,
    monitor = "val_loss",
    save_best_only = FALSE,
    save_weights_only = FALSE,
    mode = "min"
  )
)

8. En lloc d'una conclusió

Una sèrie de problemes que ens hem trobat encara no s'han resolt:

  • в keras no hi ha cap funció preparada per cercar automàticament la taxa d'aprenentatge òptima (analògic lr_finder a la biblioteca ràpid.ai); Amb cert esforç, és possible portar implementacions de tercers a R, per exemple, això;
  • com a conseqüència del punt anterior, no va ser possible seleccionar la velocitat d'entrenament correcta quan s'utilitzaven diverses GPU;
  • hi ha una manca d'arquitectures de xarxes neuronals modernes, especialment les preformades a imagenet;
  • no hi ha una política de cicle únic i taxes d'aprenentatge discriminatòries (el recuit del cosinus va ser a petició nostra implementat, gràcies skeydan).

Quines coses útils es van aprendre d'aquesta competició:

  • En maquinari de potència relativament baixa, podeu treballar amb volums de dades decents (moltes vegades la mida de la memòria RAM) sense dolor. Bossa de plàstic dades.taula estalvia memòria a causa de la modificació in situ de les taules, que evita copiar-les, i quan s'utilitzen correctament, les seves capacitats gairebé sempre demostren la velocitat més alta entre totes les eines que coneixem per als llenguatges de script. Desar dades en una base de dades us permet, en molts casos, no pensar gens en la necessitat d'esprémer tot el conjunt de dades a la memòria RAM.
  • Les funcions lentes en R es poden substituir per altres ràpides en C++ mitjançant el paquet Rcpp. Si a més d'utilitzar RcppThread o RcppParal·lel, obtenim implementacions multiplataforma multifils, de manera que no cal paral·lelitzar el codi al nivell R.
  • paquet Rcpp es pot utilitzar sense un coneixement seriós de C++, es descriu el mínim necessari aquí. Fitxers de capçalera per a diverses biblioteques C fantàstiques com xtensor disponible a CRAN, és a dir, s'està formant una infraestructura per a la implementació de projectes que integren codi C++ d'alt rendiment preparat a R. Una comoditat addicional és el ressaltat de sintaxi i un analitzador de codi C++ estàtic a RStudio.
  • docpt permet executar scripts autònoms amb paràmetres. Això és convenient per utilitzar-lo en un servidor remot, incl. sota docker. A RStudio, és inconvenient dur a terme moltes hores d'experiments amb xarxes neuronals d'entrenament, i la instal·lació de l'IDE al propi servidor no sempre està justificada.
  • Docker garanteix la portabilitat del codi i la reproductibilitat dels resultats entre desenvolupadors amb diferents versions del sistema operatiu i biblioteques, així com la facilitat d'execució als servidors. Podeu llançar tot el canal d'entrenament amb només una ordre.
  • Google Cloud és una manera econòmica d'experimentar amb maquinari car, però cal triar les configuracions amb cura.
  • Mesurar la velocitat de fragments de codi individuals és molt útil, especialment quan es combinen R i C++, i amb el paquet banc - També molt fàcil.

En general, aquesta experiència va ser molt gratificant i continuem treballant per resoldre alguns dels problemes plantejats.

Font: www.habr.com

Afegeix comentari