ProHoster > Блог > Administracija > Quick Draw Doodle Recognition: kako se sprijateljiti sa R, C++ i neuronskim mrežama
Quick Draw Doodle Recognition: kako se sprijateljiti sa R, C++ i neuronskim mrežama
Hej Habr!
Prošle jeseni, Kaggle je bio domaćin takmičenja za klasifikaciju ručno nacrtanih slika, Quick Draw Doodle Recognition, u kojem je, između ostalih, učestvovao i tim R-naučnika: Artem Klevtsova, Philippa Manager и Andrej Ogurcov. Konkurs nećemo detaljno opisivati, to je već urađeno nedavna publikacija.
Ovoga puta nije išlo s uzgojem medalja, ali stečeno je mnogo dragocjenog iskustva, pa bih javnosti ispričao nekoliko najzanimljivijih i najkorisnijih stvari na Kagleu i u svakodnevnom radu. Među temama o kojima se razgovaralo: težak život bez OpenCV, JSON raščlanjivanje (ovi primjeri ispituju integraciju C++ koda u skripte ili pakete u R koristeći Rcpp), parametrizacija skripti i dokerizacija konačnog rješenja. Dostupan je sav kod iz poruke u formi pogodnoj za izvršenje spremišta.
1. Efikasno učitajte podatke iz CSV-a u MonetDB bazu podataka
Podaci u ovom konkursu nisu dati u obliku gotovih slika, već u obliku 340 CSV fajlova (po jedan fajl za svaku klasu) koji sadrže JSON-ove sa koordinatama tačaka. Povezivanjem ovih tačaka linijama dobijamo konačnu sliku dimenzija 256x256 piksela. Takođe za svaki zapis postoji oznaka koja pokazuje da li je sliku ispravno prepoznao klasifikator koji se koristio u trenutku kada je skup podataka prikupljen, dvoslovna šifra zemlje prebivališta autora slike, jedinstveni identifikator, vremenska oznaka i ime klase koje odgovara imenu datoteke. Pojednostavljena verzija originalnih podataka teži 7.4 GB u arhivi i otprilike 20 GB nakon raspakivanja, puni podaci nakon raspakivanja zauzimaju 240 GB. Organizatori su se pobrinuli da obje verzije reproduciraju iste crteže, što znači da je puna verzija suvišna. U svakom slučaju, pohranjivanje 50 miliona slika u grafičke datoteke ili u obliku nizova odmah je smatrano neisplativim, pa smo odlučili spojiti sve CSV datoteke iz arhive train_simplified.zip u bazu podataka s naknadnim generiranjem slika potrebne veličine „u hodu“ za svaku seriju.
Kao DBMS izabran je dobro dokazan sistem MonetDB, odnosno implementacija za R kao paket MonetDBLite. Paket uključuje ugrađenu verziju servera baze podataka i omogućava vam da preuzmete server direktno iz R sesije i tamo radite s njim. Kreiranje baze podataka i povezivanje s njom vrši se jednom naredbom:
con <- DBI::dbConnect(drv = MonetDBLite::MonetDBLite(), Sys.getenv("DBDIR"))
Trebat ćemo napraviti dvije tablice: jednu za sve podatke, drugu za servisne informacije o preuzetim datotekama (korisno ako nešto krene po zlu i proces se mora nastaviti nakon preuzimanja nekoliko datoteka):
Najbrži način za učitavanje podataka u bazu podataka bio je direktno kopiranje CSV datoteka pomoću SQL - komande COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTgde tablename - naziv tabele i path - putanja do datoteke. Prilikom rada sa arhivom otkriveno je da je ugrađena implementacija unzip u R ne radi ispravno sa većim brojem fajlova iz arhive, pa smo koristili sistem unzip (koristeći parametar getOption("unzip")).
Funkcija za pisanje u bazu podataka
#' @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))
}
Ako treba da transformišete tabelu pre nego što je upišete u bazu podataka, dovoljno je da prosledite argument preprocess funkcija koja će transformisati podatke.
Kod za sekvencijalno učitavanje podataka u bazu podataka:
Upisivanje podataka u bazu podataka
# Список файлов для записи
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
Vrijeme učitavanja podataka može varirati ovisno o karakteristikama brzine pogona koji se koristi. U našem slučaju, čitanje i pisanje unutar jednog SSD-a ili sa fleš diska (izvornog fajla) na SSD (DB) traje manje od 10 minuta.
Potrebno je još nekoliko sekundi da se kreira kolona s oznakom cjelobrojne klase i stupcem indeksa (ORDERED INDEX) s brojevima reda po kojima će se uzorkovati opažanja prilikom kreiranja serija:
Kreiranje dodatnih kolona i indeksa
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)"))
Da bismo riješili problem kreiranja serije u hodu, morali smo postići maksimalnu brzinu izdvajanja nasumičnih redova iz tablice doodles. Za ovo smo koristili 3 trika. Prvi je bio da se smanji dimenzionalnost tipa koji pohranjuje ID posmatranja. U originalnom skupu podataka, tip koji je potreban za pohranjivanje ID-a je bigint, ali broj zapažanja omogućava da se njihovi identifikatori, jednaki rednom broju, uklope u tip int. Pretraga je u ovom slučaju mnogo brža. Drugi trik je bio korištenje ORDERED INDEX — do ove odluke smo došli empirijski, nakon što smo prošli kroz sve raspoložive varianty. Treći je bio korištenje parametrizovanih upita. Suština metode je da se naredba izvrši jednom PREPARE uz naknadnu upotrebu pripremljenog izraza pri kreiranju gomile upita istog tipa, ali zapravo postoji prednost u odnosu na jednostavan SELECT pokazalo se da je u opsegu statističke greške.
Proces učitavanja podataka ne troši više od 450 MB RAM-a. Odnosno, opisani pristup vam omogućava da premještate skupove podataka teške desetine gigabajta na gotovo bilo koji proračunski hardver, uključujući i neke uređaje sa jednom pločom, što je prilično cool.
Ostaje samo izmjeriti brzinu preuzimanja (slučajnih) podataka i procijeniti skaliranje prilikom uzorkovanja serija različitih veličina:
Reper baze podataka
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)
2. Priprema serija
Cijeli proces pripreme serije sastoji se od sljedećih koraka:
Parsiranje nekoliko JSON-ova koji sadrže vektore nizova sa koordinatama tačaka.
Crtanje linija u boji na osnovu koordinata tačaka na slici potrebne veličine (na primjer, 256×256 ili 128×128).
Pretvaranje rezultirajućih slika u tenzor.
U sklopu takmičenja među Python kernelima, problem je riješen prvenstveno korištenjem OpenCV. Jedan od najjednostavnijih i najočitijih analoga u R bi izgledao ovako:
Implementacija konverzije JSON u tenzor u 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)
}
Crtanje se izvodi pomoću standardnih R alata i pohranjuje u privremeni PNG pohranjen u RAM-u (na Linuxu, privremeni R direktoriji se nalaze u direktoriju /tmp, montiran u RAM). Ovaj fajl se zatim čita kao trodimenzionalni niz sa brojevima u rasponu od 0 do 1. Ovo je važno jer bi konvencionalniji BMP bio pročitan u sirovi niz sa heksadecimalnim kodovima boja.
Ova implementacija nam se činila neoptimalna, jer formiranje velikih serija traje nepristojno dugo, pa smo odlučili iskoristiti iskustvo naših kolega korištenjem moćne biblioteke OpenCV. U to vrijeme nije postojao gotov paket za R (sada ga nema), pa je minimalna implementacija potrebne funkcionalnosti napisana u C++ uz integraciju u R kod korištenjem Rcpp.
Za rješavanje problema korišteni su sljedeći paketi i biblioteke:
OpenCV za rad sa slikama i crtanje linija. Korištene su unaprijed instalirane sistemske biblioteke i zaglavlja, kao i dinamičko povezivanje.
xtensor za rad sa višedimenzionalnim nizovima i tenzorima. Koristili smo datoteke zaglavlja uključene u istoimeni R paket. Biblioteka vam omogućava da radite sa višedimenzionalnim nizovima, kako u redovima, tako i u glavnom redu kolona.
ndjson za raščlanjivanje JSON-a. Ova biblioteka se koristi u xtensor automatski ako je prisutan u projektu.
RcppThread za organizovanje višenitne obrade vektora iz JSON-a. Koristio je fajlove zaglavlja koje obezbjeđuje ovaj paket. Od popularnijih RcppParallel Paket, između ostalog, ima ugrađen mehanizam za prekid petlje.
Vredi napomenuti xtensor ispostavilo se kao dar od Boga: pored činjenice da ima opsežnu funkcionalnost i visoke performanse, njegovi programeri su se pokazali prilično osjetljivi i brzo i detaljno su odgovarali na pitanja. Uz njihovu pomoć, bilo je moguće implementirati transformacije OpenCV matrica u xtenzor tenzora, kao i način kombinovanja 3-dimenzionalnih tenzora slike u 4-dimenzionalni tenzor ispravne dimenzije (sama serija).
Za kompajliranje datoteka koje koriste sistemske datoteke i dinamičko povezivanje sa bibliotekama instaliranim na sistemu, koristili smo mehanizam dodataka implementiran u paketu Rcpp. Da bismo automatski pronašli putanje i zastavice, koristili smo popularni Linux uslužni program pkg-config.
Implementacija Rcpp dodatka za korištenje OpenCV biblioteke
Implementacijski kod za raščlanjivanje JSON-a i generiranje serije za prijenos na model je dat ispod spojlera. Prvo dodajte lokalni projektni direktorij za traženje datoteka zaglavlja (potrebno za ndjson):
// [[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;
}
Ovaj kod treba staviti u datoteku src/cv_xt.cpp i kompajlirajte sa naredbom Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); takođe potreban za rad nlohmann/json.hpp из spremište. Kod je podijeljen u nekoliko funkcija:
to_xt — šablonska funkcija za transformaciju matrice slike (cv::Mat) na tenzor xt::xtensor;
parse_json — funkcija analizira JSON string, izdvaja koordinate tačaka, pakuje ih u vektor;
ocv_draw_lines — iz rezultirajućeg vektora tačaka, crta višebojne linije;
process — kombinuje gore navedene funkcije i takođe dodaje mogućnost skaliranja rezultirajuće slike;
cpp_process_json_str - omotač preko funkcije process, koji izvozi rezultat u R-objekat (višedimenzionalni niz);
cpp_process_json_vector - omotač preko funkcije cpp_process_json_str, koji vam omogućava da obrađujete vektor niza u višenitnom modu.
Za crtanje višebojnih linija korišten je HSV model boja, nakon čega je uslijedila konverzija u RGB. Testirajmo rezultat:
Kao što vidite, povećanje brzine se pokazalo veoma značajnim i nije moguće sustići C++ kod paralelizacijom R koda.
3. Iteratori za istovar paketa iz baze podataka
R ima zasluženu reputaciju za obradu podataka koji se uklapaju u RAM, dok Python više karakteriše iterativna obrada podataka, što vam omogućava da lako i prirodno implementirate kalkulacije izvan jezgre (kalkulacije pomoću eksterne memorije). Klasičan i relevantan primjer za nas u kontekstu opisanog problema su duboke neuronske mreže obučene metodom gradijentnog spuštanja uz aproksimaciju gradijenta na svakom koraku koristeći mali dio opservacija, ili mini-batch.
Okviri za duboko učenje napisani u Pythonu imaju posebne klase koje implementiraju iteratore na osnovu podataka: tabele, slike u fasciklama, binarni formati, itd. Možete koristiti gotove opcije ili napisati svoje za određene zadatke. U R-u možemo iskoristiti sve karakteristike Python biblioteke keras sa svojim različitim backendovima koji koriste paket istog imena, koji zauzvrat radi na vrhu paketa mrežasti. Ovo posljednje zaslužuje poseban dugi članak; ne samo da vam omogućava da pokrenete Python kod iz R, već vam omogućava i prijenos objekata između R i Python sesija, automatski izvodeći sve potrebne konverzije tipova.
Rešili smo se potrebe za pohranjivanjem svih podataka u RAM pomoću MonetDBLite-a, sav posao na “neuralnoj mreži” će obavljati originalni kod u Pythonu, samo moramo napisati iterator preko podataka, jer ništa nije spremno za takvu situaciju u R ili Pythonu. U suštini postoje samo dva zahtjeva za njega: mora vratiti pakete u beskonačnoj petlji i sačuvati svoje stanje između iteracija (potonje u R se implementira na najjednostavniji način korištenjem zatvaranja). Ranije je bilo potrebno eksplicitno pretvoriti R nizove u numpy nizove unutar iteratora, ali trenutna verzija paketa keras radi sama.
Pokazalo se da je iterator za podatke o obuci i validaciji sljedeći:
Iterator za obuku i validaciju podataka
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 kao ulaz uzima varijablu s vezom na bazu podataka, brojevima korištenih linija, brojem klasa, veličinom serije, razmjerom (scale = 1 odgovara renderiranju slika od 256x256 piksela, scale = 0.5 — 128x128 piksela), indikator u boji (color = FALSE specificira prikazivanje u sivim tonovima kada se koristi color = TRUE svaki potez je nacrtan u novoj boji) i indikator preprocesiranja za mreže prethodno obučene na imagenet-u. Potonje je potrebno kako bi se vrijednosti piksela skalirali od intervala [0, 1] do intervala [-1, 1], koji je korišten pri obučavanju isporučenog keras modeli.
Eksterna funkcija sadrži provjeru tipa argumenta, tablicu data.table sa nasumično pomiješanim brojevima linija iz samples_index i brojevi serija, brojač i maksimalni broj serija, kao i SQL izraz za istovar podataka iz baze podataka. Dodatno, definirali smo brzi analog funkcije unutra keras::to_categorical(). Koristili smo skoro sve podatke za obuku, ostavljajući pola procenta za validaciju, tako da je veličina epohe bila ograničena parametrom steps_per_epoch kada je pozvan keras::fit_generator(), i stanje if (i > max_i) radio samo za iterator validacije.
U internoj funkciji, indeksi redova se preuzimaju za sljedeću grupu, zapisi se učitavaju iz baze podataka s povećanjem brojača serije, JSON raščlanjivanjem (funkcija cpp_process_json_vector(), napisan u C++) i kreiranje nizova koji odgovaraju slikama. Zatim se kreiraju jednokratni vektori sa oznakama klasa, nizovi sa vrednostima piksela i oznakama se kombinuju u listu, što je povratna vrednost. Da bismo ubrzali rad, koristili smo kreiranje indeksa u tabelama data.table i modifikacija preko linka - bez ovih paketa "čipova" data.table Prilično je teško zamisliti efikasan rad sa bilo kojom značajnom količinom podataka u R.
Rezultati mjerenja brzine na Core i5 laptopu su sljedeći:
Ako imate dovoljnu količinu RAM-a, možete ozbiljno ubrzati rad baze podataka tako što ćete je prebaciti u istu tu RAM memoriju (32 GB je dovoljno za naš zadatak). U Linuxu se particija montira prema zadanim postavkama /dev/shm, koji zauzimaju do polovine kapaciteta RAM-a. Možete istaknuti više uređivanjem /etc/fstabda dobijete lajk tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Obavezno ponovo pokrenite sistem i provjerite rezultat pokretanjem naredbe df -h.
Iterator za testne podatke izgleda mnogo jednostavnije, budući da se testni skup podataka u potpunosti uklapa u RAM:
Iterator za testne podatke
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. Izbor arhitekture modela
Prva korištena arhitektura je bila mobilenet v1, čije su karakteristike obrađene u ovo poruka. Uključen je kao standard keras i, shodno tome, dostupan je u istoimenom paketu za R. Ali kada se pokuša koristiti s jednokanalnim slikama, ispostavilo se čudno: ulazni tenzor uvijek mora imati dimenziju (batch, height, width, 3), odnosno broj kanala se ne može mijenjati. U Pythonu nema takvog ograničenja, pa smo požurili i napisali vlastitu implementaciju ove arhitekture, slijedeći originalni članak (bez ispadanja koji je u keras verziji):
Nedostaci ovog pristupa su očigledni. Želim da testiram mnogo modela, ali naprotiv, ne želim da prepisujem svaku arhitekturu ručno. Također smo bili lišeni mogućnosti da koristimo težine modela prethodno obučenih na imagenet-u. Kao i obično, pomoglo je proučavanje dokumentacije. Funkcija get_config() omogućava vam da dobijete opis modela u obliku pogodnom za uređivanje (base_model_conf$layers - regularna R lista) i funkcija from_config() izvodi obrnutu konverziju u objekt modela:
Sada nije teško napisati univerzalnu funkciju za dobivanje bilo koje od isporučenih keras modeli sa ili bez utega obučeni na imagenet-u:
Funkcija za učitavanje gotovih arhitektura
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)
}
Kada koristite jednokanalne slike, ne koriste se unaprijed obučeni utezi. Ovo bi se moglo popraviti: korištenjem funkcije get_weights() dobijte težine modela u obliku liste R nizova, promijenite dimenziju prvog elementa ove liste (uzimajući jedan kanal boje ili u prosjeku sva tri), a zatim učitajte težine natrag u model pomoću funkcije set_weights(). Nikada nismo dodali ovu funkcionalnost, jer je u ovoj fazi već bilo jasno da je produktivnije raditi sa slikama u boji.
Većinu eksperimenata izveli smo koristeći mobilenet verzije 1 i 2, kao i resnet34. Modernije arhitekture kao što je SE-ResNeXt su se dobro pokazale na ovom takmičenju. Nažalost, nismo imali gotove implementacije na raspolaganju, a nismo ni pisali svoje (ali ćemo svakako napisati).
5. Parameterizacija skripti
Radi praktičnosti, sav kod za početak obuke je dizajniran kao jedna skripta, parametrizirana korištenjem docopt kako slijedi:
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)
Paket docopt predstavlja implementaciju http://docopt.org/ za R. Uz njegovu pomoć, skripte se pokreću jednostavnim komandama poput Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db ili ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, ako fajl train_nn.R je izvršna (ova komanda će započeti obuku modela resnet50 na slikama u tri boje dimenzija 128x128 piksela, baza podataka se mora nalaziti u folderu /home/andrey/doodle_db). Na listu možete dodati brzinu učenja, tip optimizatora i sve druge prilagodljive parametre. U procesu pripreme publikacije ispostavilo se da je arhitektura mobilenet_v2 od trenutne verzije keras u R upotrebi ne može zbog promjena koje nisu uzete u obzir u R paketu, čekamo da to poprave.
Ovaj pristup je omogućio značajno ubrzanje eksperimenata sa različitim modelima u poređenju sa tradicionalnijim pokretanjem skripti u RStudiu (paket navodimo kao moguću alternativu tfruns). Ali glavna prednost je mogućnost lakog upravljanja pokretanjem skripti u Dockeru ili jednostavno na serveru, bez instaliranja RStudia za to.
6. Dokerizacija skripti
Koristili smo Docker da osiguramo prenosivost okruženja za obuku modela između članova tima i za brzu implementaciju u oblaku. Možete početi da se upoznate sa ovim alatom, koji je relativno neobičan za R programera ovo serija publikacija ili video kurs.
Docker vam omogućava da kreirate vlastite slike od nule i koristite druge slike kao osnovu za kreiranje vlastitih. Analizirajući dostupne opcije, došli smo do zaključka da je instaliranje NVIDIA, CUDA+cuDNN drajvera i Python biblioteka prilično obimni dio slike, te smo odlučili uzeti službenu sliku kao osnovu tensorflow/tensorflow:1.12.0-gpu, dodajući tamo potrebne R pakete.
Radi praktičnosti, korišćeni paketi su stavljeni u varijable; većina napisanih skripti se kopira unutar kontejnera tokom sklapanja. Također smo promijenili komandnu ljusku u /bin/bash radi lakšeg korišćenja sadržaja /etc/os-release. Time je izbjegnuta potreba za navođenjem verzije OS-a u kodu.
Dodatno, napisana je mala bash skripta koja vam omogućava da pokrenete kontejner sa raznim komandama. Na primjer, to mogu biti skripte za obuku neuronskih mreža koje su prethodno bile smještene unutar kontejnera, ili komandna ljuska za otklanjanje grešaka i praćenje rada kontejnera:
Skripta za pokretanje kontejnera
#!/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}
Ako se ova bash skripta pokrene bez parametara, skripta će biti pozvana unutar kontejnera train_nn.R sa zadanim vrijednostima; ako je prvi pozicioni argument "bash", tada će kontejner započeti interaktivno sa komandnom ljuskom. U svim ostalim slučajevima, vrijednosti pozicijskih argumenata se zamjenjuju: CMD="Rscript /app/train_nn.R $@".
Vrijedi napomenuti da se direktoriji sa izvornim podacima i bazom podataka, kao i direktorij za spremanje obučenih modela, montiraju unutar kontejnera iz host sistema, što vam omogućava pristup rezultatima skripti bez nepotrebnih manipulacija.
7. Korištenje više GPU-a na Google Cloud-u
Jedna od karakteristika takmičenja su bili veoma bučni podaci (pogledajte naslovnu sliku, pozajmljenu sa @Leigh.plt iz ODS slack). Velike serije pomažu u borbi protiv ovoga, a nakon eksperimenata na PC-u sa 1 GPU-om, odlučili smo da savladamo modele obuke na nekoliko GPU-a u oblaku. Korišten GoogleCloud (dobar vodič za osnove) zbog velikog izbora dostupnih konfiguracija, razumnih cijena i 300$ bonusa. Iz pohlepe sam naručio 4xV100 instancu sa SSD-om i tonom RAM-a, i to je bila velika greška. Takva mašina brzo pojede novac; možete propasti eksperimentirajući bez dokazanog cevovoda. U obrazovne svrhe, bolje je uzeti K80. No, velika količina RAM-a je dobro došla - cloud SSD nije impresionirao svojim performansama, pa je baza podataka prebačena na dev/shm.
Od najvećeg interesa je fragment koda odgovoran za korištenje više GPU-ova. Prvo, model se kreira na CPU-u pomoću upravitelja konteksta, baš kao u Pythonu:
Klasična tehnika zamrzavanja svih slojeva osim posljednjeg, obučavanja posljednjeg sloja, odmrzavanja i ponovnog obučavanja cijelog modela za nekoliko GPU-a nije mogla biti implementirana.
Trening je praćen bez upotrebe. tensorboard, ograničavajući se na snimanje dnevnika i spremanje modela sa informativnim nazivima nakon svake epohe:
Brojni problemi sa kojima smo se susreli još uvijek nisu riješeni:
в keras ne postoji gotova funkcija za automatsko traženje optimalne brzine učenja (analogno lr_finder u biblioteci fast.ai); Uz određeni napor, moguće je prenijeti implementacije treće strane na R, na primjer, ovo;
kao posljedica prethodne tačke, nije bilo moguće odabrati ispravnu brzinu treninga kada se koristi nekoliko GPU-ova;
postoji nedostatak modernih arhitektura neuronskih mreža, posebno onih prethodno obučenih na imagenet-u;
politika bez jednog ciklusa i diskriminativne stope učenja (kosinusno žarenje je bilo na naš zahtjev implementirano, hvala skeydan).
Koje korisne stvari smo naučili sa ovog takmičenja:
Na hardveru s relativno malom potrošnjom, možete raditi sa pristojnim (višestruko većim od RAM-a) količinama podataka bez muke. Plasticna kesa data.table štedi memoriju zbog in-place modifikacije tabela, čime se izbjegava njihovo kopiranje, a kada se pravilno koriste, njegove mogućnosti gotovo uvijek pokazuju najveću brzinu među svim nama poznatim alatima za skriptne jezike. Čuvanje podataka u bazi podataka omogućava vam, u mnogim slučajevima, da uopće ne razmišljate o potrebi da se cijeli skup podataka ugura u RAM.
Spore funkcije u R mogu se zamijeniti brzim u C++ pomoću paketa Rcpp. Ako pored upotrebe RcppThread ili RcppParallel, dobijamo višeplatformske implementacije s više niti, tako da nema potrebe za paralelizacijom koda na R nivou.
Paket Rcpp može se koristiti bez ozbiljnog poznavanja C++-a, naveden je potreban minimum ovdje. Fajlovi zaglavlja za brojne cool C-biblioteke kao što su xtensor dostupno na CRAN-u, odnosno formira se infrastruktura za implementaciju projekata koji integriraju gotov C++ kod visokih performansi u R. Dodatna pogodnost je isticanje sintakse i statički C++ analizator koda u RStudiu.
docopt omogućava vam da pokrenete samostalne skripte sa parametrima. Ovo je zgodno za korištenje na udaljenom serveru, uklj. pod docker. U RStudiu je nezgodno provoditi mnogo sati eksperimenata sa obučavanjem neuronskih mreža, a instaliranje IDE-a na samom serveru nije uvijek opravdano.
Docker osigurava prenosivost koda i ponovljivost rezultata između programera s različitim verzijama OS-a i biblioteka, kao i lakoću izvršavanja na serverima. Možete pokrenuti cijeli proces obuke sa samo jednom komandom.
Google Cloud je jeftin način za eksperimentiranje na skupom hardveru, ali morate pažljivo odabrati konfiguracije.
Mjerenje brzine pojedinačnih fragmenata koda je vrlo korisno, posebno kada se kombiniraju R i C++, te sa paketom klupa - takođe vrlo lako.
Sve u svemu, ovo iskustvo je bilo veoma korisno i nastavljamo da radimo na rešavanju nekih od postavljenih pitanja.