ProHoster > Quick Draw Doodle Recognition: kako se sprijateljiti s R, C++ i neuronskim mrežama
Quick Draw Doodle Recognition: kako se sprijateljiti s R, C++ i neuronskim mrežama
Hej Habr!
Prošle jeseni, Kaggle je bio domaćin natjecanja za klasifikaciju ručno nacrtanih slika, Quick Draw Doodle Recognition, u kojem je, između ostalih, sudjelovao i tim R-znanstvenika: Artem Klevtsova, Philippa Manager и Andrej Ogurtsov. Natjecanje nećemo detaljno opisivati, to je već učinjeno u nedavna objava.
Ovaj put nije išlo s uzgojem medalja, ali stečeno je mnogo dragocjenog iskustva, pa bih želio zajednici ispričati niz najzanimljivijih i najkorisnijih stvari na Kagleu iu svakodnevnom radu. Među temama o kojima se raspravljalo: težak život bez OpenCV, JSON parsiranje (ovi primjeri ispituju integraciju C++ koda u skripte ili pakete u R koristeći Rcpp), parametrizacija skripti i dokerizacija konačnog rješenja. Sav kod iz poruke u obliku pogodnom za izvršenje dostupan je u spremišta.
1. Učinkovito učitajte podatke iz CSV-a u MonetDB bazu podataka
Podaci u ovom natjecanju nisu dati u obliku gotovih slika, već u obliku 340 CSV datoteka (jedna datoteka za svaku klasu) koje sadrže JSON s koordinatama točaka. Spajanjem ovih točaka linijama dobivamo konačnu sliku dimenzija 256x256 piksela. Također za svaki zapis postoji oznaka koja pokazuje je li sliku ispravno prepoznao klasifikator korišten u vrijeme prikupljanja skupa podataka, dvoslovni kod zemlje prebivališta autora slike, jedinstveni identifikator, vremenska oznaka i naziv klase koji odgovara nazivu datoteke. Pojednostavljena verzija originalnih podataka teži 7.4 GB u arhivi i približno 20 GB nakon raspakiranja, puni podaci nakon raspakiranja zauzimaju 240 GB. Organizatori su osigurali da obje verzije reproduciraju iste crteže, što znači da je puna verzija bila suvišna. U svakom slučaju, pohranjivanje 50 milijuna slika u grafičke datoteke ili u obliku nizova odmah je ocijenjeno neisplativim, te 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 odabran je dobro dokazan sustav MonetDB, naime implementacija za R kao paket MonetDBLite. Paket uključuje ugrađenu verziju poslužitelja baze podataka i omogućuje vam preuzimanje poslužitelja izravno iz R sesije i rad s njim tamo. Kreiranje baze podataka i povezivanje s njom vrši se jednom naredbom:
con <- DBI::dbConnect(drv = MonetDBLite::MonetDBLite(), Sys.getenv("DBDIR"))
Morat ćemo izraditi dvije tablice: jednu za sve podatke, drugu za servisne informacije o preuzetim datotekama (korisno ako nešto pođe po zlu i proces se mora nastaviti nakon preuzimanja nekoliko datoteka):
Najbrži način učitavanja podataka u bazu bio je izravno kopiranje CSV datoteka pomoću SQL - naredbe COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTGdje tablename - naziv tablice i path - put do datoteke. Tijekom rada s arhivom otkriveno je da ugrađena implementacija unzip u R ne radi ispravno s nizom datoteka iz arhive, pa smo koristili sustav unzip (pomoću parametra 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 trebate transformirati tablicu prije nego što je upišete u bazu podataka, dovoljno je proslijediti argument preprocess funkcija koja će transformirati 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 korištenog pogona. U našem slučaju, čitanje i pisanje unutar jednog SSD-a ili s flash pogona (izvorne datoteke) na SSD (DB) traje manje od 10 minuta.
Potrebno je još nekoliko sekundi za stvaranje stupca s oznakom klase cijelog broja i stupca indeksa (ORDERED INDEX) s brojevima redaka po kojima će se promatranja uzorkovati prilikom kreiranja serija:
Stvaranje dodatnih stupaca 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 paketa u hodu, morali smo postići maksimalnu brzinu izdvajanja slučajnih redaka iz tablice doodles. Za to smo koristili 3 trika. Prvi je bio smanjiti dimenzionalnost tipa koji pohranjuje ID opažanja. U izvornom skupu podataka, vrsta potrebna za pohranu ID-a je bigint, ali broj opažanja omogućuje uklapanje njihovih identifikatora, jednakih rednom broju, u tip int. Pretraga je u ovom slučaju mnogo brža. Drugi trik bio je koristiti ORDERED INDEX — do te smo odluke došli empirijski, prošavši sve raspoloživo opcije. Treći je bio korištenje parametriziranih upita. Bit metode je izvršiti naredbu jednom PREPARE uz naknadnu upotrebu pripremljenog izraza pri kreiranju hrpe upita iste vrste, ali zapravo postoji prednost u usporedbi s jednostavnim SELECT pokazalo se da je unutar raspona statističke pogreške.
Proces učitavanja podataka ne troši više od 450 MB RAM-a. Odnosno, opisani pristup omogućuje vam premještanje skupova podataka koji teže desetke gigabajta na gotovo bilo kojem jeftinom hardveru, uključujući neke uređaje s jednom pločom, što je prilično cool.
Sve što preostaje je izmjeriti brzinu dohvaćanja (nasumičnih) podataka i procijeniti skaliranje pri uzorkovanju serija različitih veličina:
Referentna vrijednost 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:
Raščlanjivanje nekoliko JSON-ova koji sadrže vektore nizova s koordinatama točaka.
Crtanje linija u boji na temelju koordinata točaka na slici potrebne veličine (na primjer, 256×256 ili 128×128).
Pretvaranje dobivenih slika u tenzor.
U sklopu natjecanja među Python kernelima, problem je riješen prvenstveno korištenjem OpenCV. Jedan od najjednostavnijih i najočitijih analoga u R-u izgledao bi ovako:
Implementacija pretvorbe JSON u tensor 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 sprema u privremeni PNG pohranjen u RAM-u (na Linuxu se privremeni R direktoriji nalaze u direktoriju /tmp, montiran u RAM). Ta se datoteka zatim čita kao trodimenzionalni niz s brojevima u rasponu od 0 do 1. Ovo je važno jer bi se konvencionalniji BMP čitao u neobrađeni niz s heksadecimalnim kodovima boja.
Ova nam se implementacija činila neoptimalnom, jer formiranje velikih serija traje nepristojno dugo, te smo odlučili iskoristiti iskustvo naših kolega korištenjem moćne knjižnice OpenCV. U to vrijeme nije postojao gotov paket za R (sada ga nema), pa je minimalna implementacija potrebne funkcionalnosti napisana u C++ s integracijom u R kod pomoću Rcpp.
Za rješavanje problema korišteni su sljedeći paketi i biblioteke:
OpenCV za rad sa slikama i crtanje linija. Korištene predinstalirane sistemske biblioteke i datoteke zaglavlja, kao i dinamičko povezivanje.
xtenzor za rad s višedimenzionalnim nizovima i tenzorima. Koristili smo datoteke zaglavlja uključene u istoimeni R paket. Knjižnica vam omogućuje rad s višedimenzionalnim nizovima, kako u redovima tako iu stupcima.
ndjson za raščlanjivanje JSON-a. Ova biblioteka se koristi u xtenzor automatski ako je prisutan u projektu.
RcppThread za organiziranje višenitne obrade vektora iz JSON-a. Korištene su datoteke zaglavlja koje pruža ovaj paket. Od popularnijih RcppParalelno Paket, između ostalog, ima ugrađen mehanizam za prekid petlje.
Valja napomenuti da je xtenzor pokazao se božjim darom: osim činjenice da ima opsežnu funkcionalnost i visoke performanse, njegovi programeri pokazali su se prilično osjetljivima i odgovarali su na pitanja brzo i detaljno. Uz njihovu pomoć bilo je moguće implementirati transformacije OpenCV matrica u xtensor tenzore, kao i način kombiniranja 3-dimenzionalnih tenzora slike u 4-dimenzionalni tenzor ispravne dimenzije (samu seriju).
Za kompajliranje datoteka koje koriste sistemske datoteke i dinamičko povezivanje s bibliotekama instaliranim na sustavu koristili smo mehanizam dodatka implementiran u paketu Rcpp. Za automatsko pronalaženje staza i oznaka upotrijebili smo popularni uslužni program za Linux pkg-config.
Implementacija dodatka Rcpp za korištenje biblioteke OpenCV
Implementacijski kod za raščlanjivanje JSON-a i generiranje serije za prijenos u model naveden je ispod spojlera. Prvo dodajte direktorij lokalnog projekta 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 prevesti s naredbom Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); također potrebna za rad nlohmann/json.hpp od 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 niz, izdvaja koordinate točaka, pakirajući ih u vektor;
ocv_draw_lines — iz dobivenog vektora točaka crta višebojne linije;
process — kombinira gore navedene funkcije i također dodaje mogućnost skaliranja rezultirajuće slike;
cpp_process_json_str - omotač preko funkcije process, koji izvozi rezultat u R-objekt (višedimenzionalni niz);
cpp_process_json_vector - omotač preko funkcije cpp_process_json_str, koji vam omogućuje obradu vektora niza u višenitnom načinu rada.
Za crtanje raznobojnih linija korišten je HSV model boja, nakon čega je uslijedila konverzija u RGB. Testirajmo rezultat:
Kao što vidite, pokazalo se da je povećanje brzine vrlo značajno i nije moguće sustići C++ kod paraleliziranjem R koda.
3. Iteratori za istovar paketa iz baze podataka
R ima zasluženu reputaciju za obradu podataka koji stanu u RAM, dok je Python više karakteriziran iterativnom obradom podataka, što vam omogućuje jednostavnu i prirodnu implementaciju proračuna izvan jezgre (izračuni pomoću vanjske memorije). Klasičan i za nas relevantan primjer u kontekstu opisanog problema su duboke neuronske mreže trenirane metodom gradijentnog spuštanja s aproksimacijom gradijenta u svakom koraku pomoću malog dijela promatranja, odnosno mini-serije.
Okviri dubokog učenja napisani u Pythonu imaju posebne klase koje implementiraju iteratore na temelju podataka: tablice, slike u mapama, binarni formati itd. Možete koristiti gotove opcije ili napisati vlastite za određene zadatke. U R-u možemo iskoristiti sve značajke Python biblioteke keras sa svojim različitim pozadinama koristeći istoimeni paket, koji zauzvrat radi na vrhu paketa mrežast. Ovo posljednje zaslužuje poseban dugi članak; ne samo da vam omogućuje pokretanje Python koda iz R-a, već vam također omogućuje prijenos objekata između R i Python sesija, automatski izvodeći sve potrebne pretvorbe tipa.
Riješili smo se potrebe za pohranjivanjem svih podataka u RAM pomoću MonetDBLite-a, sav posao "neuralne mreže" obavljat će izvorni kod u Pythonu, samo moramo napisati iterator preko podataka, jer ništa nije spremno za takvu situaciju u R-u ili Pythonu. U biti postoje samo dva zahtjeva za njega: mora vraćati serije u beskonačnoj petlji i spremati svoje stanje između iteracija (potonje u R implementirano je na najjednostavniji način korištenjem zatvaranja). Prethodno je bilo potrebno eksplicitno pretvoriti R nizove u numpy nizove unutar iteratora, ali trenutna verzija paketa keras radi to sama.
Pokazalo se da je iterator za podatke o obuci i provjeri valjanosti 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 uzima kao ulaz varijablu s vezom na bazu podataka, broj korištenih linija, broj klasa, veličinu serije, razmjer (scale = 1 odgovara renderiranju slika od 256x256 piksela, scale = 0.5 — 128x128 piksela), indikator boja (color = FALSE navodi iscrtavanje u sivim tonovima kada se koristi color = TRUE svaki se potez iscrtava u novoj boji) i indikator pretprocesiranja za mreže koje su unaprijed obučene na imagenetu. Potonji je potreban kako bi se skalirale vrijednosti piksela od intervala [0, 1] do intervala [-1, 1], koji je korišten prilikom obuke isporučenog keras modeli.
Vanjska funkcija sadrži provjeru tipa argumenata, tablicu data.table s nasumično izmiješanim brojevima redaka od samples_index i brojevi serija, brojač i maksimalni broj serija, kao i SQL izraz za istovar podataka iz baze podataka. Osim toga, definirali smo brzi analog funkcije unutar keras::to_categorical(). Koristili smo gotovo sve podatke za obuku, ostavljajući pola posto za provjeru valjanosti, tako da je veličina epohe bila ograničena parametrom steps_per_epoch kad se prozove keras::fit_generator(), i stanje if (i > max_i) radio samo za iterator provjere valjanosti.
U internoj funkciji dohvaćaju se indeksi redaka za sljedeću seriju, zapisi se istovaruju iz baze podataka s povećanjem brojača serije, JSON parsiranje (funkcija cpp_process_json_vector(), napisano u C++) i stvaranje nizova koji odgovaraju slikama. Zatim se kreiraju jednokratni vektori s oznakama klasa, nizovi s vrijednostima piksela i oznakama se kombiniraju u popis, što je povratna vrijednost. Za ubrzanje rada poslužili smo se izradom indeksa u tablicama data.table i izmjena putem linka - bez ovih “čipova” paketa podaci.tabela Prilično je teško zamisliti učinkovit rad s bilo kojom značajnom količinom podataka u R-u.
Rezultati mjerenja brzine na prijenosnom računalu Core i5 su sljedeći:
Ako imate dovoljnu količinu RAM-a, možete ozbiljno ubrzati rad baze podataka prijenosom u ovaj isti RAM (32 GB je dovoljno za naš zadatak). U Linuxu je particija postavljena prema zadanim postavkama /dev/shm, zauzimajući do polovice kapaciteta RAM-a. Uređivanjem možete istaknuti više /etc/fstabda dobije rekord like tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Obavezno ponovno pokrenite sustav i provjerite rezultat pokretanjem naredbe df -h.
Iterator za testne podatke izgleda puno jednostavnije, budući da testni skup podataka u potpunosti stane 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. Odabir arhitekture modela
Prva korištena arhitektura bila je mobilenet v1, čije se značajke raspravlja u ovo poruka. Standardno je uključen keras i, u skladu s tim, dostupan je u istoimenom paketu za R. Ali kada se pokušao koristiti s jednokanalnim slikama, ispala je čudna stvar: ulazni tenzor uvijek mora imati dimenziju (batch, height, width, 3), odnosno broj kanala se ne može mijenjati. Ne postoji takvo ograničenje u Pythonu, pa smo požurili i napisali vlastitu implementaciju ove arhitekture, slijedeći izvorni članak (bez ispadanja koje je u keras verziji):
Nedostaci ovog pristupa su očiti. Želim testirati puno modela, ali naprotiv, ne želim ručno prepisivati svaku arhitekturu. Također smo bili lišeni mogućnosti da koristimo težine modela unaprijed obučenih na imagenetu. Kao i obično, pomoglo je proučavanje dokumentacije. Funkcija get_config() omogućuje 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 kojeg od isporučenog keras modeli sa ili bez utega trenirani na imagenetu:
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)
}
Pri korištenju jednokanalnih slika ne koriste se unaprijed uvježbani utezi. Ovo se može popraviti: pomoću funkcije get_weights() dobiti težine modela u obliku popisa R nizova, promijeniti dimenziju prvog elementa ovog popisa (uzimajući jedan kanal boje ili usrednjavajući sva tri), a zatim učitati 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.
Proveli smo većinu eksperimenata koristeći mobilenet verzije 1 i 2, kao i resnet34. Modernije arhitekture kao što je SE-ResNeXt dobro su se pokazale na ovom natjecanju. Nažalost, nismo imali na raspolaganju gotove implementacije, a nismo napisali vlastitu (ali ćemo je svakako napisati).
5. Parametriranje skripti
Radi praktičnosti, sav kod za početak obuke dizajniran je kao jedna skripta, parametrizirana korištenjem dokpt 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 dokpt predstavlja implementaciju http://docopt.org/ za R. Uz njegovu pomoć pokreću se skripte jednostavnim naredbama 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 datoteka train_nn.R je izvršna (ova naredba će započeti obuku modela resnet50 na trobojnim slikama dimenzija 128x128 piksela baza se mora nalaziti u mapi /home/andrey/doodle_db). Na popis možete dodati brzinu učenja, vrstu optimizatora i bilo koje druge prilagodljive parametre. U procesu pripreme publikacije pokazalo se da je arhitektura mobilenet_v2 od trenutne verzije keras u R upotrebi ne mogu zbog neuvaženih izmjena u R paketu, čekamo da poprave.
Ovaj je pristup omogućio znatno ubrzanje eksperimenata s različitim modelima u usporedbi s tradicionalnijim pokretanjem skripti u RStudiu (paket navodimo kao moguću alternativu tfruns). Ali glavna prednost je mogućnost jednostavnog upravljanja pokretanjem skripti u Dockeru ili jednostavno na poslužitelju, bez instaliranja RStudio za to.
6. Dokerizacija skripti
Koristili smo Docker kako bismo osigurali prenosivost okruženja za obuku modela između članova tima i za brzu implementaciju u oblaku. Možete se početi upoznavati s ovim alatom, koji je relativno neobičan za R programera, s ovo serija publikacija ili video tečaj.
Docker vam omogućuje stvaranje vlastitih slika od nule i korištenje drugih slika kao temelja za stvaranje vlastitih. Analizirajući dostupne opcije, došli smo do zaključka da je instalacija NVIDIA, CUDA+cuDNN drajvera i Python biblioteka prilično obiman 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šteni paketi su stavljeni u varijable; većina napisanih skripti kopira se unutar spremnika tijekom sklapanja. Također smo promijenili naredbenu ljusku u /bin/bash za jednostavno korištenje 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ćuje pokretanje spremnika s različitim naredbama. Na primjer, to mogu biti skripte za obuku neuronskih mreža koje su prethodno bile postavljene unutar spremnika ili naredbena ljuska za otklanjanje pogrešaka i praćenje rada spremnika:
Skripta za pokretanje spremnika
#!/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 spremnika train_nn.R sa zadanim vrijednostima; ako je prvi pozicijski argument "bash", spremnik će se pokrenuti interaktivno s naredbenom ljuskom. U svim ostalim slučajevima, vrijednosti pozicijskih argumenata se zamjenjuju: CMD="Rscript /app/train_nn.R $@".
Vrijedno je napomenuti da su direktoriji s izvornim podacima i bazom podataka, kao i direktorij za spremanje obučenih modela, montirani unutar spremnika iz glavnog sustava, što vam omogućuje pristup rezultatima skripti bez nepotrebnih manipulacija.
7. Korištenje više grafičkih procesora na Google Cloudu
Jedna od značajki natjecanja bili su vrlo bučni podaci (pogledajte naslovnu sliku, posuđenu s @Leigh.plt s ODS slacka). Velike serije pomažu u borbi protiv toga, a nakon eksperimenata na osobnom računalu s 1 GPU-om, odlučili smo svladati modele obuke na nekoliko GPU-ova u oblaku. Koristio GoogleCloud (dobar vodič kroz osnove) zbog velikog izbora dostupnih konfiguracija, razumnih cijena i bonusa od 300 USD. Iz pohlepe sam naručio 4xV100 instancu sa SSD-om i tonom RAM-a, a to je bila velika greška. Takav stroj brzo pojede novac; možete bankrotirati eksperimentirajući bez provjerenog cjevovoda. U obrazovne svrhe, bolje je uzeti K80. No velika količina RAM-a dobro je 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 stvara na CPU-u pomoću upravitelja konteksta, baš kao u Pythonu:
Klasična tehnika zamrzavanja svih slojeva osim posljednjeg, uvježbavanje zadnjeg sloja, odmrzavanje i ponovno uvježbavanje cijelog modela za nekoliko GPU-a nije se mogla implementirati.
Trening je praćen bez upotrebe. tenzorska ploča, ograničavajući se na snimanje dnevnika i spremanje modela s informativnim imenima nakon svake epohe:
Brojni problemi s kojima smo se susreli još nisu prevladani:
в keras ne postoji gotova funkcija za automatsko traženje optimalne brzine učenja (analogno lr_finder u knjižnici brzo.ai); Uz određeni napor, moguće je prenijeti implementacije trećih strana na R, na primjer, ovo;
kao posljedica prethodne točke, nije bilo moguće odabrati ispravnu brzinu treninga pri korištenju nekoliko GPU-ova;
postoji nedostatak modernih arhitektura neuronskih mreža, posebno onih koje su unaprijed obučene na imagenetu;
politika bez jednog ciklusa i diskriminativne stope učenja (kosinusno žarenje je bilo na naš zahtjev implementiran, Hvala skeydan).
Što se korisno naučilo iz ovog natjecanja:
Na hardveru relativno male snage možete raditi s pristojnim količinama podataka (višestruko većim od RAM-a) bez muke. Plastična vrećica podaci.tabela štedi memoriju zbog izmjene tablica na licu mjesta, čime se izbjegava njihovo kopiranje, a kada se pravilno koristi, njegove mogućnosti gotovo uvijek pokazuju najveću brzinu među svim nama poznatim alatima za skriptne jezike. Spremanje podataka u bazu podataka omogućuje vam, u mnogim slučajevima, da uopće ne razmišljate o potrebi zbijanja cijelog skupa podataka u RAM.
Spore funkcije u R mogu se zamijeniti brzima u C++ pomoću paketa Rcpp. Ako pored upotrebe RcppThread ili RcppParalelno, dobivamo višenitnu implementaciju na više platformi, tako da nema potrebe za paralelizacijom koda na R razini.
Paket Rcpp može se koristiti bez ozbiljnog poznavanja C++, potreban minimum je naveden здесь. Datoteke zaglavlja za brojne cool C-biblioteke poput xtenzor dostupan 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 analizator C++ koda u RStudiu.
dokpt omogućuje vam pokretanje samostalnih skripti s parametrima. Ovo je zgodno za korištenje na udaljenom poslužitelju, uklj. ispod dokera. U RStudiou je nezgodno provoditi mnogo sati eksperimenata s obukom neuronskih mreža, a instaliranje IDE-a na sam poslužitelj nije uvijek opravdano.
Docker osigurava prenosivost koda i ponovljivost rezultata između programera s različitim verzijama OS-a i biblioteka, kao i jednostavnost izvršavanja na poslužiteljima. Možete pokrenuti cijeli cjevovod obuke samo jednom naredbom.
Google Cloud jeftin je način eksperimentiranja na skupom hardveru, ali trebate pažljivo odabrati konfiguracije.
Mjerenje brzine pojedinačnih fragmenata koda je vrlo korisno, posebno kada se kombiniraju R i C++, te s paketom klupa - također vrlo lako.
Općenito, ovo je iskustvo bilo vrlo korisno i nastavljamo raditi na rješavanju nekih od postavljenih problema.