Quick Draw Doodle Recognition: kaip susidraugauti su R, C++ ir neuroniniais tinklais

Quick Draw Doodle Recognition: kaip susidraugauti su R, C++ ir neuroniniais tinklais

Sveiki, Habr!

Praėjusį rudenį Kaggle surengė ranka pieštų paveikslėlių klasifikavimo konkursą „Quick Draw Doodle Recognition“, kuriame, be kitų, dalyvavo ir R mokslininkų komanda: Artemas Klevcova, Philippa vadovas и Andrejus Ogurcovas. Detaliau konkurso neaprašysime, tai jau buvo padaryta naujausia publikacija.

Šį kartą su medaliu ūkininkauti nepavyko, tačiau buvo sukaupta daug vertingos patirties, todėl noriu papasakoti bendruomenei apie įdomiausius ir naudingiausius dalykus Kagle ir kasdieniame darbe. Tarp aptartų temų: sunkus gyvenimas be OpenCV, JSON analizė (šiuose pavyzdžiuose nagrinėjamas C++ kodo integravimas į scenarijus arba paketus R programoje, naudojant Rcpp), scenarijų parametrizavimas ir galutinio sprendimo dokerizavimas. Visas pranešimo kodas tinkama vykdyti forma yra prieinamas saugyklos.

Turinys:

  1. Efektyviai įkelkite duomenis iš CSV į MonetDB
  2. Partijų ruošimas
  3. Iteratoriai partijų iškrovimui iš duomenų bazės
  4. Modelio architektūros pasirinkimas
  5. Scenarijaus parametravimas
  6. Scenarijų dokerizavimas
  7. Kelių GPU naudojimas „Google Cloud“.
  8. Vietoj išvados

1. Efektyviai įkelkite duomenis iš CSV į MonetDB duomenų bazę

Duomenys šiame konkurse pateikiami ne paruoštų vaizdų, o 340 CSV failų (po vieną failą kiekvienai klasei) pavidalu, kuriuose yra JSON su taškų koordinatėmis. Šiuos taškus sujungę linijomis, gauname galutinį 256x256 pikselių dydžio vaizdą. Taip pat prie kiekvieno įrašo yra etiketė, nurodanti, ar paveikslėlį teisingai atpažino klasifikatorius, naudotas renkant duomenų rinkinį, dviejų raidžių nuotraukos autoriaus gyvenamosios šalies kodas, unikalus identifikatorius, laiko žyma. ir klasės pavadinimą, atitinkantį failo pavadinimą. Supaprastinta originalių duomenų versija archyve sveria 7.4 GB, o išpakavus – maždaug 20 GB, visi duomenys po išpakavimo užima 240 GB. Organizatoriai užtikrino, kad abiejose versijose būtų atkartoti tie patys brėžiniai, o tai reiškia, kad visa versija buvo perteklinė. Bet kokiu atveju 50 milijonų vaizdų saugojimas grafiniuose failuose arba masyvų pavidalu buvo nedelsiant laikomas nepelningu, todėl nusprendėme sujungti visus CSV failus iš archyvo. traukinys_supaprastintas.zip į duomenų bazę, vėliau kiekvienai partijai generuojant reikiamo dydžio vaizdus.

DBVS buvo pasirinkta gerai patikrinta sistema MonetDB, būtent R kaip paketo įgyvendinimas MonetDBLite. Į paketą įtraukta įterptoji duomenų bazės serverio versija ir leidžia pasiimti serverį tiesiai iš R seanso ir dirbti su juo. Duomenų bazės sukūrimas ir prisijungimas prie jos atliekami viena komanda:

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

Turėsime sukurti dvi lenteles: vieną – visiems duomenims, kitą – paslaugų informacijai apie atsisiųstus failus (naudinga, jei kas nors nepavyksta ir procesas turi būti atnaujintas atsisiuntus kelis failus):

Lentelių kūrimas

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

Greičiausias būdas įkelti duomenis į duomenų bazę buvo tiesiogiai nukopijuoti CSV failus naudojant SQL komandą COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTKur tablename - lentelės pavadinimas ir path - kelias į failą. Dirbant su archyvu buvo nustatyta, kad įmontuotas įgyvendinimas unzip R neveikia tinkamai su daugybe failų iš archyvo, todėl naudojome sistemą unzip (naudojant parametrą getOption("unzip")).

Funkcija rašyti į duomenų bazę

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

Jei lentelę reikia transformuoti prieš rašant ją į duomenų bazę, pakanka pateikti argumentą preprocess funkcija, kuri pakeis duomenis.

Kodas, skirtas nuosekliai įkelti duomenis į duomenų bazę:

Duomenų įrašymas į duomenų bazę

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

Duomenų įkėlimo laikas gali skirtis priklausomai nuo naudojamo disko greičio charakteristikų. Mūsų atveju skaitymas ir rašymas viename SSD arba iš „flash drive“ (šaltinio failo) į SSD (DB) trunka mažiau nei 10 minučių.

Stulpeliui su sveikųjų skaičių klasės etikete ir indekso stulpeliu sukurti prireikia dar kelių sekundžių (ORDERED INDEX) su eilučių numeriais, pagal kuriuos bus imami stebėjimai kuriant paketus:

Papildomų stulpelių ir indekso kūrimas

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

Kad išspręstume partijos sukūrimo problemą, turėjome pasiekti maksimalų atsitiktinių eilučių iš lentelės greitį. doodles. Tam panaudojome 3 triukus. Pirmasis buvo sumažinti tipo, kuriame saugomas stebėjimo ID, matmenis. Pradiniame duomenų rinkinyje ID saugojimui reikalingas tipas yra bigint, tačiau stebėjimų skaičius leidžia sutalpinti jų identifikatorius, lygius eilės skaičiui, į tipą int. Šiuo atveju paieška vyksta daug greičiau. Antrasis triukas buvo naudoti ORDERED INDEX — tokį sprendimą priėjome empiriškai, išnagrinėję visus turimus dalykus parinktys. Trečia buvo naudoti parametrizuotas užklausas. Metodo esmė – komandą vykdyti vieną kartą PREPARE vėliau naudojant paruoštą išraišką kuriant krūvą to paties tipo užklausų, tačiau iš tikrųjų yra pranašumas, palyginti su paprasta SELECT pasirodė esanti statistinės paklaidos ribose.

Duomenų įkėlimo procesas sunaudoja ne daugiau kaip 450 MB RAM. Tai yra, aprašytas metodas leidžia perkelti dešimtis gigabaitų sveriančius duomenų rinkinius beveik bet kurioje biudžetinėje aparatinėje įrangoje, įskaitant kai kuriuos vienos plokštės įrenginius, o tai yra gana puiku.

Belieka išmatuoti (atsitiktinių) duomenų gavimo greitį ir įvertinti mastelį imant įvairaus dydžio partijas:

Duomenų bazės etalonas

library(ggplot2)

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

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

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

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

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

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

DBI::dbDisconnect(con, shutdown = TRUE)

Quick Draw Doodle Recognition: kaip susidraugauti su R, C++ ir neuroniniais tinklais

2. Partijų ruošimas

Visas partijos paruošimo procesas susideda iš šių etapų:

  1. Kelių JSON, turinčių eilučių vektorius su taškų koordinatėmis, analizė.
  2. Spalvotų linijų brėžimas pagal taškų koordinates reikiamo dydžio paveikslėlyje (pvz., 256×256 arba 128×128).
  3. Gautų vaizdų pavertimas tenzoriumi.

Vykstant konkurencijai tarp Python branduolių, problema buvo išspręsta pirmiausia naudojant OpenCV. Vienas iš paprasčiausių ir akivaizdžiausių R analogų atrodytų taip:

JSON konvertavimo į Tensor diegimas 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)
}

Piešimas atliekamas naudojant standartinius R įrankius ir išsaugomas į laikiną PNG, saugomą RAM („Linux“ laikinieji R katalogai yra kataloge /tmp, sumontuotas RAM). Tada šis failas nuskaitomas kaip trimatis masyvas su skaičiais nuo 0 iki 1. Tai svarbu, nes įprastesnis BMP būtų nuskaitomas į neapdorotą masyvą su šešioliktainiais spalvų kodais.

Išbandykime rezultatą:

zip_file <- file.path("data", "train_simplified.zip")
csv_file <- "cat.csv"
unzip(zip_file, files = csv_file, exdir = tempdir(), 
      junkpaths = TRUE, unzip = getOption("unzip"))
tmp_data <- data.table::fread(file.path(tempdir(), csv_file), sep = ",", 
                              select = "drawing", nrows = 10000)
arr <- r_process_json_str(tmp_data[4, drawing])
dim(arr)
# [1] 256 256   3
plot(magick::image_read(arr))

Quick Draw Doodle Recognition: kaip susidraugauti su R, C++ ir neuroniniais tinklais

Pati partija bus suformuota taip:

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

Šis įgyvendinimas mums atrodė neoptimalus, nes didelių partijų formavimas užtrunka nepadoriai ilgai, todėl nusprendėme pasinaudoti kolegų patirtimi, pasitelkę galingą biblioteką. OpenCV. Tuo metu nebuvo paruošto R paketo (dabar jo nėra), todėl C++ buvo parašytas minimalus reikiamų funkcijų įgyvendinimas su integravimu į R kodą naudojant Rcpp.

Norėdami išspręsti problemą, buvo naudojami šie paketai ir bibliotekos:

  1. OpenCV darbui su vaizdais ir linijų piešimui. Naudojamos iš anksto įdiegtos sistemos bibliotekos ir antraščių failai, taip pat dinaminis susiejimas.

  2. xtensor darbui su daugiamačiais masyvais ir tenzoriais. Naudojome antraštės failus, įtrauktus į to paties pavadinimo R paketą. Biblioteka leidžia dirbti su daugiamačiais masyvais, tiek pagrindinės eilutės, tiek stulpelio pagrindinės eilės tvarka.

  3. ndjson JSON analizei. Ši biblioteka naudojama xtensor automatiškai, jei jis yra projekte.

  4. RcppThread organizuojant kelių gijų vektoriaus apdorojimą iš JSON. Naudojo šio paketo pateiktus antraščių failus. Iš populiaresnių RcppParallel Paketas, be kita ko, turi įmontuotą kilpos pertraukimo mechanizmą.

Reikėtų pažymėti, kad xtensor pasirodė esanti nelaimė: be to, kad jis turi platų funkcionalumą ir didelį našumą, jo kūrėjai pasirodė gana jautrūs ir greitai bei išsamiai atsakė į klausimus. Jų pagalba buvo galima įgyvendinti OpenCV matricų transformacijas į xtensor tenzorius, taip pat būdą sujungti 3 dimensijų vaizdo tenzorius į tinkamo matmens 4 dimensijos tenzorių (pačią partiją).

Rcpp, xtensor ir RcppThread mokymosi medžiaga

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

Norėdami sudaryti failus, kuriuose naudojami sistemos failai ir dinaminis susiejimas su sistemoje įdiegtomis bibliotekomis, naudojome pakete įdiegtą papildinio mechanizmą Rcpp. Norėdami automatiškai rasti kelius ir vėliavėles, naudojome populiarią „Linux“ programą pkg-config.

Rcpp įskiepio, skirto naudoti OpenCV biblioteką, įdiegimas

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

Dėl papildinio veikimo kompiliavimo metu bus pakeistos šios reikšmės:

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"

Diegimo kodas, skirtas JSON analizei ir paketo generavimui, skirtas perdavimui į modelį, pateiktas po spoileriu. Pirmiausia pridėkite vietinį projekto katalogą, kad galėtumėte ieškoti antraštės failų (reikia „ndjson“):

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

JSON konvertavimas į tensorį 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;
}

Šis kodas turi būti įdėtas į failą src/cv_xt.cpp ir sukompiliuokite su komanda Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); taip pat reikalingas darbui nlohmann/json.hppsaugykla. Kodas yra padalintas į keletą funkcijų:

  • to_xt — šabloninė funkcija vaizdo matricai transformuoti (cv::Mat) į tenzorių xt::xtensor;

  • parse_json — funkcija analizuoja JSON eilutę, išskiria taškų koordinates, supakuoja jas į vektorių;

  • ocv_draw_lines — iš gauto taškų vektoriaus nubrėžia įvairiaspalves linijas;

  • process — sujungia aukščiau nurodytas funkcijas ir taip pat prideda galimybę keisti gauto vaizdo mastelį;

  • cpp_process_json_str - apvyniokite funkciją process, kuris eksportuoja rezultatą į R objektą (daugiamatis masyvas);

  • cpp_process_json_vector - apvyniokite funkciją cpp_process_json_str, kuri leidžia apdoroti eilutės vektorių kelių gijų režimu.

Daugiaspalvėms linijoms piešti buvo naudojamas HSV spalvų modelis, po kurio buvo konvertuojamas į RGB. Išbandykime rezultatą:

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

Quick Draw Doodle Recognition: kaip susidraugauti su R, C++ ir neuroniniais tinklais
Diegimo greičio palyginimas R ir C++ kalbomis

res_bench <- bench::mark(
  r_process_json_str(tmp_data[4, drawing], scale = 0.5),
  cpp_process_json_str(tmp_data[4, drawing], scale = 0.5),
  check = FALSE,
  min_iterations = 100
)
# Параметры бенчмарка
cols <- c("expression", "min", "median", "max", "itr/sec", "total_time", "n_itr")
res_bench[, cols]

#   expression                min     median       max `itr/sec` total_time  n_itr
#   <chr>                <bch:tm>   <bch:tm>  <bch:tm>     <dbl>   <bch:tm>  <int>
# 1 r_process_json_str     3.49ms     3.55ms    4.47ms      273.      490ms    134
# 2 cpp_process_json_str   1.94ms     2.02ms    5.32ms      489.      497ms    243

library(ggplot2)
# Проведение замера
res_bench <- bench::press(
  batch_size = 2^(4:10),
  {
    .data <- tmp_data[sample(seq_len(.N), batch_size), drawing]
    bench::mark(
      r_process_json_vector(.data, scale = 0.5),
      cpp_process_json_vector(.data,  scale = 0.5),
      min_iterations = 50,
      check = FALSE
    )
  }
)

res_bench[, cols]

#    expression   batch_size      min   median      max `itr/sec` total_time n_itr
#    <chr>             <dbl> <bch:tm> <bch:tm> <bch:tm>     <dbl>   <bch:tm> <int>
#  1 r                   16   50.61ms  53.34ms  54.82ms    19.1     471.13ms     9
#  2 cpp                 16    4.46ms   5.39ms   7.78ms   192.      474.09ms    91
#  3 r                   32   105.7ms 109.74ms 212.26ms     7.69        6.5s    50
#  4 cpp                 32    7.76ms  10.97ms  15.23ms    95.6     522.78ms    50
#  5 r                   64  211.41ms 226.18ms 332.65ms     3.85      12.99s    50
#  6 cpp                 64   25.09ms  27.34ms  32.04ms    36.0        1.39s    50
#  7 r                  128   534.5ms 627.92ms 659.08ms     1.61      31.03s    50
#  8 cpp                128   56.37ms  58.46ms  66.03ms    16.9        2.95s    50
#  9 r                  256     1.15s    1.18s    1.29s     0.851     58.78s    50
# 10 cpp                256  114.97ms 117.39ms 130.09ms     8.45       5.92s    50
# 11 r                  512     2.09s    2.15s    2.32s     0.463       1.8m    50
# 12 cpp                512  230.81ms  235.6ms 261.99ms     4.18      11.97s    50
# 13 r                 1024        4s    4.22s     4.4s     0.238       3.5m    50
# 14 cpp               1024  410.48ms 431.43ms 462.44ms     2.33      21.45s    50

ggplot(res_bench, aes(x = factor(batch_size), y = median, 
                      group =  expression, color = expression)) +
  geom_point() +
  geom_line() +
  ylab("median time, s") +
  theme_minimal() +
  scale_color_discrete(name = "", labels = c("cpp", "r")) +
  theme(legend.position = "bottom") 

Quick Draw Doodle Recognition: kaip susidraugauti su R, C++ ir neuroniniais tinklais

Kaip matote, greičio padidėjimas pasirodė labai reikšmingas, o lygiagrečiuojant R kodą C++ kodo pasivyti neįmanoma.

3. Iteratoriai partijų iškrovimui iš duomenų bazės

R turi pelnytą reputaciją apdoroti duomenis, kurie telpa į RAM, o Python labiau būdingas pasikartojantis duomenų apdorojimas, leidžiantis lengvai ir natūraliai įgyvendinti ne pagrindinius skaičiavimus (skaičiavimus naudojant išorinę atmintį). Klasikinis ir mums aktualus pavyzdys aprašytos problemos kontekste yra gilieji neuroniniai tinklai, treniruojami gradiento nusileidimo metodu su gradiento aproksimavimu kiekviename žingsnyje naudojant nedidelę stebėjimų dalį arba mini partiją.

„Python“ parašytose giluminio mokymosi sistemose yra specialios klasės, kurios įdiegia iteratorius pagal duomenis: lenteles, paveikslėlius aplankuose, dvejetainius formatus ir kt. Galite naudoti paruoštas parinktis arba parašyti savo konkrečias užduotis. R programoje galime pasinaudoti visomis Python bibliotekos funkcijomis sunku su įvairiomis galinėmis programomis, naudojant to paties pavadinimo paketą, kuris savo ruožtu veikia paketo viršuje tinklinis. Pastarasis nusipelno atskiro ilgo straipsnio; tai ne tik leidžia paleisti Python kodą iš R, bet ir perkelti objektus tarp R ir Python seansų, automatiškai atliekant visas reikalingas tipo konvertacijas.

Atsikratėme poreikio saugoti visus duomenis operatyviojoje atmintyje naudodami MonetDBLite, visas „neuronų tinklo“ darbas bus atliktas originaliu Python kodu, tereikia ant duomenų parašyti iteratorių, nes nėra nieko paruošto tokiai situacijai R arba Python. Jam iš esmės keliami tik du reikalavimai: jis turi grąžinti partijas begaliniu ciklu ir išsaugoti savo būseną tarp iteracijų (pastarasis R yra įgyvendinamas paprasčiausiu būdu naudojant uždarymus). Anksčiau buvo reikalaujama aiškiai konvertuoti R masyvus į numpy masyvus iteratoriaus viduje, tačiau dabartinė paketo versija sunku daro pati.

Mokymo ir patvirtinimo duomenų iteratorius pasirodė toks:

Iteratorius, skirtas mokymo ir patvirtinimo duomenims

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

Funkcija kaip įvestį paima kintamąjį su ryšiu su duomenų baze, naudojamų eilučių skaičių, klasių skaičių, partijos dydį, mastelį (scale = 1 atitinka 256 x 256 pikselių vaizdų atvaizdavimą, scale = 0.5 - 128x128 pikseliai), spalvų indikatorius (color = FALSE nurodomas pilkos spalvos atvaizdavimas, kai naudojamas color = TRUE kiekvienas potėpis nupieštas nauja spalva) ir išankstinio apdorojimo indikatorius tinklams, iš anksto apmokytiems „imagenet“. Pastarasis reikalingas pikselių reikšmių masteliui nuo intervalo [0, 1] iki intervalo [-1, 1], kuris buvo naudojamas treniruojant pateiktą sunku modeliai.

Išorinėje funkcijoje yra argumentų tipo tikrinimas, lentelė data.table su atsitiktinai sumaišytais eilučių numeriais iš samples_index ir partijų numeriai, skaitiklis ir maksimalus partijų skaičius, taip pat SQL išraiška duomenims iš duomenų bazės iškrauti. Be to, apibrėžėme greitą funkcijos analogą viduje keras::to_categorical(). Treniruotėms panaudojome beveik visus duomenis, patvirtinimui palikome pusę procento, todėl epochos dydį ribojo parametras steps_per_epoch kai skambina keras::fit_generator(), ir sąlyga if (i > max_i) veikė tik patvirtinimo iteratoriuje.

Vidinėje funkcijoje nuskaitomi eilučių indeksai kitai paketai, įrašai iškraunami iš duomenų bazės didinant partijos skaitiklį, JSON analizavimas (funkcija cpp_process_json_vector(), parašytas C++) ir sukurti paveikslėlius atitinkančius masyvus. Tada sukuriami vienkartiniai vektoriai su klasių etiketėmis, masyvai su pikselių reikšmėmis ir etiketėmis sujungiami į sąrašą, kuris yra grąžinama reikšmė. Norėdami pagreitinti darbą, mes panaudojome indeksų kūrimą lentelėse data.table ir modifikavimas per nuorodą - be šių paketo „lustų“ duomenys. lentelė Gana sunku įsivaizduoti efektyvų darbą su dideliu duomenų kiekiu R.

„Core i5“ nešiojamojo kompiuterio greičio matavimų rezultatai yra tokie:

Iteratoriaus etalonas

library(Rcpp)
library(keras)
library(ggplot2)

source("utils/rcpp.R")
source("utils/keras_iterator.R")

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

ind <- seq_len(DBI::dbGetQuery(con, "SELECT count(*) FROM doodles")[[1L]])
num_classes <- DBI::dbGetQuery(con, "SELECT max(label_int) + 1 FROM doodles")[[1L]]

# Индексы для обучающей выборки
train_ind <- sample(ind, floor(length(ind) * 0.995))
# Индексы для проверочной выборки
val_ind <- ind[-train_ind]
rm(ind)
# Коэффициент масштаба
scale <- 0.5

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

#   batch_size      min   median      max `itr/sec` total_time n_itr
#        <dbl> <bch:tm> <bch:tm> <bch:tm>     <dbl>   <bch:tm> <int>
# 1         16     25ms  64.36ms   92.2ms     15.9       3.09s    49
# 2         32   48.4ms 118.13ms 197.24ms     8.17       5.88s    48
# 3         64   69.3ms 117.93ms 181.14ms     8.57       5.83s    50
# 4        128  157.2ms 240.74ms 503.87ms     3.85      12.71s    49
# 5        256  359.3ms 613.52ms 988.73ms     1.54       30.5s    47
# 6        512  884.7ms    1.53s    2.07s     0.674      1.11m    45
# 7       1024     2.7s    3.83s    5.47s     0.261      2.81m    44

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

DBI::dbDisconnect(con, shutdown = TRUE)

Quick Draw Doodle Recognition: kaip susidraugauti su R, C++ ir neuroniniais tinklais

Jei turite pakankamai RAM, galite rimtai pagreitinti duomenų bazės veikimą, perkeldami ją į tą pačią RAM (mūsų užduočiai pakanka 32 GB). Linux sistemoje skaidinys yra prijungtas pagal numatytuosius nustatymus /dev/shm, užimantis iki pusės RAM talpos. Redaguodami galite paryškinti daugiau /etc/fstabgauti tokį įrašą kaip tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Būtinai paleiskite iš naujo ir patikrinkite rezultatą paleisdami komandą df -h.

Bandymų duomenų iteratorius atrodo daug paprastesnis, nes bandymo duomenų rinkinys visiškai telpa į RAM:

Bandymo duomenų iteratorius

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. Modelio architektūros parinkimas

Pirmoji panaudota architektūra buvo Mobilenet v1, kurios ypatybės aptariamos tai žinutę. Jis įtrauktas kaip standartinis sunku ir, atitinkamai, yra to paties pavadinimo pakuotėje, skirta R. Tačiau bandant jį naudoti su vieno kanalo vaizdais, pasirodė keistas dalykas: įvesties tenzorius visada turi turėti matmenis (batch, height, width, 3)ty kanalų skaičiaus keisti negalima. Python nėra tokio apribojimo, todėl suskubome ir parašėme savo šios architektūros įgyvendinimą, vadovaudamiesi originaliu straipsniu (be iškritimo, kuris yra keras versijoje):

Mobilenet v1 architektūra

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

Šio metodo trūkumai yra akivaizdūs. Noriu išbandyti daugybę modelių, bet, priešingai, nenoriu perrašyti kiekvienos architektūros rankiniu būdu. Mums taip pat buvo atimta galimybė naudoti modelių, iš anksto apmokytų „imagenet“ svoriais. Kaip įprasta, dokumentų studijavimas padėjo. Funkcija get_config() leidžia gauti modelio aprašymą redaguoti tinkama forma (base_model_conf$layers - įprastas R sąrašas) ir funkcija from_config() atlieka atvirkštinį konvertavimą į modelio objektą:

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)

Dabar nėra sunku parašyti universalią funkciją, kad gautumėte bet kurią iš pateiktų sunku modeliai su svoriais arba be jų, treniruojami „imagenet“ tinkle:

Paruoštų architektūrų įkėlimo funkcija

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

Naudojant vieno kanalo vaizdus, ​​nenaudojami jokie iš anksto paruošti svoriai. Tai galima ištaisyti: naudojant funkciją get_weights() gaukite modelio svorius kaip R masyvų sąrašą, pakeiskite pirmojo šio sąrašo elemento matmenį (paimdami vieną spalvų kanalą arba apskaičiuodami visų trijų vidurkį), tada įkelkite svorius atgal į modelį naudodami funkciją set_weights(). Niekada nepridėjome šios funkcijos, nes jau šiame etape buvo aišku, kad produktyviau dirbti su spalvotomis nuotraukomis.

Daugumą eksperimentų atlikome naudodami 1 ir 2 mobiliojo tinklo versijas, taip pat resnet34. Šiame konkurse gerai pasirodė modernesnės architektūros, tokios kaip SE-ResNeXt. Deja, mes neturėjome paruoštų diegimų ir savo neparašėme (bet būtinai parašysime).

5. Scenarijų parametrizavimas

Patogumui visas mokymo pradžios kodas buvo sukurtas kaip vienas scenarijus, parametrizuotas naudojant docpt taip:

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)

Pakuotė docpt reprezentuoja įgyvendinimą http://docopt.org/ už R. Su jo pagalba paleidžiami scenarijai su paprastomis komandomis, pvz Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db arba ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, jei failas train_nn.R yra vykdomas (ši komanda pradės treniruoti modelį resnet50 ant trijų spalvų 128x128 pikselių vaizdų duomenų bazė turi būti aplanke /home/andrey/doodle_db). Į sąrašą galite įtraukti mokymosi greitį, optimizatoriaus tipą ir bet kokius kitus tinkinamus parametrus. Rengiant leidinį paaiškėjo, kad architektūra mobilenet_v2 nuo dabartinės versijos sunku naudojant R negaliu dėl pakeitimų, į kuriuos neatsižvelgta R pakete, laukiame, kol juos pataisys.

Šis metodas leido žymiai paspartinti eksperimentus su skirtingais modeliais, palyginti su tradiciniu scenarijų paleidimu RStudio (atkreipiame dėmesį į paketą kaip į galimą alternatyvą tfruns). Tačiau pagrindinis privalumas yra galimybė lengvai valdyti scenarijų paleidimą „Docker“ arba tiesiog serveryje, neįdiegus RStudio.

6. Scenarijų dokerizavimas

Naudojome „Docker“, kad užtikrintume aplinkos perkeliamumą mokymo modeliams tarp komandos narių ir greitam diegimui debesyje. Susipažinti su šiuo R programuotojui gana neįprastu įrankiu galite pradėti su tai publikacijų serijos arba vaizdo kursas.

„Docker“ leidžia kurti savo vaizdus nuo nulio ir naudoti kitus vaizdus kaip pagrindą kuriant savo. Analizuodami galimas parinktis padarėme išvadą, kad NVIDIA, CUDA+cuDNN tvarkyklių ir Python bibliotekų įdiegimas yra gana didelė vaizdo dalis, todėl nusprendėme remtis oficialiu vaizdu. tensorflow/tensorflow:1.12.0-gpu, pridedant ten reikiamus R paketus.

Galutinis docker failas atrodė taip:

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

Patogumui panaudotos pakuotės buvo suskirstytos į kintamuosius; Didžioji dalis parašytų scenarijų surinkimo metu nukopijuojama į konteinerius. Taip pat pakeitėme komandų apvalkalą į /bin/bash kad būtų patogu naudotis turiniu /etc/os-release. Taip išvengta būtinybės kode nurodyti OS versiją.

Be to, buvo parašytas mažas bash scenarijus, leidžiantis paleisti konteinerį su įvairiomis komandomis. Pavyzdžiui, tai gali būti scenarijai, skirti apmokyti neuroninius tinklus, kurie anksčiau buvo sudėti konteinerio viduje, arba komandų apvalkalas, skirtas derinti ir stebėti konteinerio veikimą:

Scenarijus konteineriui paleisti

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

Jei šis bash scenarijus vykdomas be parametrų, scenarijus bus iškviestas konteinerio viduje train_nn.R su numatytosiomis reikšmėmis; jei pirmasis pozicijos argumentas yra "bash", konteineris prasidės interaktyviai su komandos apvalkalu. Visais kitais atvejais pozicijos argumentų reikšmės pakeičiamos: CMD="Rscript /app/train_nn.R $@".

Verta paminėti, kad katalogai su šaltinio duomenimis ir duomenų baze, taip pat apmokytų modelių išsaugojimo katalogas yra sumontuoti talpyklos viduje iš pagrindinės sistemos, o tai leidžia pasiekti scenarijų rezultatus be nereikalingų manipuliacijų.

7. Kelių GPU naudojimas „Google Cloud“.

Viena iš konkurso ypatybių buvo labai triukšmingi duomenys (žr. titulinį paveikslėlį, pasiskolintas iš @Leigh.plt iš ODS slack). Didelės partijos padeda su tuo kovoti, ir po eksperimentų su kompiuteriu su 1 GPU nusprendėme įvaldyti mokymo modelius keliuose GPU debesyje. Naudojamas GoogleCloud (geras pagrindų vadovas) dėl didelio galimų konfigūracijų pasirinkimo, priimtinų kainų ir 300 USD premijos. Iš godumo užsisakiau 4xV100 egzempliorių su SSD ir daugybe RAM, ir tai buvo didelė klaida. Toks aparatas greitai suvalgo pinigus; galite žlugti eksperimentuodami be patikrinto vamzdyno. Švietimo tikslais geriau pasiimti K80. Tačiau didelis RAM kiekis pravertė – debesies SSD savo našumu nesužavėjo, todėl duomenų bazė buvo perkelta į dev/shm.

Didžiausią susidomėjimą kelia kodo fragmentas, atsakingas už kelių GPU naudojimą. Pirma, modelis sukuriamas CPU naudojant konteksto tvarkyklę, kaip ir 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
  )
})

Tada nesukompiliuotas (tai svarbu) modelis nukopijuojamas į tam tikrą skaičių galimų GPU ir tik po to sukompiliuojamas:

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

Klasikinės visų sluoksnių, išskyrus paskutinį, užšaldymo, paskutinio sluoksnio mokymo, viso modelio atšaldymo ir perkvalifikavimo keliems GPU technikos nepavyko įgyvendinti.

Treniruotės buvo stebimos nenaudojant. tenzoro lenta, apsiribodami žurnalų įrašymu ir modelių išsaugojimu informatyviais pavadinimais po kiekvienos epochos:

Atgaliniai skambučiai

# Шаблон имени файла лога
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. Vietoj išvados

Kai kurios problemos, su kuriomis susidūrėme, dar nebuvo įveiktos:

  • в sunku nėra paruoštos funkcijos, leidžiančios automatiškai ieškoti optimalaus mokymosi greičio (analoginis lr_finder bibliotekoje greitai.ai); Su tam tikromis pastangomis galima perkelti trečiųjų šalių diegimus į R, pavyzdžiui, tai;
  • dėl ankstesnio punkto, naudojant kelis GPU, nebuvo įmanoma pasirinkti tinkamo treniruočių greičio;
  • trūksta modernių neuroninių tinklų architektūrų, ypač tų, kurios iš anksto apmokytos „imagenet“;
  • jokios ciklo politikos ir diskriminacinių mokymosi tempų (mūsų prašymu buvo atliktas kosinuso atkaitinimas įgyvendinta, dėkoju skeydan).

Ko naudingo išmokote iš šio konkurso:

  • Naudodami santykinai mažos galios aparatinę įrangą galite be skausmo dirbti su pakankamais (daug kartų didesniais už RAM) kiekiais duomenų. Plastikinis maišelis duomenys. lentelė taupo atmintį dėl vietoje modifikuotų lentelių, kurios išvengia jų kopijavimo, o teisingai naudojant jos galimybės beveik visada demonstruoja didžiausią greitį tarp visų mums žinomų skriptų kalbų įrankių. Duomenų išsaugojimas duomenų bazėje leidžia daugeliu atvejų visai negalvoti apie būtinybę išspausti visą duomenų rinkinį į RAM.
  • Lėtas funkcijas R galima pakeisti greitosiomis C++ naudojant paketą Rcpp. Jei be naudojimo RcppThread arba RcppParallel, gauname kelių platformų kelių gijų diegimus, todėl nereikia lygiagretinti kodo R lygiu.
  • Paketas Rcpp gali būti naudojamas be rimtų C++ žinių, nurodytas reikalingas minimumas čia. Antraštės failai daugeliui puikių C bibliotekų, pvz xtensor prieinama CRAN, tai yra, formuojama infrastruktūra projektams, integruojantiems paruoštą didelio našumo C++ kodą į R, įgyvendinti. Papildomas patogumas yra sintaksės paryškinimas ir statinis C++ kodo analizatorius RStudio.
  • docpt leidžia paleisti savarankiškus scenarijus su parametrais. Tai patogu naudoti nuotoliniame serveryje, įskaitant. pagal dokerį. RStudio yra nepatogu atlikti daugybę valandų eksperimentų su neuroniniais tinklais, o IDE diegimas pačiame serveryje ne visada pagrįstas.
  • „Docker“ užtikrina kodo perkeliamumą ir rezultatų atkuriamumą tarp kūrėjų, turinčių skirtingas OS versijas ir bibliotekas, taip pat lengvą vykdymą serveriuose. Galite paleisti visą mokymo vamzdyną tik viena komanda.
  • „Google Cloud“ yra ekonomiškas būdas eksperimentuoti su brangia technine įranga, tačiau turite atidžiai pasirinkti konfigūracijas.
  • Atskirų kodo fragmentų greičio matavimas yra labai naudingas, ypač derinant R ir C++ bei su paketu suolas - taip pat labai lengva.

Apskritai ši patirtis buvo labai naudinga, todėl toliau dirbame, kad išspręstume kai kurias iškeltas problemas.

Šaltinis: www.habr.com

Добавить комментарий