Recunoaștere rapidă a Doodle-ului: cum să vă împrietenești cu R, C++ și rețelele neuronale

Recunoaștere rapidă a Doodle-ului: cum să vă împrietenești cu R, C++ și rețelele neuronale

Hei Habr!

Toamna trecută, Kaggle a găzduit o competiție de clasificare a imaginilor desenate manual, Quick Draw Doodle Recognition, la care, printre altele, a participat și o echipă de oameni de știință R: Artem Klevtsova, Manager Philippa и Andrei Ogurțov. Nu vom descrie competiția în detaliu; asta a fost deja făcut în publicație recentă.

De data aceasta nu a funcționat cu cultivarea medaliilor, dar a fost acumulată multă experiență valoroasă, așa că aș dori să spun comunității despre o serie dintre cele mai interesante și utile lucruri despre Kagle și în munca de zi cu zi. Printre subiectele discutate: viață dificilă fără OpenCV, analiza JSON (aceste exemple examinează integrarea codului C++ în scripturi sau pachete în R folosind Rcpp), parametrizarea scripturilor și dockerizarea soluției finale. Tot codul din mesaj într-o formă adecvată pentru execuție este disponibil în depozite.

Cuprins:

  1. Încărcați eficient datele din CSV în MonetDB
  2. Pregătirea loturilor
  3. Iteratoare pentru descărcarea loturilor din baza de date
  4. Selectarea unui model de arhitectură
  5. Parametrizare script
  6. Dockerizarea scripturilor
  7. Folosind mai multe GPU-uri pe Google Cloud
  8. În loc de concluzie

1. Încărcați eficient datele din CSV în baza de date MonetDB

Datele din această competiție sunt furnizate nu sub formă de imagini gata făcute, ci sub forma a 340 de fișiere CSV (un fișier pentru fiecare clasă) care conțin JSON-uri cu coordonate punct. Prin conectarea acestor puncte cu linii, obținem o imagine finală care măsoară 256x256 pixeli. De asemenea, pentru fiecare înregistrare există o etichetă care indică dacă imaginea a fost recunoscută corect de clasificatorul utilizat la momentul colectării setului de date, un cod din două litere al țării de reședință a autorului imaginii, un identificator unic, un marcaj de timp. și un nume de clasă care se potrivește cu numele fișierului. O versiune simplificată a datelor originale cântărește 7.4 GB în arhivă și aproximativ 20 GB după despachetare, datele complete după despachetare ocupă 240 GB. Organizatorii s-au asigurat că ambele versiuni reproduc aceleași desene, ceea ce înseamnă că versiunea completă a fost redundantă. În orice caz, stocarea a 50 de milioane de imagini în fișiere grafice sau sub formă de matrice a fost imediat considerată neprofitabilă și am decis să îmbinam toate fișierele CSV din arhivă train_simplified.zip în baza de date cu generarea ulterioară de imagini de dimensiunea necesară „din zbor” pentru fiecare lot.

Un sistem bine dovedit a fost ales ca SGBD MonetDB, și anume o implementare pentru R ca pachet MonetDBLite. Pachetul include o versiune încorporată a serverului de baze de date și vă permite să preluați serverul direct dintr-o sesiune R și să lucrați cu el acolo. Crearea unei baze de date și conectarea la aceasta se realizează cu o singură comandă:

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

Va trebui să creăm două tabele: unul pentru toate datele, celălalt pentru informațiile de serviciu despre fișierele descărcate (util dacă ceva nu merge bine și procesul trebuie reluat după descărcarea mai multor fișiere):

Crearea de tabele

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

Cea mai rapidă modalitate de a încărca date în baza de date a fost să copiați direct fișierele CSV folosind comanda SQL COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTUnde tablename - numele tabelului și path - calea către fișier. În timp ce lucrați cu arhiva, sa descoperit că implementarea încorporată unzip în R nu funcționează corect cu un număr de fișiere din arhivă, așa că am folosit sistemul unzip (folosind parametrul getOption("unzip")).

Funcție de scriere în baza de date

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

Dacă trebuie să transformați tabelul înainte de a-l scrie în baza de date, este suficient să treceți argumentul preprocess funcția care va transforma datele.

Cod pentru încărcarea secvenţială a datelor în baza de date:

Scrierea datelor în baza de date

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

Timpul de încărcare a datelor poate varia în funcție de caracteristicile de viteză ale unității utilizate. În cazul nostru, citirea și scrierea într-un SSD sau de pe o unitate flash (fișier sursă) pe un SSD (DB) durează mai puțin de 10 minute.

Este nevoie de încă câteva secunde pentru a crea o coloană cu o etichetă de clasă întreagă și o coloană index (ORDERED INDEX) cu numere de rând prin care vor fi eșantionate observațiile la crearea loturilor:

Crearea de coloane și index suplimentare

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

Pentru a rezolva problema creării unui lot din mers, trebuia să atingem viteza maximă de extragere a rândurilor aleatorii din tabel. doodles. Pentru asta am folosit 3 trucuri. Prima a fost reducerea dimensionalității tipului care stochează ID-ul de observație. În setul de date original, tipul necesar pentru stocarea ID-ului este bigint, dar numărul de observații face posibilă încadrarea identificatorilor lor, egali cu numărul ordinal, în tip int. Căutarea este mult mai rapidă în acest caz. Al doilea truc a fost să folosești ORDERED INDEX — am ajuns la această decizie în mod empiric, după ce am parcurs toate cele disponibile opțiuni. Al treilea a fost folosirea interogărilor parametrizate. Esența metodei este să executați comanda o dată PREPARE cu utilizarea ulterioară a unei expresii pregătite atunci când se creează o grămadă de interogări de același tip, dar de fapt există un avantaj în comparație cu una simplă SELECT s-a dovedit a fi în intervalul erorii statistice.

Procesul de încărcare a datelor nu consumă mai mult de 450 MB de RAM. Adică, abordarea descrisă vă permite să mutați seturi de date cântărind zeci de gigaocteți pe aproape orice hardware de buget, inclusiv unele dispozitive cu o singură placă, ceea ce este destul de grozav.

Tot ce rămâne este să măsori viteza de recuperare a datelor (aleatorie) și să evaluezi scalarea atunci când eșantionezi loturi de diferite dimensiuni:

Benchmark pentru baze de date

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)

Recunoaștere rapidă a Doodle-ului: cum să vă împrietenești cu R, C++ și rețelele neuronale

2. Pregătirea loturilor

Întregul proces de pregătire a lotului constă din următorii pași:

  1. Analizarea mai multor JSON care conțin vectori de șiruri de caractere cu coordonatele punctelor.
  2. Desenarea liniilor colorate bazate pe coordonatele punctelor dintr-o imagine de dimensiunea necesară (de exemplu, 256×256 sau 128×128).
  3. Conversia imaginilor rezultate într-un tensor.

Ca parte a competiției dintre nucleele Python, problema a fost rezolvată în principal folosind OpenCV. Unul dintre cei mai simpli și mai evidenti analogi din R ar arăta astfel:

Implementarea conversiei JSON la tensor în 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)
}

Desenarea este efectuată folosind instrumente standard R și salvată într-un PNG temporar stocat în RAM (pe Linux, directoarele temporare R sunt situate în director /tmp, montat în RAM). Acest fișier este apoi citit ca o matrice tridimensională cu numere cuprinse între 0 și 1. Acest lucru este important deoarece un BMP mai convențional ar fi citit într-o matrice brută cu coduri de culoare hexadecimale.

Să testăm rezultatul:

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

Recunoaștere rapidă a Doodle-ului: cum să vă împrietenești cu R, C++ și rețelele neuronale

Lotul în sine va fi format după cum urmează:

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

Această implementare ni s-a părut suboptimă, deoarece formarea de loturi mari durează un timp indecent și am decis să profităm de experiența colegilor noștri folosind o bibliotecă puternică. OpenCV. La acel moment nu exista un pachet gata făcut pentru R (nu există acum), așa că o implementare minimă a funcționalității necesare a fost scrisă în C++ cu integrare în codul R folosind Rcpp.

Pentru a rezolva problema, au fost folosite următoarele pachete și biblioteci:

  1. OpenCV pentru lucrul cu imagini și trasarea liniilor. S-au folosit biblioteci de sistem preinstalate și fișiere antet, precum și legături dinamice.

  2. xtensor pentru lucrul cu rețele multidimensionale și tensori. Am folosit fișiere antet incluse în pachetul R cu același nume. Biblioteca vă permite să lucrați cu tablouri multidimensionale, atât în ​​rândurile majore, cât și în ordinea majoră a coloanelor.

  3. ndjson pentru analizarea JSON. Această bibliotecă este folosită în xtensor automat dacă este prezent în proiect.

  4. RcppThread pentru organizarea procesării multi-threaded a unui vector din JSON. S-au folosit fișierele antet furnizate de acest pachet. Din mai populare RcppParallel Pachetul, printre altele, are un mecanism de întrerupere a buclei încorporat.

Ar trebui remarcat faptul că xtensor s-a dovedit a fi o mană divină: pe lângă faptul că are funcționalitate extinsă și performanță ridicată, dezvoltatorii săi s-au dovedit a fi destul de receptivi și au răspuns la întrebări prompt și în detaliu. Cu ajutorul lor, a fost posibilă implementarea transformărilor matricelor OpenCV în tensori xtensori, precum și o modalitate de a combina tensorii imaginii tridimensionale într-un tensor tridimensional de dimensiunea corectă (lotul în sine).

Materiale pentru învățarea 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

Pentru a compila fișiere care folosesc fișiere de sistem și legături dinamice cu bibliotecile instalate pe sistem, am folosit mecanismul plugin implementat în pachet Rcpp. Pentru a găsi automat căi și steaguri, am folosit un utilitar Linux popular pkg-config.

Implementarea pluginului Rcpp pentru utilizarea bibliotecii 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)
  ))
})

Ca urmare a funcționării pluginului, următoarele valori vor fi înlocuite în timpul procesului de compilare:

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"

Codul de implementare pentru analizarea JSON și generarea unui lot pentru transmiterea către model este dat sub spoiler. Mai întâi, adăugați un director de proiect local pentru a căuta fișiere de antet (necesar pentru ndjson):

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

Implementarea conversiei JSON la tensor în 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;
}

Acest cod ar trebui să fie plasat în fișier src/cv_xt.cpp și compilați cu comanda Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); necesar și pentru muncă nlohmann/json.hpp de repertoriu. Codul este împărțit în mai multe funcții:

  • to_xt — o funcție șablon pentru transformarea unei matrice de imagine (cv::Mat) la un tensor xt::xtensor;

  • parse_json — funcția parsează un șir JSON, extrage coordonatele punctelor, împachetându-le într-un vector;

  • ocv_draw_lines — din vectorul de puncte rezultat, trage linii multicolore;

  • process — combină funcțiile de mai sus și adaugă și capacitatea de a scala imaginea rezultată;

  • cpp_process_json_str - înveliș peste funcție process, care exportă rezultatul într-un obiect R (matrice multidimensională);

  • cpp_process_json_vector - înveliș peste funcție cpp_process_json_str, care vă permite să procesați un vector șir în modul cu mai multe fire.

Pentru a desena linii multicolore, a fost folosit modelul de culoare HSV, urmat de conversia în RGB. Să testăm rezultatul:

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

Recunoaștere rapidă a Doodle-ului: cum să vă împrietenești cu R, C++ și rețelele neuronale
Comparația vitezei implementărilor în 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") 

Recunoaștere rapidă a Doodle-ului: cum să vă împrietenești cu R, C++ și rețelele neuronale

După cum puteți vedea, creșterea vitezei s-a dovedit a fi foarte semnificativă și nu este posibil să ajungeți din urmă cu codul C++ prin paralelizarea codului R.

3. Iteratoare pentru descărcarea loturilor din baza de date

R are o reputație binemeritată pentru procesarea datelor care se încadrează în RAM, în timp ce Python este caracterizat mai mult de procesarea iterativă a datelor, permițându-vă să implementați cu ușurință și în mod natural calcule out-of-core (calcule folosind memoria externă). Un exemplu clasic și relevant pentru noi în contextul problemei descrise sunt rețelele neuronale profunde antrenate prin metoda de coborâre a gradientului cu aproximarea gradientului la fiecare pas folosind o mică parte de observații sau mini-lot.

Cadrele de învățare profundă scrise în Python au clase speciale care implementează iteratoare bazate pe date: tabele, imagini în foldere, formate binare etc. Puteți utiliza opțiuni gata făcute sau puteți scrie propriile dvs. pentru sarcini specifice. În R putem profita de toate caracteristicile bibliotecii Python keras cu diferitele sale backend-uri folosind pachetul cu același nume, care la rândul său funcționează deasupra pachetului reticulat. Acesta din urmă merită un articol lung separat; nu numai că vă permite să rulați cod Python din R, dar vă permite și să transferați obiecte între sesiunile R și Python, efectuând automat toate conversiile de tip necesare.

Am scăpat de necesitatea de a stoca toate datele în RAM utilizând MonetDBlite, toată munca de „rețea neuronală” va fi efectuată de codul original în Python, trebuie doar să scriem un iterator peste date, deoarece nu este nimic gata pentru o astfel de situație fie în R sau Python. În esență, există doar două cerințe pentru acesta: trebuie să returneze loturi într-o buclă nesfârșită și să-și salveze starea între iterații (cea din urmă în R este implementată în cel mai simplu mod folosind închideri). Anterior, era necesar să se convertească în mod explicit matricele R în matrice numpy în interiorul iteratorului, dar versiunea actuală a pachetului keras o face ea însăși.

Iteratorul pentru datele de instruire și validare s-a dovedit a fi după cum urmează:

Iterator pentru date de instruire și validare

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

Funcția ia ca intrare o variabilă cu conexiune la baza de date, numărul de linii utilizate, numărul de clase, dimensiunea lotului, scara (scale = 1 corespunde redării imaginilor de 256x256 pixeli, scale = 0.5 — 128x128 pixeli), indicator de culoare (color = FALSE specifică randarea în tonuri de gri atunci când este utilizat color = TRUE fiecare contur este desenat într-o culoare nouă) și un indicator de preprocesare pentru rețelele pre-antrenate pe imagenet. Acesta din urmă este necesar pentru a scala valorile pixelilor de la intervalul [0, 1] la intervalul [-1, 1], care a fost folosit la antrenamentul furnizat. keras modele.

Funcția externă conține verificarea tipului de argument, un tabel data.table cu numere de linie amestecate aleatoriu din samples_index și numere de lot, contor și număr maxim de loturi, precum și o expresie SQL pentru descărcarea datelor din baza de date. În plus, am definit un analog rapid al funcției din interior keras::to_categorical(). Am folosit aproape toate datele pentru antrenament, lăsând o jumătate de procent pentru validare, astfel încât dimensiunea epocii a fost limitată de parametru steps_per_epoch când chemat keras::fit_generator(), și starea if (i > max_i) a funcționat doar pentru iteratorul de validare.

În funcția internă, indicii de rând sunt preluați pentru lotul următor, înregistrările sunt descărcate din baza de date cu contorul de lot în creștere, analiza JSON (funcția cpp_process_json_vector(), scris în C++) și creând tablouri corespunzătoare imaginilor. Apoi sunt creați vectori one-hot cu etichete de clasă, matrice cu valori de pixeli și etichete sunt combinate într-o listă, care este valoarea returnată. Pentru a accelera munca, am folosit crearea de indici în tabele data.table și modificare prin link - fără aceste pachete „cipuri” tabel de date Este destul de greu de imaginat să lucrezi eficient cu orice cantitate semnificativă de date în R.

Rezultatele măsurătorilor vitezei pe un laptop Core i5 sunt următoarele:

Benchmark iterator

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)

Recunoaștere rapidă a Doodle-ului: cum să vă împrietenești cu R, C++ și rețelele neuronale

Dacă aveți o cantitate suficientă de RAM, puteți accelera serios funcționarea bazei de date transferând-o pe aceeași memorie RAM (32 GB sunt suficiente pentru sarcina noastră). În Linux, partiția este montată implicit /dev/shm, ocupând până la jumătate din capacitatea RAM. Puteți evidenția mai multe prin editare /etc/fstabpentru a obține un record ca tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Asigurați-vă că reporniți și verificați rezultatul executând comanda df -h.

Iteratorul pentru datele de testare pare mult mai simplu, deoarece setul de date de testare se potrivește în întregime în RAM:

Iterator pentru datele de testare

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. Selectarea arhitecturii modelului

Prima arhitectură folosită a fost mobilenet v1, ale căror caracteristici sunt discutate în acest mesaj. Este inclus ca standard keras și, în consecință, este disponibil în pachetul cu același nume pentru R. Dar când încercați să îl utilizați cu imagini cu un singur canal, sa dovedit un lucru ciudat: tensorul de intrare trebuie să aibă întotdeauna dimensiunea (batch, height, width, 3), adică numărul de canale nu poate fi schimbat. Nu există o astfel de limitare în Python, așa că ne-am grăbit și am scris propria noastră implementare a acestei arhitecturi, urmând articolul original (fără abandonul care este în versiunea keras):

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

Dezavantajele acestei abordări sunt evidente. Vreau să testez o mulțime de modele, dar dimpotrivă, nu vreau să rescriu fiecare arhitectură manual. De asemenea, am fost lipsiți de posibilitatea de a folosi greutățile modelelor pre-antrenate pe imagenet. Ca de obicei, studierea documentației a ajutat. Funcţie get_config() vă permite să obțineți o descriere a modelului într-o formă adecvată pentru editare (base_model_conf$layers - o listă R obișnuită) și funcția from_config() efectuează conversia inversă într-un obiect 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)

Acum nu este dificil să scrieți o funcție universală pentru a obține oricare dintre cele furnizate keras modele cu sau fără greutăți antrenate pe imagenet:

Funcție pentru încărcarea arhitecturilor gata făcute

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

Când utilizați imagini cu un singur canal, nu sunt utilizate greutăți preantrenate. Acest lucru ar putea fi remediat: folosind funcția get_weights() obțineți greutățile modelului sub forma unei liste de matrice R, modificați dimensiunea primului element din această listă (prin luarea unui canal de culoare sau făcând media tuturor celor trei), apoi încărcați greutățile înapoi în model cu funcția set_weights(). Nu am adăugat niciodată această funcționalitate, deoarece în această etapă era deja clar că era mai productiv să lucrezi cu imagini color.

Am efectuat majoritatea experimentelor folosind versiunile mobilenet 1 și 2, precum și resnet34. Arhitecturile mai moderne, cum ar fi SE-ResNeXt, au avut rezultate bune în această competiție. Din păcate, nu am avut la dispoziție implementări gata făcute și nu le-am scris pe ale noastre (dar cu siguranță vom scrie).

5. Parametrizarea scripturilor

Pentru comoditate, tot codul pentru începerea antrenamentului a fost conceput ca un singur script, parametrizat folosind docpt după cum urmează:

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)

pachet docpt reprezintă implementarea http://docopt.org/ pentru R. Cu ajutorul lui, scripturile sunt lansate cu comenzi simple precum Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db sau ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, dacă fișier train_nn.R este executabil (această comandă va începe antrenamentul modelului resnet50 pe imagini tricolore de 128x128 pixeli, baza de date trebuie să fie localizată în folder /home/andrey/doodle_db). Puteți adăuga în listă viteza de învățare, tipul de optimizator și orice alți parametri personalizabili. În procesul de pregătire a publicației, sa dovedit că arhitectura mobilenet_v2 din versiunea actuală keras în utilizarea R nu poate din cauza modificărilor care nu au fost luate în considerare în pachetul R, așteptăm ca aceștia să-l repare.

Această abordare a făcut posibilă accelerarea semnificativă a experimentelor cu diferite modele în comparație cu lansarea mai tradițională a scripturilor în RStudio (observăm pachetul ca o posibilă alternativă tfruns). Dar principalul avantaj este abilitatea de a gestiona cu ușurință lansarea de scripturi în Docker sau pur și simplu pe server, fără a instala RStudio pentru asta.

6. Dockerizarea scripturilor

Am folosit Docker pentru a asigura portabilitatea mediului pentru modelele de instruire între membrii echipei și pentru implementarea rapidă în cloud. Puteți începe să vă familiarizați cu acest instrument, care este relativ neobișnuit pentru un programator R, cu acest serie de publicaţii sau curs video.

Docker vă permite atât să vă creați propriile imagini de la zero, cât și să folosiți alte imagini ca bază pentru crearea propriei imagini. Analizând opțiunile disponibile, am ajuns la concluzia că instalarea driverelor NVIDIA, CUDA+cuDNN și a bibliotecilor Python este o parte destul de voluminoasă a imaginii și am decis să luăm ca bază imaginea oficială. tensorflow/tensorflow:1.12.0-gpu, adăugând acolo pachetele R necesare.

Fișierul docker final arăta astfel:

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

Pentru comoditate, pachetele utilizate au fost puse în variabile; cea mai mare parte a scripturilor scrise sunt copiate în interiorul containerelor în timpul asamblarii. De asemenea, am schimbat shell-ul de comandă în /bin/bash pentru ușurința utilizării conținutului /etc/os-release. Acest lucru a evitat necesitatea de a specifica versiunea OS în cod.

În plus, a fost scris un mic script bash care vă permite să lansați un container cu diverse comenzi. De exemplu, acestea ar putea fi scripturi pentru antrenarea rețelelor neuronale care au fost plasate anterior în interiorul containerului sau un shell de comandă pentru depanarea și monitorizarea funcționării containerului:

Script pentru lansarea containerului

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

Dacă acest script bash este rulat fără parametri, scriptul va fi apelat în interiorul containerului train_nn.R cu valori implicite; dacă primul argument pozițional este „bash”, atunci containerul va porni interactiv cu un shell de comandă. În toate celelalte cazuri, valorile argumentelor poziționale sunt înlocuite: CMD="Rscript /app/train_nn.R $@".

Este de remarcat faptul că directoarele cu date sursă și baza de date, precum și directorul pentru salvarea modelelor antrenate, sunt montate în interiorul containerului din sistemul gazdă, ceea ce vă permite să accesați rezultatele scripturilor fără manipulări inutile.

7. Utilizarea mai multor GPU-uri pe Google Cloud

Una dintre caracteristicile competiției au fost datele foarte zgomotoase (vezi poza din titlu, împrumutată de la @Leigh.plt de la ODS slack). Loturile mari ajută la combaterea acestui lucru, iar după experimente pe un PC cu 1 GPU, am decis să stăpânim modele de antrenament pe mai multe GPU-uri din cloud. Folosit GoogleCloud (bun ghid pentru elementele de bază) datorită selecției mari de configurații disponibile, prețurilor rezonabile și bonusului de 300 USD. Din lăcomie, am comandat o instanță 4xV100 cu un SSD și o tonă de RAM și asta a fost o mare greșeală. O astfel de mașină consumă bani rapid; poți să faci experimente fără o conductă dovedită. În scopuri educaționale, este mai bine să luați K80. Dar cantitatea mare de RAM a fost utilă - SSD-ul cloud nu a impresionat cu performanța sa, așa că baza de date a fost transferată în dev/shm.

De cel mai mare interes este fragmentul de cod responsabil pentru utilizarea mai multor GPU-uri. În primul rând, modelul este creat pe CPU folosind un manager de context, la fel ca în 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
  )
})

Apoi, modelul necompilat (acest lucru este important) este copiat pe un anumit număr de GPU-uri disponibile și numai după aceea este compilat:

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

Tehnica clasică de înghețare a tuturor straturilor cu excepția ultimului, antrenamentul ultimului strat, dezghețarea și reantrenarea întregului model pentru mai multe GPU-uri nu a putut fi implementată.

Antrenamentul a fost monitorizat fără utilizare. tensorboard, limitându-ne la înregistrarea jurnalelor și la salvarea modelelor cu nume informative după fiecare epocă:

Reapeluri

# Шаблон имени файла лога
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. În loc de o concluzie

O serie de probleme pe care le-am întâlnit nu au fost încă depășite:

  • в keras nu există nicio funcție gata pregătită pentru căutarea automată a ratei optime de învățare (analogic lr_finder în bibliotecă repede.ai); Cu oarecare efort, este posibil să portați implementări de la terți la R, de exemplu, acest;
  • ca urmare a punctului anterior, nu a fost posibilă selectarea vitezei corecte de antrenament la utilizarea mai multor GPU-uri;
  • există o lipsă de arhitecturi moderne de rețele neuronale, în special cele pre-antrenate pe imagenet;
  • politică fără ciclu și rate de învățare discriminatorii (recoacerea cosinus a fost la cererea noastră implementate, Mulțumiri skeydan).

Ce lucruri utile au fost învățate din această competiție:

  • Pe hardware-ul cu o putere relativ redusă, puteți lucra cu volume de date decente (de multe ori mai mari decât RAM) fără durere. Punga de plastic tabel de date economisește memorie datorită modificării în loc a tabelelor, ceea ce evită copierea acestora, iar atunci când sunt utilizate corect, capacitățile sale demonstrează aproape întotdeauna cea mai mare viteză dintre toate instrumentele cunoscute pentru limbajele de scripting. Salvarea datelor într-o bază de date vă permite, în multe cazuri, să nu vă gândiți deloc la necesitatea de a stoarce întregul set de date în RAM.
  • Funcțiile lente din R pot fi înlocuite cu cele rapide din C++ folosind pachetul Rcpp. Dacă pe lângă utilizare RcppThread sau RcppParallel, obținem implementări multiplatformă cu mai multe fire, deci nu este nevoie să paralelizăm codul la nivelul R.
  • pachet Rcpp poate fi folosit fără cunoștințe serioase de C++, se subliniază minimul necesar aici. Fișiere antet pentru o serie de biblioteci C cool, cum ar fi xtensor disponibil pe CRAN, adică se formează o infrastructură pentru implementarea proiectelor care integrează cod C++ de înaltă performanță gata făcut în R. O comoditate suplimentară este evidențierea sintaxei și un analizor static de cod C++ în RStudio.
  • docpt vă permite să rulați scripturi autonome cu parametri. Acest lucru este convenabil pentru utilizare pe un server la distanță, inclusiv. sub docker. În RStudio, este incomod să efectuați multe ore de experimente cu antrenarea rețelelor neuronale, iar instalarea IDE-ului pe server în sine nu este întotdeauna justificată.
  • Docker asigură portabilitatea codului și reproductibilitatea rezultatelor între dezvoltatorii cu diferite versiuni ale sistemului de operare și biblioteci, precum și ușurința de execuție pe servere. Puteți lansa întreaga conductă de antrenament cu o singură comandă.
  • Google Cloud este o modalitate ieftină de a experimenta hardware scump, dar trebuie să alegeți cu atenție configurațiile.
  • Măsurarea vitezei fragmentelor de cod individuale este foarte utilă, mai ales atunci când combinați R și C++ și cu pachetul bancă - de asemenea, foarte ușor.

În general, această experiență a fost foarte plină de satisfacții și continuăm să lucrăm pentru a rezolva unele dintre problemele ridicate.

Sursa: www.habr.com

Adauga un comentariu