Quick Draw Doodle Recognition: kuinka ystävystyä R-, C++- ja hermoverkkojen kanssa

Quick Draw Doodle Recognition: kuinka ystävystyä R-, C++- ja hermoverkkojen kanssa

Hei Habr!

Viime syksynä Kaggle isännöi käsin piirrettyjen kuvien luokittelukilpailua, Quick Draw Doodle Recognition, johon osallistui mm. R-tieteilijöitä: Artem Klevtsova, Philippa Manager и Andrei Ogurtsov. Emme kuvaile kilpailua yksityiskohtaisesti, se on jo tehty tuore julkaisu.

Tällä kertaa mitaliviljelyllä ei onnistunut, mutta arvokasta kokemusta saatiin paljon, joten haluan kertoa yhteisölle useista mielenkiintoisimmista ja hyödyllisimmistä asioista Kaglessa ja arjen työssä. Keskusteltujen aiheiden joukossa: vaikea elämä ilman OpenCV, JSON-jäsennys (näissä esimerkeissä tarkastellaan C++-koodin integrointia komentosarjoihin tai paketeihin R:ssä käyttämällä Rcpp), komentosarjojen parametrointi ja lopullisen ratkaisun telakointi. Kaikki viestin koodi suoritukseen sopivassa muodossa on saatavilla arkistot.

Sisältö:

  1. Lataa tiedot tehokkaasti CSV:stä MonetDB:hen
  2. Erien valmistelu
  3. Iteraattorit erien purkamiseen tietokannasta
  4. Malliarkkitehtuurin valitseminen
  5. Skriptin parametrointi
  6. Skriptien telakointi
  7. Useiden GPU:iden käyttäminen Google Cloudissa
  8. Sen sijaan johtopäätös

1. Lataa tiedot tehokkaasti CSV-tiedostosta MonetDB-tietokantaan

Tämän kilpailun tiedot eivät toimiteta valmiina kuvina, vaan 340 CSV-tiedostona (yksi tiedosto jokaiselle luokalle), jotka sisältävät JSONit pistekoordinaateineen. Yhdistämällä nämä pisteet viivoilla saamme lopullisen kuvan, jonka koko on 256x256 pikseliä. Jokaisessa tietueessa on myös tarra, joka kertoo, tunnistiko aineiston keräyshetkellä käytetty luokitin kuvan oikein, kuvan tekijän asuinmaan kaksikirjaiminen koodi, yksilöllinen tunniste, aikaleima ja luokan nimi, joka vastaa tiedoston nimeä. Yksinkertaistettu versio alkuperäisestä tiedosta painaa arkistossa 7.4 Gt ja noin 20 Gt pakkauksesta purkamisen jälkeen, koko data pakkauksen purkamisen jälkeen vie 240 Gt. Järjestäjät varmistivat, että molemmissa versioissa toistettiin samat piirustukset, mikä tarkoittaa, että täysversio oli tarpeeton. Joka tapauksessa 50 miljoonan kuvan tallentamista grafiikkatiedostoihin tai taulukoiden muodossa pidettiin välittömästi kannattamattomana, ja päätimme yhdistää kaikki CSV-tiedostot arkistosta. train_simplified.zip tietokantaan, jolloin kullekin erälle luodaan vaaditun kokoiset kuvat "lennossa".

DBMS:ksi valittiin hyvin todistettu järjestelmä MonetDB, nimittäin toteutus R:lle pakettina MonetDBLite. Paketti sisältää tietokantapalvelimen sulautetun version, jonka avulla voit noutaa palvelimen suoraan R-istunnosta ja työskennellä sen kanssa siellä. Tietokannan luominen ja siihen yhdistäminen suoritetaan yhdellä komennolla:

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

Meidän on luotava kaksi taulukkoa: yksi kaikille tiedoille, toinen ladattujen tiedostojen palvelutiedoille (hyödyllinen, jos jokin menee pieleen ja prosessia on jatkettava useiden tiedostojen lataamisen jälkeen):

Taulukoiden luominen

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

Nopein tapa ladata tiedot tietokantaan oli kopioida CSV-tiedostoja suoraan komennolla SQL - COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTMissä tablename - taulukon nimi ja path - tiedoston polku. Työskennellessään arkiston kanssa havaittiin, että sisäänrakennettu toteutus unzip R:ssä ei toimi oikein useiden arkistotiedostojen kanssa, joten käytimme järjestelmää unzip (käyttäen parametria getOption("unzip")).

Toiminto tietokantaan kirjoittamista varten

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

Jos taulukko on muutettava ennen sen kirjoittamista tietokantaan, riittää argumentin välittäminen preprocess toiminto, joka muuttaa tiedot.

Koodi tietojen peräkkäiseen lataamiseen tietokantaan:

Tietojen kirjoittaminen tietokantaan

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

Tietojen latausaika voi vaihdella käytetyn taajuusmuuttajan nopeusominaisuuksien mukaan. Meidän tapauksessamme lukeminen ja kirjoittaminen yhdellä SSD-levyllä tai flash-asemalta (lähdetiedostolta) SSD-levylle (DB) kestää alle 10 minuuttia.

Kestää vielä muutaman sekunnin luodaksesi sarakkeen, jossa on kokonaislukuluokkatunniste ja indeksisarake (ORDERED INDEX) rivinumeroilla, joiden perusteella havainnoista otetaan näyte eriä luotaessa:

Lisäsarakkeiden ja indeksien luominen

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

Erän luomisen lennossa ongelman ratkaisemiseksi meidän piti saavuttaa maksiminopeus satunnaisten rivien poimimiselle taulukosta doodles. Tätä varten käytimme 3 temppua. Ensimmäinen oli havaintotunnuksen tallentavan tyypin dimensioitumisen vähentäminen. Alkuperäisessä tietojoukossa tunnuksen tallentamiseen vaadittava tyyppi on bigint, mutta havaintojen lukumäärä mahdollistaa niiden tunnisteiden sovittamisen järjestyslukua vastaavaan tyyppiin int. Haku on tässä tapauksessa paljon nopeampi. Toinen temppu oli käyttää ORDERED INDEX — Päädyimme tähän päätökseen empiirisesti, käytyämme läpi kaikki saatavilla olevat варианты. Kolmas oli parametrisoitujen kyselyjen käyttö. Menetelmän ydin on suorittaa komento kerran PREPARE kun valmisteltua lauseketta käytetään myöhemmin luotaessa joukko samantyyppisiä kyselyitä, mutta itse asiassa sillä on etu yksinkertaiseen kyselyyn verrattuna SELECT osoittautui tilastovirheen rajoissa.

Tietojen latausprosessi kuluttaa enintään 450 Mt RAM-muistia. Toisin sanoen kuvatun lähestymistavan avulla voit siirtää kymmeniä gigatavuja painavia tietojoukkoja lähes millä tahansa budjettilaitteistolla, mukaan lukien jotkin yksilevylaitteet, mikä on melko siistiä.

Jäljelle jää vain mitata (satunnaisten) tietojen noudon nopeus ja arvioida skaalaus otettaessa erikokoisia eriä:

Tietokannan benchmark

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: kuinka ystävystyä R-, C++- ja hermoverkkojen kanssa

2. Erien valmistaminen

Koko erän valmistusprosessi koostuu seuraavista vaiheista:

  1. Jäsentää useita JSON-tiedostoja, jotka sisältävät merkkijonovektoreita pistekoordinaateilla.
  2. Värillisten viivojen piirtäminen halutun kokoisen kuvan pisteiden koordinaattien perusteella (esim. 256×256 tai 128×128).
  3. Tuloksena olevien kuvien muuntaminen tensoriksi.

Osana Python-ytimien välistä kilpailua ongelma ratkaistiin ensisijaisesti käyttämällä OpenCV. Yksi R:n yksinkertaisimmista ja ilmeisimmistä analogeista näyttäisi tältä:

JSON-tensor-muunnoksen käyttöönotto R:ssä

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

Piirustus suoritetaan tavallisilla R-työkaluilla ja tallennetaan väliaikaiseen PNG-muotoon, joka on tallennettu RAM-muistiin (Linuxissa väliaikaiset R-hakemistot sijaitsevat hakemistossa /tmp, asennettu RAM-muistiin). Tämä tiedosto luetaan sitten kolmiulotteisena taulukkona, jonka numerot vaihtelevat välillä 0 - 1. Tämä on tärkeää, koska perinteisempi BMP luettaisiin raakataulukoksi heksadesimaattisilla värikoodeilla.

Testataan tulosta:

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: kuinka ystävystyä R-, C++- ja hermoverkkojen kanssa

Itse erä muodostetaan seuraavasti:

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

Tämä toteutus tuntui meistä epäoptimaaliselta, koska suurten erien muodostaminen kestää kohtuuttoman kauan ja päätimme hyödyntää kollegojemme kokemusta tehokkaan kirjaston avulla. OpenCV. Tuolloin R:lle ei ollut valmiita paketteja (ei ole nytkään), joten tarvittava toiminnallisuus kirjoitettiin minimaalisesti C++:lla integroimalla R-koodiin käyttämällä Rcpp.

Ongelman ratkaisemiseksi käytettiin seuraavia paketteja ja kirjastoja:

  1. OpenCV kuvien käsittelyyn ja viivojen piirtämiseen. Käytetyt esiasennetut järjestelmäkirjastot ja otsikkotiedostot sekä dynaaminen linkitys.

  2. xtensori moniulotteisten taulukoiden ja tensorien kanssa työskentelyyn. Käytimme samannimiseen R-pakettiin sisältyviä otsikkotiedostoja. Kirjaston avulla voit työskennellä moniulotteisten taulukoiden kanssa sekä rivi- että sarakepääjärjestyksessä.

  3. ndjson JSON-jäsennykseen. Tätä kirjastoa käytetään xtensori automaattisesti, jos se on mukana projektissa.

  4. RcppThread JSON-vektorin monisäikeisen käsittelyn järjestämiseen. Käytettiin tämän paketin toimittamia otsikkotiedostoja. Suosituimmasta RcppParallel Paketissa on muun muassa sisäänrakennettu silmukan keskeytysmekanismi.

On syytä huomata, että xtensori osoittautui jumalan lahjaksi: sen lisäksi, että sillä on laaja toiminnallisuus ja korkea suorituskyky, sen kehittäjät osoittautuivat varsin reagoiviksi ja vastasivat kysymyksiin nopeasti ja yksityiskohtaisesti. Niiden avulla pystyttiin toteuttamaan OpenCV-matriisien muunnoksia xtensortensoreiksi sekä tapa yhdistää 3-ulotteiset kuvatensorit oikean mittaisen 4-ulotteiseksi tensoriksi (itse erä).

Materiaalit Rcpp, xtensor ja RcppThread oppimiseen

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

Kokoamaan tiedostot, jotka käyttävät järjestelmätiedostoja ja dynaamista linkitystä järjestelmään asennettuihin kirjastoihin, käytimme paketissa toteutettua laajennusmekanismia Rcpp. Etsiäksemme polut ja liput automaattisesti käytimme suosittua Linux-apuohjelmaa pkg-config.

Rcpp-laajennuksen toteutus OpenCV-kirjaston käyttöä varten

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

Liitännäisen toiminnan seurauksena seuraavat arvot korvataan käännösprosessin aikana:

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"

Toteutuskoodi JSON-jäsentämiseksi ja erän luomiseksi malliin lähetystä varten on annettu spoilerin alla. Lisää ensin paikallinen projektihakemisto etsiäksesi otsikkotiedostoja (tarvitaan ndjsonille):

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

JSON-tensorin muuntaminen C++:ssa

// [[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;
}

Tämä koodi tulee sijoittaa tiedostoon src/cv_xt.cpp ja käännä komennolla Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); tarvitaan myös työhön nlohmann/json.hpp ja arkisto. Koodi on jaettu useisiin toimintoihin:

  • to_xt — mallipohjainen toiminto kuvamatriisin muuntamiseksi (cv::Mat) tensoriksi xt::xtensor;

  • parse_json — funktio jäsentää JSON-merkkijonon, poimii pisteiden koordinaatit ja pakkaa ne vektoriksi;

  • ocv_draw_lines — piirtää tuloksena olevasta pistevektorista monivärisiä viivoja;

  • process — yhdistää yllä olevat toiminnot ja lisää myös mahdollisuuden skaalata tuloksena olevaa kuvaa;

  • cpp_process_json_str - kääri toiminnon päälle process, joka vie tuloksen R-objektiin (moniulotteinen array);

  • cpp_process_json_vector - kääri toiminnon päälle cpp_process_json_str, jonka avulla voit käsitellä merkkijonovektoria monisäikeisessä tilassa.

Moniväristen viivojen piirtämiseen käytettiin HSV-värimallia, jota seurasi muuntaminen RGB:ksi. Testataan tulosta:

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

Quick Draw Doodle Recognition: kuinka ystävystyä R-, C++- ja hermoverkkojen kanssa
R- ja C++-toteutusten nopeuden vertailu

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: kuinka ystävystyä R-, C++- ja hermoverkkojen kanssa

Kuten näette, nopeuden lisäys osoittautui erittäin merkittäväksi, eikä C++-koodia ole mahdollista saada kiinni rinnastamalla R-koodia.

3. Iteraattorit erien purkamiseen tietokannasta

R:llä on ansaittu maine RAM-muistiin mahtuvien tietojen käsittelyssä, kun taas Pythonille on ominaista iteratiivinen tietojenkäsittely, jonka avulla voit helposti ja luonnollisesti toteuttaa ytimen ulkopuolisia laskelmia (ulkoista muistia käyttävät laskelmat). Klassinen ja meille merkityksellinen esimerkki kuvatun ongelman yhteydessä ovat syvät hermoverkot, jotka on koulutettu gradienttilaskeutumismenetelmällä gradientin likiarvolla jokaisessa vaiheessa käyttämällä pientä osaa havainnoista tai mini-erä.

Pythonilla kirjoitetuissa syväoppimiskehyksissä on erityisluokat, jotka toteuttavat tietoihin perustuvia iteraattoreita: taulukoita, kansiossa olevia kuvia, binäärimuotoja jne. Voit käyttää valmiita vaihtoehtoja tai kirjoittaa omia tiettyihin tehtäviin. R:ssä voimme hyödyntää kaikkia Python-kirjaston ominaisuuksia Keras erilaisilla taustaohjelmillaan käyttämällä samannimistä pakettia, joka puolestaan ​​​​toimii paketin päällä verkkomainen. Jälkimmäinen ansaitsee erillisen pitkän artikkelin; sen avulla voit suorittaa Python-koodin R:stä, mutta myös siirtää objekteja R- ja Python-istuntojen välillä suorittamalla automaattisesti kaikki tarvittavat tyyppimuunnokset.

Pääsimme eroon tarpeesta tallentaa kaikki tiedot RAM-muistiin MonetDBLiten avulla, kaikki "hermoverkkotyöt" suorittaa alkuperäisellä Python-koodilla, meidän on vain kirjoitettava iteraattori tietojen päälle, koska mitään ei ole valmiina sellaiseen tilanteeseen joko R:ssä tai Pythonissa. Sille on periaatteessa vain kaksi vaatimusta: sen on palautettava erät loputtomassa silmukassa ja tallennettava tila iteraatioiden välillä (jälkimmäinen R:ssä toteutetaan yksinkertaisimmalla tavalla sulkemisilla). Aikaisemmin R-taulukot piti eksplisiittisesti muuntaa iteraattorin sisällä numpy-taulukoiksi, mutta paketin nykyinen versio Keras tekee sen itse.

Harjoittelu- ja validointitietojen iteraattori osoittautui seuraavaksi:

Iteraattori koulutus- ja validointitiedoille

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

Funktio ottaa syötteeksi muuttujan, jolla on yhteys tietokantaan, käytettyjen rivien lukumäärät, luokkien lukumäärä, erän koko, mittakaava (scale = 1 vastaa 256 x 256 pikselin kuvien renderöintiä, scale = 0.5 — 128x128 pikseliä), väriosoitin (color = FALSE määrittää renderöinnin harmaasävyisenä käytettäessä color = TRUE jokainen veto piirretään uudella värillä) ja esikäsittelyindikaattori verkoille, jotka on esikoulutettu imagenetissä. Jälkimmäistä tarvitaan pikseliarvojen skaalaamiseksi intervallista [0, 1] intervalliin [-1, 1], jota käytettiin opetettaessa toimitettua Keras mallit.

Ulkoinen funktio sisältää argumenttityypin tarkistuksen, taulukon data.table satunnaisesti sekoitettujen rivinumeroiden kanssa samples_index ja eränumerot, laskuri ja erien enimmäismäärä sekä SQL-lauseke tietojen purkamiseksi tietokannasta. Lisäksi määritimme sisällä olevalle funktiolle nopean analogin keras::to_categorical(). Käytimme lähes kaikkia tietoja koulutukseen, jättäen puoli prosenttia validointiin, joten parametri rajoitti aikakauden kokoa steps_per_epoch kun soitetaan keras::fit_generator(), ja kunto if (i > max_i) toimi vain validointiiteraattorissa.

Sisäisessä toiminnossa haetaan riviindeksit seuraavaa erää varten, tietueet puretaan tietokannasta erälaskurin kasvaessa, JSON-jäsennys (toiminto cpp_process_json_vector(), kirjoitettu C++) ja luo kuvia vastaavia taulukoita. Sitten luodaan one-hot vektorit luokkatunnisteilla, taulukot pikseliarvoilla ja tunnisteilla yhdistetään luetteloksi, joka on palautusarvo. Työn nopeuttamiseksi käytimme indeksien luomista taulukoihin data.table ja muokkaus linkin kautta - ilman näitä pakettisiruja data. taulukko On melko vaikea kuvitella toimivaa tehokkaasti minkään merkittävän tietomäärän kanssa R:ssä.

Core i5 -kannettavan nopeusmittausten tulokset ovat seuraavat:

Iteraattorin vertailuarvo

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: kuinka ystävystyä R-, C++- ja hermoverkkojen kanssa

Jos sinulla on riittävästi RAM-muistia, voit vakavasti nopeuttaa tietokantaa siirtämällä sen samaan RAM-muistiin (32 Gt riittää tehtäväämme). Linuxissa osio asennetaan oletusarvoisesti /dev/shm, vievät jopa puolet RAM-kapasiteetista. Voit korostaa enemmän muokkaamalla /etc/fstabsaada ennätys, kuten tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Muista käynnistää uudelleen ja tarkistaa tulos suorittamalla komento df -h.

Testitietojen iteraattori näyttää paljon yksinkertaisemmalta, koska testitietojoukko sopii kokonaan RAM-muistiin:

Iteraattori testidatalle

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. Mallin arkkitehtuurin valinta

Ensimmäinen käytetty arkkitehtuuri oli mobiiliverkko v1, jonka ominaisuuksia käsitellään kohdassa tämä viesti. Se sisältyy vakiona Keras ja vastaavasti on saatavana samannimisessä paketissa R:lle. Mutta kun yritettiin käyttää sitä yksikanavaisten kuvien kanssa, kävi outo asia: syöttötensorilla on aina oltava mitat (batch, height, width, 3)eli kanavien määrää ei voi muuttaa. Pythonissa ei ole tällaista rajoitusta, joten kiirehdimme ja kirjoitimme oman toteutuksen tästä arkkitehtuurista alkuperäisen artikkelin mukaisesti (ilman keras-versiossa olevaa dropoutia):

Mobilenet v1 -arkkitehtuuri

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

Tämän lähestymistavan haitat ovat ilmeiset. Haluan testata monia malleja, mutta päinvastoin, en halua kirjoittaa jokaista arkkitehtuuria uudelleen manuaalisesti. Meiltä evättiin myös mahdollisuus käyttää imagenetissä esikoulutettujen mallien painoja. Kuten tavallista, dokumenttien tutkiminen auttoi. Toiminto get_config() voit saada kuvauksen mallista muokattavaksi sopivassa muodossa (base_model_conf$layers - tavallinen R-luettelo) ja funktio from_config() suorittaa käänteisen muunnoksen malliobjektiksi:

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)

Nyt ei ole vaikeaa kirjoittaa universaalia funktiota minkä tahansa toimitetun hankkimiseksi Keras mallit painoilla tai ilman, jotka on treenattu imagenetissä:

Toiminto valmiiden arkkitehtuurien lataamiseen

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

Yksikanavaisia ​​kuvia käytettäessä ei käytetä esikoulutettuja painoja. Tämä voidaan korjata: käyttämällä toimintoa get_weights() hanki mallin painot R-taulukoiden luettelon muodossa, muuta tämän luettelon ensimmäisen elementin mittaa (ottamalla yksi värikanava tai laskemalla kaikkien kolmen keskiarvon) ja lataa painot sitten takaisin malliin funktiolla. set_weights(). Emme koskaan lisänneet tätä toimintoa, koska jo tässä vaiheessa oli selvää, että värikuvien työstäminen oli tuottavampaa.

Teimme suurimman osan kokeista käyttämällä mobilenet-versioita 1 ja 2 sekä resnet34:ää. Nykyaikaisemmat arkkitehtuurit, kuten SE-ResNeXt, menestyivät hyvin tässä kilpailussa. Valitettavasti meillä ei ollut valmiita toteutuksia käytettävissämme, emmekä kirjoittaneet omia (mutta kirjoitamme ehdottomasti).

5. Skriptien parametrointi

Mukavuussyistä kaikki harjoituksen aloituskoodi suunniteltiin yhdeksi skriptiksi, joka parametroitiin käyttämällä docpt seuraavasti:

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)

paketti docpt edustaa toteutusta http://docopt.org/ R:lle. Sen avulla skriptit käynnistetään yksinkertaisilla komennoilla, kuten Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db tai ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, jos tiedosto train_nn.R on suoritettava (tämä komento aloittaa mallin koulutuksen resnet50 kolmivärisissä kuvissa, joiden koko on 128x128 pikseliä, tietokannan on sijaittava kansiossa /home/andrey/doodle_db). Voit lisätä luetteloon oppimisnopeuden, optimoijan tyypin ja muita mukautettavia parametreja. Julkaisua valmisteltaessa kävi ilmi, että arkkitehtuuri mobilenet_v2 nykyisestä versiosta Keras R-käytössä ei voi R-paketissa huomioimattomien muutosten vuoksi odotamme heidän korjaavan sen.

Tämä lähestymistapa mahdollisti merkittävästi nopeuttaa kokeiluja eri malleilla verrattuna perinteisempään komentosarjojen käynnistämiseen RStudiossa (huomioimme paketin mahdollisena vaihtoehtona tfruns). Mutta tärkein etu on kyky hallita skriptien käynnistämistä helposti Dockerissa tai yksinkertaisesti palvelimella asentamatta RStudiota tähän.

6. Skriptien telakointi

Käytimme Dockeria varmistaaksemme ympäristön siirrettävyyden koulutusmalleille tiimin jäsenten välillä ja nopeaan käyttöönoton pilvessä. Voit aloittaa tutustumisen tähän R-ohjelmoijalle suhteellisen epätavalliseen työkaluun tämä julkaisusarja tai videokurssi.

Dockerin avulla voit luoda omia kuviasi alusta alkaen ja käyttää muita kuvia oman luomisen perustana. Analysoidessamme käytettävissä olevia vaihtoehtoja tulimme siihen tulokseen, että NVIDIA-, CUDA+cuDNN-ajureiden ja Python-kirjastojen asentaminen on melko suuri osa kuvaa, ja päätimme ottaa virallisen kuvan pohjaksi. tensorflow/tensorflow:1.12.0-gpu, lisäämällä sinne tarvittavat R-paketit.

Lopullinen Docker-tiedosto näytti tältä:

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

Mukavuuden vuoksi käytetyt paketit laitettiin muuttujiksi; Suurin osa kirjoitetuista skripteistä kopioidaan säiliöiden sisään kokoonpanon aikana. Muutimme myös komentotulkin muotoon /bin/bash sisällön käytön helpottamiseksi /etc/os-release. Näin vältyttiin OS-version määrittämiseltä koodissa.

Lisäksi kirjoitettiin pieni bash-skripti, jonka avulla voit käynnistää kontin erilaisilla komennoilla. Nämä voivat olla esimerkiksi komentosarjoja, joilla opetetaan hermoverkkoja, jotka oli aiemmin sijoitettu säilön sisälle, tai komentotulkki vianmääritykseen ja säilön toiminnan valvontaan:

Säilön käynnistävä komentosarja

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

Jos tämä bash-skripti suoritetaan ilman parametreja, komentosarjaa kutsutaan säilön sisällä train_nn.R oletusarvoilla; jos ensimmäinen sijaintiargumentti on "bash", säilö alkaa interaktiivisesti komentokuljella. Kaikissa muissa tapauksissa paikkaargumenttien arvot korvataan: CMD="Rscript /app/train_nn.R $@".

On syytä huomata, että hakemistot, joissa on lähdetiedot ja tietokanta, sekä hakemisto koulutettujen mallien tallentamista varten, on asennettu kontin sisään isäntäjärjestelmästä, jonka avulla voit käyttää komentosarjojen tuloksia ilman tarpeettomia manipulaatioita.

7. Useiden GPU:iden käyttäminen Google Cloudissa

Yksi kilpailun ominaisuuksista oli erittäin meluisa data (katso otsikkokuva, lainattu osoitteesta @Leigh.plt ODS slackilta). Suuret erät auttavat torjumaan tätä, ja 1 GPU:lla varustetulla PC-tietokoneella tehtyjen kokeilujen jälkeen päätimme hallita harjoitusmalleja useissa pilvessä olevissa GPU:issa. Käytetty GoogleCloud (hyvä opas perusasioihin) saatavilla olevien kokoonpanojen suuren valikoiman, kohtuullisten hintojen ja 300 dollarin bonuksen ansiosta. Ahneudesta tilasin 4xV100-instanssin, jossa oli SSD ja tonni RAM-muistia, ja se oli suuri virhe. Tällainen kone kuluttaa rahaa nopeasti; voit mennä konkurssiin kokeilemalla ilman todistettua putkistoa. Koulutustarkoituksiin on parempi ottaa K80. Mutta suuri määrä RAM-muistia tuli tarpeeseen - pilvi-SSD ei vaikuttanut suorituskyvyllään, joten tietokanta siirrettiin dev/shm.

Suurin mielenkiinto on koodifragmentti, joka vastaa useiden GPU:iden käytöstä. Ensin malli luodaan CPU:ssa kontekstinhallinnan avulla, aivan kuten Pythonissa:

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

Sitten kääntämätön (tämä on tärkeä) malli kopioidaan tiettyyn määrään käytettävissä olevia GPU:ita ja vasta sen jälkeen se käännetään:

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

Klassista tekniikkaa, jossa kaikki tasot paitsi viimeinen jäädytetään, viimeinen kerros opetetaan, koko mallin jäädyttäminen ja uudelleenkoulutus useille GPU:ille ei voitu toteuttaa.

Harjoitusta seurattiin ilman käyttöä. tensorilevyrajoittuen lokien tallentamiseen ja mallien tallentamiseen informatiivisilla nimillä jokaisen aikakauden jälkeen:

Takaisinsoittoja

# Шаблон имени файла лога
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. Päätelmän sijaan

Useita kohtaamiamme ongelmia ei ole vielä ratkaistu:

  • в Keras ei ole valmiita toimintoa optimaalisen oppimisnopeuden automaattiseen etsimiseen (analoginen lr_finder kirjastossa nopeasti.ai); Pienellä vaivalla on mahdollista siirtää kolmannen osapuolen toteutuksia R:lle, esim. tämä;
  • edellisen kohdan seurauksena ei ollut mahdollista valita oikeaa harjoitusnopeutta useaa GPU:ta käytettäessä;
  • nykyaikaisista hermoverkkoarkkitehtuureista on pulaa, erityisesti niistä, jotka on esikoulutettu imagenetissä;
  • ei yhden syklin politiikkaa ja syrjivät oppimisnopeudet (kosinin hehkutus oli pyynnöstämme toteutettu, Kiitos skeydan).

Mitä hyödyllistä tästä kilpailusta opittiin:

  • Suhteellisen pienitehoisilla laitteistoilla voit työskennellä kohtuullisen (moninkertaisesti RAM-muistin kokoisen) tietomäärän kanssa ilman kipua. Muovipussi data. taulukko säästää muistia taulukoiden paikan päällä tapahtuvan muokkauksen ansiosta, mikä välttää niiden kopioimisen, ja oikein käytettynä sen ominaisuudet osoittavat lähes aina korkeimman nopeuden kaikista tunnetuista skriptikielille tarkoitetuista työkaluista. Tietojen tallentaminen tietokantaan sallii monissa tapauksissa olla ajattelematta ollenkaan tarvetta puristaa koko tietojoukko RAM-muistiin.
  • R:n hitaat toiminnot voidaan korvata nopeilla C++:n funktioilla paketin avulla Rcpp. Jos käytön lisäksi RcppThread tai RcppParallel, saamme cross-platform-monisäikeiset toteutukset, joten koodia ei tarvitse rinnastaa R-tasolla.
  • Paketti Rcpp voidaan käyttää ilman C++:n vakavaa tuntemusta, vaadittava minimi on hahmoteltu täällä. Otsikkotiedostot useille hienoille C-kirjastoille, kuten xtensori saatavilla CRANissa, eli ollaan muodostamassa infrastruktuuria sellaisten projektien toteuttamiseksi, jotka integroivat valmiin korkean suorituskyvyn C++-koodin R:hen. Lisämukavuutta ovat syntaksin korostus ja staattinen C++-koodianalysaattori RStudiossa.
  • docpt voit suorittaa itsenäisiä komentosarjoja parametrein. Tämä on kätevä käyttää etäpalvelimella, mm. telakan alla. RStudiossa on hankalaa suorittaa useita tunteja kokeita neuroverkkojen opettelulla, eikä IDE:n asentaminen itse palvelimelle ole aina perusteltua.
  • Docker varmistaa koodin siirrettävyyden ja tulosten toistettavuuden kehittäjien välillä, joilla on eri käyttöjärjestelmäversiot ja kirjastot, sekä helpon suorittamisen palvelimilla. Voit käynnistää koko koulutusputken yhdellä komennolla.
  • Google Cloud on budjettiystävällinen tapa kokeilla kalliilla laitteistoilla, mutta sinun on valittava kokoonpanot huolellisesti.
  • Yksittäisten koodinpätkien nopeuden mittaaminen on erittäin hyödyllistä, varsinkin kun yhdistetään R ja C++ sekä paketin kanssa penkki - myös erittäin helppoa.

Kaiken kaikkiaan tämä kokemus oli erittäin palkitseva, ja jatkamme työtä joidenkin esiin tuotujen ongelmien ratkaisemiseksi.

Lähde: will.com

Lisää kommentti