ProHoster > Blog > administração > Quick Draw Doodle Recognition: como fazer amizade com R, C++ e redes neurais
Quick Draw Doodle Recognition: como fazer amizade com R, C++ e redes neurais
Oi, Habr!
No outono passado, Kaggle organizou uma competição para classificar imagens desenhadas à mão, Quick Draw Doodle Recognition, na qual, entre outros, participou uma equipe de cientistas R: Artem Klevtsov, Gerente Filipa и Andrey Ogurtsov. Não descreveremos a competição em detalhes; isso já foi feito em publicação recente.
Desta vez não deu certo com o cultivo de medalhas, mas muita experiência valiosa foi adquirida, então gostaria de contar à comunidade sobre uma série de coisas mais interessantes e úteis no Kagle e no trabalho diário. Entre os temas discutidos: vida difícil sem OpenCV, análise JSON (estes exemplos examinam a integração do código C++ em scripts ou pacotes em R usando RCPP), parametrização de scripts e dockerização da solução final. Todo o código da mensagem em um formato adequado para execução está disponível em repositórios.
1. Carregar dados CSV com eficiência no banco de dados MonetDB
Os dados desta competição são fornecidos não na forma de imagens prontas, mas na forma de 340 arquivos CSV (um arquivo para cada classe) contendo JSONs com coordenadas de pontos. Ao conectar esses pontos com linhas, obtemos uma imagem final medindo 256x256 pixels. Além disso, para cada registro há uma etiqueta indicando se a imagem foi corretamente reconhecida pelo classificador utilizado no momento da coleta do conjunto de dados, um código de duas letras do país de residência do autor da imagem, um identificador único, um carimbo de data/hora e um nome de classe que corresponda ao nome do arquivo. A versão simplificada dos dados originais pesa 7.4 GB no arquivo e aproximadamente 20 GB após a descompactação, os dados completos após a descompactação ocupam 240 GB. Os organizadores garantiram que ambas as versões reproduzissem os mesmos desenhos, o que significa que a versão completa era redundante. De qualquer forma, armazenar 50 milhões de imagens em arquivos gráficos ou na forma de arrays foi imediatamente considerado não lucrativo, e decidimos mesclar todos os arquivos CSV do arquivo trem_simplificado.zip no banco de dados com posterior geração de imagens do tamanho necessário “on the fly” para cada lote.
Um sistema comprovado foi escolhido como SGBD MonetDB, ou seja, uma implementação para R como um pacote MonetDBLite. O pacote inclui uma versão incorporada do servidor de banco de dados e permite que você pegue o servidor diretamente de uma sessão R e trabalhe com ele lá. A criação de um banco de dados e a conexão a ele são realizadas com um comando:
con <- DBI::dbConnect(drv = MonetDBLite::MonetDBLite(), Sys.getenv("DBDIR"))
Precisaremos criar duas tabelas: uma para todos os dados e outra para informações de serviço sobre os arquivos baixados (útil se algo der errado e o processo precisar ser retomado após o download de vários arquivos):
A maneira mais rápida de carregar dados no banco de dados era copiar diretamente os arquivos CSV usando o comando SQL COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTOnde tablename - nome da tabela e path - o caminho para o arquivo. Ao trabalhar com o arquivo, descobriu-se que a implementação integrada unzip em R não funciona corretamente com vários arquivos do arquivo, então usamos o sistema unzip (usando o parâmetro getOption("unzip")).
Função para escrever no banco de dados
#' @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))
}
Se precisar transformar a tabela antes de gravá-la no banco de dados, basta passar o argumento preprocess função que transformará os dados.
Código para carregar dados sequencialmente no banco de dados:
Gravando dados no banco de dados
# Список файлов для записи
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
O tempo de carregamento dos dados pode variar dependendo das características de velocidade do drive utilizado. No nosso caso, ler e gravar em um SSD ou de uma unidade flash (arquivo de origem) em um SSD (DB) leva menos de 10 minutos.
Demora mais alguns segundos para criar uma coluna com um rótulo de classe inteira e uma coluna de índice (ORDERED INDEX) com números de linha pelos quais as observações serão amostradas ao criar lotes:
Criação de colunas e índices adicionais
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)"))
Para resolver o problema de criação de um lote dinamicamente, precisávamos atingir a velocidade máxima de extração de linhas aleatórias da tabela doodles. Para isso usamos 3 truques. A primeira foi reduzir a dimensionalidade do tipo que armazena o ID da observação. No conjunto de dados original, o tipo necessário para armazenar o ID é bigint, mas o número de observações permite encaixar seus identificadores, iguais ao número ordinal, no tipo int. A busca é muito mais rápida neste caso. O segundo truque foi usar ORDERED INDEX - chegamos a esta decisão empiricamente, tendo passado por todos os disponíveis opções. A terceira foi usar consultas parametrizadas. A essência do método é executar o comando uma vez PREPARE com posterior uso de uma expressão preparada ao criar várias consultas do mesmo tipo, mas na verdade há uma vantagem em comparação com uma simples SELECT acabou por estar dentro da faixa de erro estatístico.
O processo de upload de dados não consome mais de 450 MB de RAM. Ou seja, a abordagem descrita permite mover conjuntos de dados pesando dezenas de gigabytes em quase qualquer hardware de orçamento, incluindo alguns dispositivos de placa única, o que é muito legal.
Tudo o que resta é medir a velocidade de recuperação de dados (aleatórios) e avaliar o dimensionamento ao amostrar lotes de tamanhos diferentes:
Referência de banco de dados
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. Preparando lotes
Todo o processo de preparação do lote consiste nas seguintes etapas:
Analisando vários JSONs contendo vetores de strings com coordenadas de pontos.
Desenhar linhas coloridas com base nas coordenadas dos pontos em uma imagem do tamanho necessário (por exemplo, 256×256 ou 128×128).
Convertendo as imagens resultantes em um tensor.
Como parte da competição entre os kernels Python, o problema foi resolvido principalmente usando OpenCV. Um dos análogos mais simples e óbvios em R seria assim:
Implementando conversão de JSON para tensor em 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)
}
O desenho é executado usando ferramentas R padrão e salvo em um PNG temporário armazenado na RAM (no Linux, os diretórios R temporários estão localizados no diretório /tmp, montado na RAM). Este arquivo é então lido como uma matriz tridimensional com números variando de 0 a 1. Isso é importante porque um BMP mais convencional seria lido em uma matriz bruta com códigos de cores hexadecimais.
Esta implementação nos pareceu abaixo do ideal, já que a formação de grandes lotes leva um tempo indecentemente longo, e decidimos aproveitar a experiência de nossos colegas usando uma poderosa biblioteca OpenCV. Naquela época não havia nenhum pacote pronto para R (não há nenhum agora), então uma implementação mínima da funcionalidade necessária foi escrita em C++ com integração ao código R usando RCPP.
Para resolver o problema, foram utilizados os seguintes pacotes e bibliotecas:
OpenCV para trabalhar com imagens e desenhar linhas. Bibliotecas de sistema e arquivos de cabeçalho pré-instalados usados, bem como links dinâmicos.
xtensor para trabalhar com matrizes e tensores multidimensionais. Usamos arquivos de cabeçalho incluídos no pacote R de mesmo nome. A biblioteca permite que você trabalhe com matrizes multidimensionais, tanto na ordem principal da linha quanto na ordem principal da coluna.
djson para analisar JSON. Esta biblioteca é usada em xtensor automaticamente se estiver presente no projeto.
RcppThread para organizar o processamento multithread de um vetor de JSON. Usou os arquivos de cabeçalho fornecidos por este pacote. Dos mais populares RcppParalelo O pacote, entre outras coisas, possui um mecanismo de interrupção de loop integrado.
Deve notar-se que xtensor acabou sendo uma dádiva de Deus: além de possuir ampla funcionalidade e alto desempenho, seus desenvolvedores se mostraram bastante receptivos e responderam às perguntas com rapidez e detalhes. Com a ajuda deles, foi possível implementar transformações de matrizes OpenCV em tensores xtensor, bem como uma forma de combinar tensores de imagem tridimensionais em um tensor quadridimensional da dimensão correta (o próprio lote).
Materiais para aprender Rcpp, xtensor e RcppThread
Para compilar arquivos que utilizam arquivos de sistema e vinculação dinâmica com bibliotecas instaladas no sistema, utilizamos o mecanismo de plugin implementado no pacote RCPP. Para encontrar caminhos e sinalizadores automaticamente, usamos um utilitário Linux popular pacote-config.
Implementação do plugin Rcpp para utilização da biblioteca OpenCV
O código de implementação para analisar JSON e gerar um lote para transmissão ao modelo é fornecido no spoiler. Primeiro, adicione um diretório de projeto local para procurar arquivos de cabeçalho (necessários para ndjson):
Implementação de JSON para conversão de tensor em C++
// [[Rcpp::plugins(cpp14)]]
// [[Rcpp::plugins(opencv)]]
// [[Rcpp::depends(xtensor)]]
// [[Rcpp::depends(RcppThread)]]
#include <xtensor/xjson.hpp>
#include <xtensor/xadapt.hpp>
#include <xtensor/xview.hpp>
#include <xtensor-r/rtensor.hpp>
#include <opencv2/core/core.hpp>
#include <opencv2/highgui/highgui.hpp>
#include <opencv2/imgproc/imgproc.hpp>
#include <Rcpp.h>
#include <RcppThread.h>
// Синонимы для типов
using RcppThread::parallelFor;
using json = nlohmann::json;
using points = xt::xtensor<double,2>; // Извлечённые из JSON координаты точек
using strokes = std::vector<points>; // Извлечённые из JSON координаты точек
using xtensor3d = xt::xtensor<double, 3>; // Тензор для хранения матрицы изоображения
using xtensor4d = xt::xtensor<double, 4>; // Тензор для хранения множества изображений
using rtensor3d = xt::rtensor<double, 3>; // Обёртка для экспорта в R
using rtensor4d = xt::rtensor<double, 4>; // Обёртка для экспорта в R
// Статические константы
// Размер изображения в пикселях
const static int SIZE = 256;
// Тип линии
// См. https://en.wikipedia.org/wiki/Pixel_connectivity#2-dimensional
const static int LINE_TYPE = cv::LINE_4;
// Толщина линии в пикселях
const static int LINE_WIDTH = 3;
// Алгоритм ресайза
// https://docs.opencv.org/3.1.0/da/d54/group__imgproc__transform.html#ga5bb5a1fea74ea38e1a5445ca803ff121
const static int RESIZE_TYPE = cv::INTER_LINEAR;
// Шаблон для конвертирования OpenCV-матрицы в тензор
template <typename T, int NCH, typename XT=xt::xtensor<T,3,xt::layout_type::column_major>>
XT to_xt(const cv::Mat_<cv::Vec<T, NCH>>& src) {
// Размерность целевого тензора
std::vector<int> shape = {src.rows, src.cols, NCH};
// Общее количество элементов в массиве
size_t size = src.total() * NCH;
// Преобразование cv::Mat в xt::xtensor
XT res = xt::adapt((T*) src.data, size, xt::no_ownership(), shape);
return res;
}
// Преобразование JSON в список координат точек
strokes parse_json(const std::string& x) {
auto j = json::parse(x);
// Результат парсинга должен быть массивом
if (!j.is_array()) {
throw std::runtime_error("'x' must be JSON array.");
}
strokes res;
res.reserve(j.size());
for (const auto& a: j) {
// Каждый элемент массива должен быть 2-мерным массивом
if (!a.is_array() || a.size() != 2) {
throw std::runtime_error("'x' must include only 2d arrays.");
}
// Извлечение вектора точек
auto p = a.get<points>();
res.push_back(p);
}
return res;
}
// Отрисовка линий
// Цвета HSV
cv::Mat ocv_draw_lines(const strokes& x, bool color = true) {
// Исходный тип матрицы
auto stype = color ? CV_8UC3 : CV_8UC1;
// Итоговый тип матрицы
auto dtype = color ? CV_32FC3 : CV_32FC1;
auto bg = color ? cv::Scalar(0, 0, 255) : cv::Scalar(255);
auto col = color ? cv::Scalar(0, 255, 220) : cv::Scalar(0);
cv::Mat img = cv::Mat(SIZE, SIZE, stype, bg);
// Количество линий
size_t n = x.size();
for (const auto& s: x) {
// Количество точек в линии
size_t n_points = s.shape()[1];
for (size_t i = 0; i < n_points - 1; ++i) {
// Точка начала штриха
cv::Point from(s(0, i), s(1, i));
// Точка окончания штриха
cv::Point to(s(0, i + 1), s(1, i + 1));
// Отрисовка линии
cv::line(img, from, to, col, LINE_WIDTH, LINE_TYPE);
}
if (color) {
// Меняем цвет линии
col[0] += 180 / n;
}
}
if (color) {
// Меняем цветовое представление на RGB
cv::cvtColor(img, img, cv::COLOR_HSV2RGB);
}
// Меняем формат представления на float32 с диапазоном [0, 1]
img.convertTo(img, dtype, 1 / 255.0);
return img;
}
// Обработка JSON и получение тензора с данными изображения
xtensor3d process(const std::string& x, double scale = 1.0, bool color = true) {
auto p = parse_json(x);
auto img = ocv_draw_lines(p, color);
if (scale != 1) {
cv::Mat out;
cv::resize(img, out, cv::Size(), scale, scale, RESIZE_TYPE);
cv::swap(img, out);
out.release();
}
xtensor3d arr = color ? to_xt<double,3>(img) : to_xt<double,1>(img);
return arr;
}
// [[Rcpp::export]]
rtensor3d cpp_process_json_str(const std::string& x,
double scale = 1.0,
bool color = true) {
xtensor3d res = process(x, scale, color);
return res;
}
// [[Rcpp::export]]
rtensor4d cpp_process_json_vector(const std::vector<std::string>& x,
double scale = 1.0,
bool color = false) {
size_t n = x.size();
size_t dim = floor(SIZE * scale);
size_t channels = color ? 3 : 1;
xtensor4d res({n, dim, dim, channels});
parallelFor(0, n, [&x, &res, scale, color](int i) {
xtensor3d tmp = process(x[i], scale, color);
auto view = xt::view(res, i, xt::all(), xt::all(), xt::all());
view = tmp;
});
return res;
}
Este código deve ser colocado no arquivo src/cv_xt.cpp e compile com o comando Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); também necessário para o trabalho nlohmann/json.hpp de repositório. O código está dividido em diversas funções:
to_xt — uma função modelo para transformar uma matriz de imagem (cv::Mat) para um tensor xt::xtensor;
parse_json — a função analisa uma string JSON, extrai as coordenadas dos pontos, empacotando-as em um vetor;
ocv_draw_lines — a partir do vetor de pontos resultante, desenha linhas multicoloridas;
process — combina as funções acima e também adiciona a capacidade de dimensionar a imagem resultante;
cpp_process_json_str - wrapper sobre a função process, que exporta o resultado para um objeto R (matriz multidimensional);
cpp_process_json_vector - wrapper sobre a função cpp_process_json_str, que permite processar um vetor de string no modo multithread.
Para desenhar linhas multicoloridas foi utilizado o modelo de cores HSV, seguido de conversão para RGB. Vamos testar o resultado:
Como você pode ver, o aumento de velocidade acabou sendo muito significativo e não é possível acompanhar o código C++ paralelizando o código R.
3. Iteradores para descarregar lotes do banco de dados
R tem uma reputação merecida de processamento de dados que cabem na RAM, enquanto Python é mais caracterizado pelo processamento de dados iterativo, permitindo implementar de forma fácil e natural cálculos fora do núcleo (cálculos usando memória externa). Um exemplo clássico e relevante para nós no contexto do problema descrito são as redes neurais profundas treinadas pelo método gradiente descendente com aproximação do gradiente em cada etapa usando uma pequena porção de observações, ou minilote.
Estruturas de aprendizado profundo escritas em Python possuem classes especiais que implementam iteradores baseados em dados: tabelas, imagens em pastas, formatos binários, etc. Em R podemos aproveitar todos os recursos da biblioteca Python keras com seus diversos backends usando o pacote de mesmo nome, que por sua vez funciona em cima do pacote reticulado. Este último merece um longo artigo separado; ele não apenas permite executar código Python a partir de R, mas também permite transferir objetos entre sessões R e Python, executando automaticamente todas as conversões de tipo necessárias.
Nos livramos da necessidade de armazenar todos os dados na RAM usando o MonetDBLite, todo o trabalho de “rede neural” será realizado pelo código original em Python, bastando escrever um iterador sobre os dados, já que não há nada pronto para tal situação em R ou Python. Existem essencialmente apenas dois requisitos para isso: ele deve retornar lotes em um loop infinito e salvar seu estado entre as iterações (o último em R é implementado da maneira mais simples usando encerramentos). Anteriormente, era necessário converter explicitamente matrizes R em matrizes numpy dentro do iterador, mas a versão atual do pacote keras faz isso sozinha.
O iterador para dados de treinamento e validação ficou assim:
Iterador para dados de treinamento e validação
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)
}
}
A função toma como entrada uma variável com conexão ao banco de dados, o número de linhas utilizadas, o número de classes, tamanho do lote, escala (scale = 1 corresponde à renderização de imagens de 256x256 pixels, scale = 0.5 — 128x128 pixels), indicador de cor (color = FALSE especifica a renderização em escala de cinza quando usada color = TRUE cada traço é desenhado em uma nova cor) e um indicador de pré-processamento para redes pré-treinadas no imagenet. Este último é necessário para dimensionar os valores dos pixels do intervalo [0, 1] para o intervalo [-1, 1], que foi usado ao treinar o fornecido keras modelos.
A função externa contém verificação de tipo de argumento, uma tabela data.table com números de linha misturados aleatoriamente de samples_index e números de lote, contador e número máximo de lotes, além de expressão SQL para descarregamento de dados do banco de dados. Além disso, definimos um análogo rápido da função dentro keras::to_categorical(). Usamos quase todos os dados para treinamento, deixando meio por cento para validação, portanto o tamanho da época foi limitado pelo parâmetro steps_per_epoch ao ligar keras::fit_generator(), e a condição if (i > max_i) funcionou apenas para o iterador de validação.
Na função interna, os índices de linha são recuperados para o próximo lote, os registros são descarregados do banco de dados com o contador do lote aumentando, análise JSON (função cpp_process_json_vector(), escrito em C++) e criando arrays correspondentes a imagens. Em seguida, são criados vetores one-hot com rótulos de classe, matrizes com valores de pixel e rótulos são combinadas em uma lista, que é o valor de retorno. Para agilizar o trabalho, utilizamos a criação de índices em tabelas data.table e modificação através do link - sem esses “chips” de pacote Tabela de dados É muito difícil imaginar trabalhar de forma eficaz com qualquer quantidade significativa de dados em R.
Os resultados das medições de velocidade em um laptop Core i5 são os seguintes:
Se você tiver RAM suficiente, poderá acelerar seriamente o funcionamento do banco de dados transferindo-o para esta mesma RAM (32 GB são suficientes para nossa tarefa). No Linux, a partição é montada por padrão /dev/shm, ocupando até metade da capacidade da RAM. Você pode destacar mais editando /etc/fstabpara obter um registro como tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Certifique-se de reiniciar e verificar o resultado executando o comando df -h.
O iterador para dados de teste parece muito mais simples, já que o conjunto de dados de teste cabe inteiramente na RAM:
Iterador para dados de teste
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. Seleção da arquitetura do modelo
A primeira arquitetura utilizada foi rede móvel v1, cujas características são discutidas em esta mensagem. Está incluído como padrão keras e, portanto, está disponível no pacote de mesmo nome para R. Mas ao tentar usá-lo com imagens monocanal, aconteceu uma coisa estranha: o tensor de entrada deve sempre ter a dimensão (batch, height, width, 3), ou seja, o número de canais não pode ser alterado. Não existe tal limitação em Python, então nos apressamos e escrevemos nossa própria implementação dessa arquitetura, seguindo o artigo original (sem o dropout que está na versão keras):
As desvantagens desta abordagem são óbvias. Quero testar muitos modelos, mas pelo contrário, não quero reescrever cada arquitetura manualmente. Também fomos privados da oportunidade de utilizar os pesos de modelos pré-treinados no imagenet. Como sempre, estudar a documentação ajudou. Função get_config() permite obter uma descrição do modelo em um formato adequado para edição (base_model_conf$layers - uma lista R regular) e a função from_config() realiza a conversão reversa para um objeto modelo:
Agora não é difícil escrever uma função universal para obter qualquer um dos dados fornecidos keras modelos com ou sem pesos treinados no imagenet:
Função para carregar arquiteturas prontas
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)
}
Ao usar imagens de canal único, nenhum peso pré-treinado é usado. Isso poderia ser corrigido: usando a função get_weights() obtenha os pesos do modelo na forma de uma lista de matrizes R, altere a dimensão do primeiro elemento desta lista (tomando um canal de cor ou calculando a média de todos os três) e, em seguida, carregue os pesos de volta no modelo com a função set_weights(). Nunca adicionamos essa funcionalidade, pois nesta fase já estava claro que era mais produtivo trabalhar com imagens coloridas.
Realizamos a maioria dos experimentos usando as versões 1 e 2 do mobilenet, bem como o resnet34. Arquiteturas mais modernas como SE-ResNeXt tiveram um bom desempenho nesta competição. Infelizmente, não tínhamos implementações prontas à nossa disposição e não escrevemos as nossas próprias (mas com certeza escreveremos).
5. Parametrização de scripts
Por conveniência, todo o código para iniciar o treinamento foi projetado como um único script, parametrizado usando documento da seguinte maneira:
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)
Pacote documento representa a implementação http://docopt.org/ para R. Com sua ajuda, os scripts são iniciados com comandos simples como Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db ou ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, se arquivo train_nn.R é executável (este comando começará a treinar o modelo resnet50 em imagens tricolores medindo 128x128 pixels, o banco de dados deve estar localizado na pasta /home/andrey/doodle_db). Você pode adicionar velocidade de aprendizado, tipo de otimizador e quaisquer outros parâmetros personalizáveis à lista. No processo de preparação da publicação, descobriu-se que a arquitetura mobilenet_v2 da versão atual keras em uso R não deve devido a alterações não levadas em consideração no pacote R, estamos aguardando que consertem.
Esta abordagem permitiu acelerar significativamente os experimentos com diferentes modelos em comparação com o lançamento mais tradicional de scripts no RStudio (notamos o pacote como uma possível alternativa tfruns). Mas a principal vantagem é a possibilidade de gerenciar facilmente o lançamento de scripts no Docker ou simplesmente no servidor, sem instalar o RStudio para isso.
6. Dockerização de scripts
Utilizamos o Docker para garantir a portabilidade do ambiente para treinamento de modelos entre membros da equipe e para rápida implantação na nuvem. Você pode começar a se familiarizar com esta ferramenta, que é relativamente incomum para um programador R, com este série de publicações ou curso de vídeo.
O Docker permite que você crie suas próprias imagens do zero e use outras imagens como base para criar as suas próprias. Ao analisar as opções disponíveis, chegamos à conclusão de que a instalação de drivers NVIDIA, CUDA+cuDNN e bibliotecas Python é uma parte bastante volumosa da imagem, e decidimos tomar a imagem oficial como base tensorflow/tensorflow:1.12.0-gpu, adicionando os pacotes R necessários lá.
Por conveniência, os pacotes utilizados foram colocados em variáveis; a maior parte dos scripts escritos é copiada dentro dos contêineres durante a montagem. Também alteramos o shell de comando para /bin/bash para facilidade de uso do conteúdo /etc/os-release. Isso evitou a necessidade de especificar a versão do sistema operacional no código.
Além disso, foi escrito um pequeno script bash que permite iniciar um contêiner com vários comandos. Por exemplo, podem ser scripts para treinar redes neurais que foram previamente colocadas dentro do contêiner ou um shell de comando para depurar e monitorar a operação do contêiner:
Script para iniciar o contêiner
#!/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}
Se este script bash for executado sem parâmetros, o script será chamado dentro do contêiner train_nn.R com valores padrão; se o primeiro argumento posicional for "bash", o contêiner iniciará interativamente com um shell de comando. Em todos os outros casos, os valores dos argumentos posicionais são substituídos: CMD="Rscript /app/train_nn.R $@".
Vale ressaltar que os diretórios com dados de origem e banco de dados, bem como o diretório para salvamento dos modelos treinados, são montados dentro do container do sistema host, o que permite acessar os resultados dos scripts sem manipulações desnecessárias.
7. Usando várias GPUs no Google Cloud
Uma das características da competição foram os dados muito barulhentos (veja a imagem do título, emprestada de @Leigh.plt do ODS slack). Lotes grandes ajudam a combater isso e, após experimentos em um PC com 1 GPU, decidimos dominar modelos de treinamento em várias GPUs na nuvem. GoogleCloud usado (bom guia para o básico) devido à grande variedade de configurações disponíveis, preços razoáveis e bônus de US$ 300. Por ganância, encomendei uma instância 4xV100 com SSD e muita RAM, e isso foi um grande erro. Essa máquina consome dinheiro rapidamente; você pode ir à falência experimentando sem um pipeline comprovado. Para fins educacionais, é melhor levar o K80. Mas a grande quantidade de RAM foi útil - o SSD em nuvem não impressionou com seu desempenho, então o banco de dados foi transferido para dev/shm.
De maior interesse é o fragmento de código responsável pelo uso de múltiplas GPUs. Primeiro, o modelo é criado na CPU usando um gerenciador de contexto, assim como em Python:
A técnica clássica de congelar todas as camadas exceto a última, treinar a última camada, descongelar e retreinar todo o modelo para várias GPUs não pôde ser implementada.
O treinamento foi monitorado sem uso. placa tensora, limitando-nos a registrar logs e salvar modelos com nomes informativos após cada época:
Vários problemas que encontramos ainda não foram superados:
в keras não existe uma função pronta para procurar automaticamente a taxa de aprendizagem ideal (analógico lr_finder na biblioteca rápido.ai); Com algum esforço é possível portar implementações de terceiros para R, por exemplo, este;
como consequência do ponto anterior, não foi possível selecionar a velocidade correta de treinamento ao utilizar várias GPUs;
faltam arquiteturas de redes neurais modernas, especialmente aquelas pré-treinadas em imagenet;
política de ciclo único e taxas de aprendizagem discriminativas (recozimento de cosseno foi a nosso pedido implementadoobrigado Skeydan).
Que coisas úteis foram aprendidas nesta competição:
Em hardware de consumo relativamente baixo, você pode trabalhar com volumes de dados decentes (muitas vezes o tamanho da RAM) sem problemas. Saco de plástico Tabela de dados economiza memória devido à modificação in-loco de tabelas, o que evita copiá-las e, quando usado corretamente, seus recursos quase sempre demonstram a maior velocidade entre todas as ferramentas que conhecemos para linguagens de script. Salvar dados em um banco de dados permite, em muitos casos, nem pensar na necessidade de comprimir todo o conjunto de dados na RAM.
Funções lentas em R podem ser substituídas por funções rápidas em C++ usando o pacote RCPP. Se além de usar RcppThread ou RcppParalelo, obtemos implementações multithread multiplataforma, portanto não há necessidade de paralelizar o código no nível R.
pacote RCPP pode ser usado sem conhecimento sério de C++, o mínimo exigido é descrito aqui. Arquivos de cabeçalho para várias bibliotecas C interessantes, como xtensor disponível no CRAN, ou seja, está sendo formada uma infraestrutura para a implementação de projetos que integrem código C++ pronto de alto desempenho em R. Conveniência adicional é o destaque de sintaxe e um analisador de código C++ estático no RStudio.
documento permite executar scripts independentes com parâmetros. Isto é conveniente para uso em um servidor remoto, incl. na janela de encaixe. No RStudio é inconveniente realizar muitas horas de experimentos com treinamento de redes neurais, e nem sempre se justifica a instalação do IDE no próprio servidor.
Docker garante portabilidade de código e reprodutibilidade de resultados entre desenvolvedores com diferentes versões de SO e bibliotecas, além de facilidade de execução em servidores. Você pode iniciar todo o pipeline de treinamento com apenas um comando.
O Google Cloud é uma maneira econômica de experimentar hardware caro, mas você precisa escolher as configurações com cuidado.
Medir a velocidade de fragmentos de código individuais é muito útil, especialmente ao combinar R e C++, e com o pacote Banco - também muito fácil.
No geral esta experiência foi muito gratificante e continuamos a trabalhar para resolver algumas das questões levantadas.