ProHoster > Blog > Uprava > Quick Draw Doodle Recognition: kako se spoprijateljiti z R, C++ in nevronskimi mrežami
Quick Draw Doodle Recognition: kako se spoprijateljiti z R, C++ in nevronskimi mrežami
Pozdravljeni, Habr!
Lansko jesen je Kaggle gostil tekmovanje za razvrščanje ročno narisanih slik Quick Draw Doodle Recognition, v katerem je med drugim sodelovala ekipa R-znanstvenikov: Artem Klevcova, Philippa Manager и Andrej Ogurtsov. Tekmovanja ne bomo podrobneje opisovali, to je bilo že narejeno v nedavna objava.
Tokrat se s kmetovanjem medalj ni izšlo, je pa bilo pridobljenih veliko dragocenih izkušenj, zato bi rad skupnosti povedal o številnih najbolj zanimivih in uporabnih stvareh na Kagleju in v vsakdanjem delu. Med obravnavanimi temami: težko življenje brez OpenCV, razčlenjevanje JSON (ti primeri preučujejo integracijo kode C++ v skripte ali pakete v R z uporabo Rcpp), parametrizacijo skriptov in dokerizacijo končne rešitve. Vsa koda iz sporočila v obliki, primerni za izvedbo, je na voljo v repozitorije.
1. Učinkovito naložite podatke iz CSV v bazo podatkov MonetDB
Podatki v tem tekmovanju niso na voljo v obliki že pripravljenih slik, temveč v obliki 340 datotek CSV (ena datoteka za vsak razred), ki vsebujejo JSON s koordinatami točk. Če te točke povežemo s črtami, dobimo končno sliko velikosti 256x256 pikslov. Za vsak zapis je tudi oznaka, ki označuje, ali je klasifikator, ki je bil uporabljen v času zbiranja podatkovnega niza, pravilno prepoznal sliko, dvočrkovno kodo države stalnega prebivališča avtorja slike, enolični identifikator, časovni žig in ime razreda, ki se ujema z imenom datoteke. Poenostavljena različica originalnih podatkov tehta 7.4 GB v arhivu in približno 20 GB po razpakiranju, polni podatki po razpakiranju zavzamejo 240 GB. Organizatorji so zagotovili, da sta obe različici reproducirali iste risbe, kar pomeni, da je bila polna različica odveč. Vsekakor je bilo shranjevanje 50 milijonov slik v grafičnih datotekah ali v obliki nizov takoj ocenjeno kot nerentabilno, zato smo se odločili združiti vse datoteke CSV iz arhiva train_simplified.zip v bazo podatkov z naknadnim generiranjem slik zahtevane velikosti "sproti" za vsako serijo.
Za DBMS je bil izbran dobro preverjen sistem MonetDB, in sicer implementacija za R kot paket MonetDBLite. Paket vključuje vdelano različico strežnika baze podatkov in vam omogoča, da prevzamete strežnik neposredno iz seje R in tam delate z njim. Ustvarjanje baze podatkov in povezovanje z njo izvedemo z enim ukazom:
con <- DBI::dbConnect(drv = MonetDBLite::MonetDBLite(), Sys.getenv("DBDIR"))
Ustvariti bomo morali dve tabeli: eno za vse podatke, drugo za storitvene informacije o prenesenih datotekah (uporabno, če gre kaj narobe in je treba postopek nadaljevati po prenosu več datotek):
Najhitrejši način za nalaganje podatkov v podatkovno bazo je bilo neposredno kopiranje datotek CSV z ukazom SQL COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTČe tablename - ime tabele in path - pot do datoteke. Med delom z arhivom je bilo ugotovljeno, da vgrajena izvedba unzip v R ne deluje pravilno s številnimi datotekami iz arhiva, zato smo uporabili sistem unzip (z uporabo parametra getOption("unzip")).
Funkcija za pisanje v podatkovno bazo
#' @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))
}
Če morate tabelo preoblikovati, preden jo zapišete v bazo podatkov, je dovolj, da posredujete argument preprocess funkcijo, ki bo preoblikovala podatke.
Koda za zaporedno nalaganje podatkov v bazo:
Zapisovanje podatkov v podatkovno bazo
# Список файлов для записи
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
Čas nalaganja podatkov se lahko razlikuje glede na hitrostne značilnosti uporabljenega pogona. V našem primeru branje in pisanje znotraj enega SSD-ja ali z bliskovnega pogona (izvorna datoteka) na SSD (DB) traja manj kot 10 minut.
Ustvarjanje stolpca z oznako razreda celega števila in indeksnim stolpcem traja še nekaj sekund (ORDERED INDEX) s številkami vrstic, po katerih bodo opazovanja vzorčena pri ustvarjanju paketov:
Ustvarjanje dodatnih stolpcev in kazala
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 bi rešili problem sprotnega ustvarjanja paketa, smo morali doseči največjo hitrost ekstrahiranja naključnih vrstic iz tabele doodles. Za to smo uporabili 3 trike. Prvi je bil zmanjšanje dimenzionalnosti tipa, ki hrani ID opazovanja. V izvirnem nizu podatkov je vrsta, potrebna za shranjevanje ID-ja bigint, vendar število opazovanj omogoča, da se njihovi identifikatorji, enaki redni številki, prilagodijo tipu int. Iskanje je v tem primeru veliko hitrejše. Drugi trik je bil uporaba ORDERED INDEX — do te odločitve smo prišli empirično, po pregledu vseh razpoložljivih možnosti. Tretja je bila uporaba parametriziranih poizvedb. Bistvo metode je enkratna izvršitev ukaza PREPARE z naknadno uporabo pripravljenega izraza pri ustvarjanju množice poizvedb istega tipa, vendar je dejansko prednost v primerjavi s preprostim SELECT se je izkazalo, da je v območju statistične napake.
Postopek nalaganja podatkov ne porabi več kot 450 MB RAM-a. To pomeni, da vam opisani pristop omogoča premikanje naborov podatkov, ki tehtajo desetine gigabajtov, na skoraj kateri koli proračunski strojni opremi, vključno z nekaterimi napravami z eno ploščo, kar je zelo kul.
Vse, kar ostane, je merjenje hitrosti pridobivanja (naključnih) podatkov in ovrednotenje skaliranja pri vzorčenju paketov različnih velikosti:
Primerjalna baza podatkov
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. Priprava serij
Celoten postopek priprave serije je sestavljen iz naslednjih korakov:
Razčlenjevanje več datotek JSON, ki vsebujejo vektorje nizov s koordinatami točk.
Risanje barvnih črt na podlagi koordinat točk na sliki zahtevane velikosti (na primer 256×256 ali 128×128).
Pretvarjanje nastalih slik v tenzor.
V okviru tekmovanja med jedri Python je bila težava rešena predvsem z uporabo OpenCV. Eden najpreprostejših in najbolj očitnih analogov v R bi bil videti takole:
Implementacija pretvorbe JSON v Tensor v 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)
}
Risanje se izvaja s standardnimi orodji R in se shrani v začasni PNG, shranjen v RAM-u (v sistemu Linux se začasni imeniki R nahajajo v imeniku /tmp, nameščen v RAM). Ta datoteka se nato prebere kot tridimenzionalna matrika s številkami v razponu od 0 do 1. To je pomembno, ker bi se bolj običajen BMP prebral v neobdelano matriko s šestnajstiškimi barvnimi kodami.
Ta izvedba se nam je zdela neoptimalna, saj oblikovanje velikih serij traja nespodobno dolgo, zato smo se odločili izkoristiti izkušnje naših kolegov z uporabo zmogljive knjižnice OpenCV. Takrat še ni bilo pripravljenega paketa za R (zdaj ga ni), zato je bila minimalna implementacija zahtevane funkcionalnosti napisana v C++ z integracijo v kodo R z uporabo Rcpp.
Za rešitev težave so bili uporabljeni naslednji paketi in knjižnice:
OpenCV za delo s slikami in risanje črt. Uporabljene vnaprej nameščene sistemske knjižnice in datoteke glave ter dinamično povezovanje.
xtenzor za delo z večdimenzionalnimi nizi in tenzorji. Uporabili smo datoteke glave, vključene v istoimenski paket R. Knjižnica vam omogoča delo z večdimenzionalnimi nizi, tako v glavnem vrstnem redu kot v stolpcu.
ndjson za razčlenjevanje JSON. Ta knjižnica se uporablja v xtenzor samodejno, če je prisoten v projektu.
RcppThread za organiziranje večnitne obdelave vektorja iz JSON. Uporabljene so bile datoteke glave, ki jih ponuja ta paket. Od bolj priljubljenih RcppParallel Paket ima med drugim vgrajen mehanizem za prekinitev zanke.
Opozoriti je treba, da je xtenzor izkazalo se je za božji dar: poleg dejstva, da ima obsežno funkcionalnost in visoko zmogljivost, so se njegovi razvijalci izkazali za precej odzivne in so na vprašanja odgovarjali hitro in podrobno. Z njihovo pomočjo je bilo mogoče implementirati transformacije matrik OpenCV v tenzorje xtenzorjev, kakor tudi način združevanja 3-dimenzionalnih slikovnih tenzorjev v 4-dimenzionalni tenzor pravilne dimenzije (sam paket).
Za prevajanje datotek, ki uporabljajo sistemske datoteke in dinamično povezovanje s knjižnicami, nameščenimi v sistemu, smo uporabili mehanizem vtičnikov, implementiran v paketu Rcpp. Za samodejno iskanje poti in zastavic smo uporabili priljubljen pripomoček za Linux pkg-config.
Izvedbena koda za razčlenjevanje JSON in generiranje paketa za prenos v model je podana pod spojlerjem. Najprej dodajte lokalni imenik projekta za iskanje datotek glave (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;
}
To kodo je treba postaviti v datoteko src/cv_xt.cpp in prevedite z ukazom Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); potrebno tudi za delo nlohmann/json.hpp z dne repozitorija. Koda je razdeljena na več funkcij:
to_xt — funkcija predloge za preoblikovanje slikovne matrike (cv::Mat) na tenzor xt::xtensor;
parse_json — funkcija razčleni niz JSON, izvleče koordinate točk in jih zapakira v vektor;
ocv_draw_lines — iz nastalega vektorja točk nariše večbarvne črte;
process — združuje zgornje funkcije in dodaja tudi možnost spreminjanja velikosti dobljene slike;
cpp_process_json_str - ovoj nad funkcijo process, ki izvozi rezultat v R-objekt (večdimenzionalni niz);
cpp_process_json_vector - ovoj nad funkcijo cpp_process_json_str, ki vam omogoča obdelavo vektorja niza v večnitnem načinu.
Za risanje večbarvnih črt je bil uporabljen barvni model HSV, ki mu je sledila pretvorba v RGB. Preizkusimo rezultat:
Kot lahko vidite, se je povečanje hitrosti izkazalo za zelo pomembno in ni mogoče dohiteti kode C++ s paralelizacijo kode R.
3. Iteratorji za razkladanje paketov iz podatkovne baze
R ima zaslužen sloves pri obdelavi podatkov, ki se prilegajo RAM-u, medtem ko je za Python bolj značilna iterativna obdelava podatkov, ki vam omogoča preprosto in naravno izvajanje izračunov zunaj jedra (izračuni z uporabo zunanjega pomnilnika). Klasičen in za nas relevanten primer v kontekstu opisanega problema so globoke nevronske mreže, trenirane po metodi gradientnega spuščanja z aproksimacijo gradienta na vsakem koraku z uporabo majhnega deleža opazovanj ali mini serije.
Ogrodja za globoko učenje, napisana v Pythonu, imajo posebne razrede, ki izvajajo iteratorje na podlagi podatkov: tabele, slike v mapah, binarne oblike itd. Uporabite lahko že pripravljene možnosti ali napišete svoje za določene naloge. V R lahko izkoristimo vse funkcije knjižnice Python keras s svojimi različnimi zaledji uporablja istoimenski paket, ki pa deluje na vrhu paketa mrežasti. Slednje si zasluži poseben dolg članek; ne omogoča samo izvajanja kode Python iz R, ampak vam omogoča tudi prenos predmetov med sejami R in Python, pri čemer samodejno izvede vse potrebne pretvorbe tipov.
Znebili smo se potrebe po shranjevanju vseh podatkov v RAM-u z uporabo MonetDBLite, vse delo "nevronske mreže" bo opravila izvirna koda v Pythonu, le iterator moramo napisati čez podatke, saj ni nič pripravljenega za takšno situacijo v R ali Pythonu. Zanj sta v bistvu samo dve zahtevi: vračati mora pakete v neskončni zanki in shraniti svoje stanje med iteracijami (slednje je v R implementirano na najpreprostejši način z zapiranjem). Prej je bilo treba izrecno pretvoriti nize R v nize numpy znotraj iteratorja, vendar trenutna različica paketa keras to naredi sama.
Izkazalo se je, da je iterator za podatke o usposabljanju in validaciji naslednji:
Iterator za podatke o usposabljanju in validaciji
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 sprejme kot vhod spremenljivko s povezavo do baze podatkov, število uporabljenih vrstic, število razredov, velikost serije, lestvico (scale = 1 ustreza upodabljanju slik 256x256 slikovnih pik, scale = 0.5 — 128x128 slikovnih pik), barvni indikator (color = FALSE določa upodabljanje v sivinah, kadar se uporablja color = TRUE vsaka poteza je narisana v novi barvi) in indikator predobdelave za omrežja, ki so vnaprej naučena na imagenet. Slednji je potreben za skaliranje vrednosti pikslov iz intervala [0, 1] v interval [-1, 1], ki je bil uporabljen pri usposabljanju dobavljenega keras modeli.
Zunanja funkcija vsebuje preverjanje vrste argumentov, tabelo data.table z naključno mešanimi številkami vrstic od samples_index in številke paketov, števec in največje število paketov ter izraz SQL za razkladanje podatkov iz baze podatkov. Poleg tega smo definirali hiter analog funkcije znotraj keras::to_categorical(). Za usposabljanje smo uporabili skoraj vse podatke, pol odstotka smo pustili za validacijo, zato je bila velikost epohe omejena s parametrom steps_per_epoch ob klicu keras::fit_generator(), in stanje if (i > max_i) deloval samo za validacijski iterator.
V notranji funkciji se indeksi vrstic pridobijo za naslednji paket, zapisi se razložijo iz baze podatkov z naraščajočim števcem paketov, razčlenjevanje JSON (funkcija cpp_process_json_vector(), napisano v C++) in ustvarjanje nizov, ki ustrezajo slikam. Nato se ustvarijo enkratni vektorji z oznakami razreda, nizi z vrednostmi slikovnih pik in oznakami se združijo v seznam, ki je povratna vrednost. Za pospešitev dela smo uporabili ustvarjanje indeksov v tabelah data.table in spreminjanje preko povezave - brez teh paketnih “čipov” podatki.tabela Težko si je predstavljati učinkovito delo s katero koli veliko količino podatkov v R.
Rezultati meritev hitrosti na prenosniku Core i5 so naslednji:
Če imate zadostno količino RAM-a, lahko resno pospešite delovanje baze s prenosom v ta isti RAM (32 GB je dovolj za našo nalogo). V Linuxu je particija privzeto nameščena /dev/shm, ki zasedejo do polovice kapacitete RAM-a. Več lahko poudarite z urejanjem /etc/fstabda dobim zapis všeč tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Ne pozabite znova zagnati in preveriti rezultat z zagonom ukaza df -h.
Iterator za testne podatke je videti veliko enostavnejši, saj se testni nabor podatkov v celoti prilega RAM-u:
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. Izbira arhitekture modela
Prva uporabljena arhitektura je bila mobilenet v1, katere značilnosti so obravnavane v to sporočilo. Standardno je vključen keras in je v skladu s tem na voljo v istoimenskem paketu za R. Toda pri poskusu uporabe z enokanalnimi slikami se je izkazalo čudno: vhodni tenzor mora vedno imeti dimenzijo (batch, height, width, 3), to pomeni, da števila kanalov ni mogoče spremeniti. V Pythonu te omejitve ni, zato smo pohiteli in napisali lastno implementacijo te arhitekture po izvirnem članku (brez izpada, ki je v različici keras):
Slabosti tega pristopa so očitne. Želim preizkusiti veliko modelov, a ravno nasprotno, ne želim ročno prepisati vsake arhitekture. Prav tako smo bili prikrajšani za možnost uporabe uteži modelov, predhodno usposobljenih na imagenetu. Kot običajno je pomagalo preučevanje dokumentacije. funkcija get_config() vam omogoča, da dobite opis modela v obliki, primerni za urejanje (base_model_conf$layers - običajni seznam R) in funkcijo from_config() izvede obratno pretvorbo v objekt modela:
Zdaj ni težko napisati univerzalne funkcije za pridobitev katerega koli od dobavljenega keras modeli z ali brez uteži, trenirani na imagenetu:
Funkcija za nalaganje že pripravljenih arhitektur
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 uporabi enokanalnih slik se ne uporabljajo vnaprej pripravljene uteži. To je mogoče popraviti: z uporabo funkcije get_weights() pridobite uteži modela v obliki seznama nizov R, spremenite dimenzijo prvega elementa tega seznama (tako da vzamete en barvni kanal ali povprečite vse tri) in nato naložite uteži nazaj v model s funkcijo set_weights(). Te funkcionalnosti nismo nikoli dodali, ker je bilo na tej stopnji že jasno, da je bolj produktivno delati z barvnimi slikami.
Večino poskusov smo izvedli z uporabo mobilenet različic 1 in 2 ter resnet34. Sodobnejše arhitekture, kot je SE-ResNeXt, so se v tem tekmovanju dobro izkazale. Na žalost nismo imeli na razpolago že pripravljenih izvedb, svoje pa nismo napisali (vendar jo bomo zagotovo napisali).
5. Parametriranje skriptov
Zaradi priročnosti je bila vsa koda za začetek usposabljanja zasnovana kot en sam skript, parametriran z uporabo dokpt kot sledi:
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 izvedbo http://docopt.org/ za R. Z njegovo pomočjo se skripti zaženejo s preprostimi ukazi, kot je Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db ali ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, če datoteka train_nn.R je izvršljiv (ta ukaz bo začel usposabljati model resnet50 na tribarvnih slikah velikosti 128 x 128 pik mora biti baza podatkov v mapi /home/andrey/doodle_db). Na seznam lahko dodate hitrost učenja, vrsto optimizatorja in vse druge prilagodljive parametre. V procesu priprave publikacije se je izkazalo, da je arhitektura mobilenet_v2 iz trenutne različice keras v uporabi R ne morem zaradi neupoštevanih sprememb v R paketu čakamo da popravijo.
Ta pristop je omogočil znatno pospešitev poskusov z različnimi modeli v primerjavi z bolj tradicionalnim zagonom skriptov v RStudiu (paket označujemo kot možno alternativo tfruns). Toda glavna prednost je zmožnost enostavnega upravljanja zagona skriptov v Dockerju ali preprosto na strežniku, ne da bi za to namestili RStudio.
6. Dockerizacija skriptov
Docker smo uporabili za zagotovitev prenosljivosti okolja za usposabljanje modelov med člani ekipe in za hitro uvajanje v oblaku. S tem orodjem, ki je razmeroma neobičajno za programerja R, se lahko začnete seznanjati z to serije publikacij oz video tečaj.
Docker vam omogoča ustvarjanje lastnih slik iz nič in uporabo drugih slik kot osnovo za ustvarjanje lastnih. Pri analizi razpoložljivih možnosti smo prišli do zaključka, da je namestitev gonilnikov NVIDIA, CUDA+cuDNN in knjižnic Python precej obsežen del slike, zato smo se odločili, da za osnovo vzamemo uradno sliko. tensorflow/tensorflow:1.12.0-gpuin tam dodal potrebne pakete R.
Zaradi udobja so bili uporabljeni paketi postavljeni v spremenljivke; večina napisanih skriptov se kopira v vsebnike med sestavljanjem. Spremenili smo tudi ukazno lupino v /bin/bash za lažjo uporabo vsebine /etc/os-release. S tem se izognete potrebi po navedbi različice OS v kodi.
Poleg tega je bil napisan majhen bash skript, ki vam omogoča zagon vsebnika z različnimi ukazi. To so lahko na primer skripti za usposabljanje nevronskih mrež, ki so bile predhodno nameščene v vsebniku, ali ukazna lupina za odpravljanje napak in spremljanje delovanja vsebnika:
Skript za zagon vsebnika
#!/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}
Če se ta skript bash izvaja brez parametrov, bo skript poklican znotraj vsebnika train_nn.R s privzetimi vrednostmi; če je prvi pozicijski argument "bash", se bo vsebnik interaktivno zagnal z ukazno lupino. V vseh drugih primerih se vrednosti pozicijskih argumentov nadomestijo: CMD="Rscript /app/train_nn.R $@".
Omeniti velja, da so imeniki z izvornimi podatki in bazo podatkov ter imenik za shranjevanje usposobljenih modelov nameščeni znotraj vsebnika iz gostiteljskega sistema, kar vam omogoča dostop do rezultatov skriptov brez nepotrebnih manipulacij.
7. Uporaba več grafičnih procesorjev v Google Cloudu
Ena od značilnosti tekmovanja so bili zelo šumni podatki (glej naslovno sliko, izposojeno od @Leigh.plt pri ODS slack). Velike serije pomagajo pri boju proti temu in po poskusih na osebnem računalniku z 1 grafično procesorsko enoto smo se odločili obvladati modele usposabljanja na več grafičnih procesorjih v oblaku. Uporabljeno GoogleCloud (dober vodnik po osnovah) zaradi velike izbire razpoložljivih konfiguracij, ugodnih cen in 300 $ bonusa. Iz pohlepa sem naročil primerek 4xV100 s SSD in tono RAM-a in to je bila velika napaka. Tak stroj hitro požre denar, z eksperimentiranjem brez preizkušenega cevovoda lahko propadete. Za izobraževalne namene je bolje vzeti K80. Toda velika količina RAM-a je prišla še kako prav - oblačni SSD ni navdušil s svojo zmogljivostjo, zato so bazo podatkov prenesli na dev/shm.
Najbolj zanimiv je fragment kode, odgovoren za uporabo več grafičnih procesorjev. Najprej je model ustvarjen v CPU z uporabo upravitelja konteksta, tako kot v Pythonu:
Klasične tehnike zamrznitve vseh plasti razen zadnje, usposabljanje zadnje plasti, odmrznitev in ponovno usposabljanje celotnega modela za več grafičnih procesorjev ni bilo mogoče izvesti.
Usposabljanje je bilo spremljano brez uporabe. tenzorska plošča, pri čemer se omejimo na snemanje dnevnikov in shranjevanje modelov z informativnimi imeni po vsaki epohi:
Številne težave, s katerimi smo se srečali, še niso bile premagane:
в keras ni pripravljene funkcije za samodejno iskanje optimalne hitrosti učenja (analogno lr_finder v knjižnici hitro.ai); Z nekaj truda je mogoče v R prenesti izvedbe tretjih oseb, na primer to;
kot posledica prejšnje točke ni bilo mogoče izbrati pravilne hitrosti vadbe pri uporabi več grafičnih procesorjev;
primanjkuje sodobnih arhitektur nevronskih mrež, zlasti tistih, ki so vnaprej usposobljene za imagenet;
politika brez enega cikla in diskriminativne stopnje učenja (kosinusno žarjenje je bilo na našo zahtevo izvajati, hvala skeydan).
Kaj koristnega smo se naučili iz tega tekmovanja:
Na strojni opremi z razmeroma nizko porabo energije lahko brez težav delate s spodobnimi (večkrat večjimi od RAM-a) količinami podatkov. Plastična vrečka podatki.tabela prihrani pomnilnik zaradi spreminjanja tabel na mestu, s čimer se izognemo njihovemu kopiranju, ob pravilni uporabi pa njegove zmogljivosti skoraj vedno izkazujejo najvišjo hitrost med vsemi znanimi orodji za skriptne jezike. Shranjevanje podatkov v bazo podatkov vam v mnogih primerih omogoča, da sploh ne razmišljate o potrebi po stiskanju celotnega nabora podatkov v RAM.
Počasne funkcije v R je mogoče zamenjati s hitrimi v C++ s pomočjo paketa Rcpp. Če poleg uporabe RcppThread ali RcppParallel, dobimo medplatformske večnitne izvedbe, tako da ni potrebe po paraleliziranju kode na ravni R.
Paket Rcpp se lahko uporablja brez resnega znanja C++, zahtevani minimum je opisan tukaj. Datoteke glave za številne odlične knjižnice C, kot je xtenzor na voljo na CRAN, torej se oblikuje infrastruktura za izvajanje projektov, ki integrirajo že pripravljeno visoko zmogljivo kodo C++ v R. Dodatno udobje je označevanje sintakse in statični analizator kode C++ v RStudiu.
dokpt vam omogoča zagon samostojnih skriptov s parametri. To je priročno za uporabo na oddaljenem strežniku, vklj. pod dokerjem. V RStudio je neprijetno izvajati veliko ur poskusov z usposabljanjem nevronskih mrež, namestitev IDE na sam strežnik pa ni vedno upravičena.
Docker zagotavlja prenosljivost kode in ponovljivost rezultatov med razvijalci z različnimi različicami operacijskega sistema in knjižnicami ter enostavnost izvajanja na strežnikih. Celoten cevovod usposabljanja lahko zaženete s samo enim ukazom.
Google Cloud je proračunu prijazen način za eksperimentiranje z drago strojno opremo, vendar morate skrbno izbrati konfiguracije.
Merjenje hitrosti posameznih fragmentov kode je zelo uporabno, zlasti pri kombinaciji R in C++ ter s paketom klop - tudi zelo enostavno.
Na splošno je bila ta izkušnja zelo koristna in še naprej si prizadevamo rešiti nekatere postavljene težave.