Quick Draw Doodle 辨識:如何與 R、C++ 和神經網路交朋友

Quick Draw Doodle 辨識:如何與 R、C++ 和神經網路交朋友

嘿哈布爾!

去年秋天,Kaggle 舉辦了一場手繪圖片分類競賽“Quick Draw Doodle Recognition”,其中包括一群 R 科學家參加的競賽: 阿爾喬姆·克萊夫佐娃, 菲利帕經理 и 安德烈·奧古爾佐夫。 我們不會詳細描述比賽;這已經在 最近發表.

這次刷獎牌雖然沒有成功,但獲得了很多寶貴的經驗,所以我想向社區介紹一些 Kagle 上以及日常工作中最有趣、最有用的東西。 討論的主題包括:沒有的困難生活 OpenCV的、JSON 解析(這些範例檢查 C++ 程式碼與 R 中的腳本或套件的集成,使用 反傾銷)、腳本參數化和最終解決方案的 Docker 化。 訊息中的所有代碼均以適合執行的形式提供 儲存庫.

內容:

  1. 有效率地將資料從 CSV 載入到 MonetDB 中
  2. 準備批次
  3. 用於從資料庫卸載批次的迭代器
  4. 選擇模型架構
  5. 腳本參數化
  6. 腳本的 Docker 化
  7. 在 Google Cloud 上使用多個 GPU
  8. 取而代之的是結論

1.有效率地將資料從CSV載入到MonetDB資料庫中

本次比賽的資料不是以現成影像的形式提供,而是以 340 個 CSV 檔案(每個類別一個檔案)的形式提供,其中包含有點座標的 JSON。 透過用線連接這些點,我們得到了 256x256 像素的最終影像。 此外,對於每個記錄,還有一個標籤,指示收集資料集時使用的分類器是否正確識別圖片、圖片作者居住國家/地區的兩個字母代碼、唯一識別碼、時間戳記以及與檔案名稱相符的類名。 原始資料的簡化版本在壓縮包中重7.4GB,解壓縮後約20GB,解壓縮後的完整資料佔用240GB。 組織者確保兩個版本都複製相同的圖紙,這意味著完整版本是多餘的。 無論如何,以圖形文件或數組的形式存儲 50 萬張圖像立即被認為是無利可圖的,我們決定合併存檔中的所有 CSV 文件 train_simplified.zip 進入資料庫,隨後為每批「即時」產生所需尺寸的影像。

選擇一個經過充分驗證的系統作為 DBMS 數據庫,即 R 作為包的實現 莫奈DBLite。 該軟體包包含資料庫伺服器的嵌入式版本,可讓您直接從 R 會話中取得伺服器並在那裡使用它。 使用一個命令即可建立資料庫並連接到該資料庫:

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

我們需要建立兩個表:一個用於所有數據,另一個用於下載文件的服務資訊(如果出現問題並且在下載多個文件後必須恢復該過程,則很有用):

創建表

if (!DBI::dbExistsTable(con, "doodles")) {
  DBI::dbCreateTable(
    con = con,
    name = "doodles",
    fields = c(
      "countrycode" = "char(2)",
      "drawing" = "text",
      "key_id" = "bigint",
      "recognized" = "bool",
      "timestamp" = "timestamp",
      "word" = "text"
    )
  )
}

if (!DBI::dbExistsTable(con, "upload_log")) {
  DBI::dbCreateTable(
    con = con,
    name = "upload_log",
    fields = c(
      "id" = "serial",
      "file_name" = "text UNIQUE",
      "uploaded" = "bool DEFAULT false"
    )
  )
}

將資料載入到資料庫的最快方法是使用 SQL - 命令直接複製 CSV 文件 COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORT哪裡 tablename - 表名和 path - 文件的路徑。 在使用存檔時,發現內建實現 unzip 在 R 中無法正確處理存檔中的許多文件,因此我們使用了系統 unzip (使用參數 getOption("unzip")).

寫入資料庫的函數

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

如果需要在將表寫入資料庫之前對其進行轉換,則傳入參數就足夠了 preprocess 將轉換資料的函數。

將資料順序載入到資料庫的程式碼:

將資料寫入資料庫

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

資料載入時間可能會因所用磁碟機的速度特性而異。 在我們的例子中,在一個 SSD 內讀取和寫入或從快閃磁碟機(來源檔案)到 SSD (DB) 的讀取和寫入時間不到 10 分鐘。

建立具有整數類別標籤和索引列的欄位還需要幾秒鐘的時間(ORDERED INDEX) 以及建立批次時對觀測值進行採樣的行號:

建立附加列和索引

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

為了解決動態建立批次的問題,我們需要達到從表中提取隨機行的最大速度 doodles。 為此我們使用了 3 個技巧。 第一個是減少儲存觀察 ID 的類型的維數。 原始資料集中,需要儲存ID的類型為 bigint,但是觀察的數量使得可以將其標識符(等於序數)放入類型中 int。 在這種情況下,搜尋速度要快得多。 第二個技巧是使用 ORDERED INDEX - 我們根據經驗做出了這個決定,考慮了所有可用的 選項。 第三種是使用參數化查詢。 該方法的本質是執行一次命令 PREPARE 隨後在建立一堆相同類型的查詢時使用準備好的表達式,但實際上與簡單的查詢相比有一個優點 SELECT 結果顯示在統計誤差範圍內。

上傳資料的過程消耗不超過450 MB RAM。 也就是說,所描述的方法允許您在幾乎任何預算硬體(包括一些單板設備)上移動數十 GB 的資料集,這非常酷。

剩下的就是測量檢索(隨機)資料的速度並評估對不同大小的批次進行採樣時的縮放比例:

資料庫基準測試

library(ggplot2)

set.seed(0)
# Подключение к базе данных
con <- DBI::dbConnect(MonetDBLite::MonetDBLite(), Sys.getenv("DBDIR"))

# Функция для подготовки запроса на стороне сервера
prep_sql <- function(batch_size) {
  sql <- sprintf("PREPARE SELECT id FROM doodles WHERE id IN (%s)",
                 paste(rep("?", batch_size), collapse = ","))
  res <- DBI::dbSendQuery(con, sql)
  return(res)
}

# Функция для извлечения данных
fetch_data <- function(rs, batch_size) {
  ids <- sample(seq_len(n), batch_size)
  res <- DBI::dbFetch(DBI::dbBind(rs, as.list(ids)))
  return(res)
}

# Проведение замера
res_bench <- bench::press(
  batch_size = 2^(4:10),
  {
    rs <- prep_sql(batch_size)
    bench::mark(
      fetch_data(rs, batch_size),
      min_iterations = 50L
    )
  }
)
# Параметры бенчмарка
cols <- c("batch_size", "min", "median", "max", "itr/sec", "total_time", "n_itr")
res_bench[, cols]

#   batch_size      min   median      max `itr/sec` total_time n_itr
#        <dbl> <bch:tm> <bch:tm> <bch:tm>     <dbl>   <bch:tm> <int>
# 1         16   23.6ms  54.02ms  93.43ms     18.8        2.6s    49
# 2         32     38ms  84.83ms 151.55ms     11.4       4.29s    49
# 3         64   63.3ms 175.54ms 248.94ms     5.85       8.54s    50
# 4        128   83.2ms 341.52ms 496.24ms     3.00      16.69s    50
# 5        256  232.8ms 653.21ms 847.44ms     1.58      31.66s    50
# 6        512  784.6ms    1.41s    1.98s     0.740       1.1m    49
# 7       1024  681.7ms    2.72s    4.06s     0.377      2.16m    49

ggplot(res_bench, aes(x = factor(batch_size), y = median, group = 1)) +
  geom_point() +
  geom_line() +
  ylab("median time, s") +
  theme_minimal()

DBI::dbDisconnect(con, shutdown = TRUE)

Quick Draw Doodle 辨識:如何與 R、C++ 和神經網路交朋友

2. 準備批次

整個批次製備過程包括以下步驟:

  1. 解析多個包含帶有有點座標的字串向量的 JSON。
  2. 根據所需尺寸(例如256×256或128×128)的影像上的點座標繪製彩色線條。
  3. 將產生的影像轉換為張量。

作為Python內核之間競爭的一部分,該問題主要透過使用 OpenCV的。 R 中最簡單、最明顯的類似物之一如下所示:

在 R 中實作 JSON 到張量的轉換

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

使用標準 R 工具執行繪圖,並將其儲存到儲存在 RAM 中的暫存 PNG(在 Linux 上,暫存 R 目錄位於以下目錄中) /tmp,安裝在 RAM 中)。 然後,該檔案被讀取為數字範圍從 0 到 1 的三維數組。這很重要,因為更傳統的 BMP 將被讀入具有十六進位顏色代碼的原始數組中。

我們來測試一下結果:

zip_file <- file.path("data", "train_simplified.zip")
csv_file <- "cat.csv"
unzip(zip_file, files = csv_file, exdir = tempdir(), 
      junkpaths = TRUE, unzip = getOption("unzip"))
tmp_data <- data.table::fread(file.path(tempdir(), csv_file), sep = ",", 
                              select = "drawing", nrows = 10000)
arr <- r_process_json_str(tmp_data[4, drawing])
dim(arr)
# [1] 256 256   3
plot(magick::image_read(arr))

Quick Draw Doodle 辨識:如何與 R、C++ 和神經網路交朋友

批次本身將形成如下:

res <- r_process_json_vector(tmp_data[1:4, drawing], scale = 0.5)
str(res)
 # num [1:4, 1:128, 1:128, 1:3] 1 1 1 1 1 1 1 1 1 1 ...
 # - attr(*, "dimnames")=List of 4
 #  ..$ : NULL
 #  ..$ : NULL
 #  ..$ : NULL
 #  ..$ : NULL

這種實現對我們來說似乎不是最理想的,因為大批量的形成需要相當長的時間,我們決定透過使用強大的函式庫來利用同事的經驗 OpenCV的。 當時沒有現成的 R 套件(現在沒有),因此所需功能的最小實作是用 C++ 編寫的,並使用以下命令整合到 R 程式碼中 反傾銷.

為了解決該問題,使用了以下套件和庫:

  1. OpenCV的 用於處理影像和繪製線條。 使用預先安裝的系統庫和頭文件,以及動態連結。

  2. x張量 用於處理多維數組和張量。 我們使用同名 R 套件中包含的頭檔。 該庫允許您使用多維數組,無論是行主順序還是列主順序。

  3. ndjson 用於解析 JSON。 該庫用於 x張量 如果項目中存在,則自動進行。

  4. Rcpp線程 用於組織 JSON 向量的多執行緒處理。 使用了這個包提供的頭檔。 來自比較熱門的 Rcpp並行 除此之外,該軟體包還具有內建的循環中斷機制。

它應該指出的是 x張量 事實證明這是天賜之物:除了具有廣泛的功能和高性能之外,其開發人員的反應也非常靈敏,並迅速而詳細地回答了問題。 在他們的幫助下,可以實現 OpenCV 矩陣到 xtensor 張量的轉換,以及將 3 維圖像張量組合成正確維度(批次本身)的 4 維張量的方法。

學習 Rcpp、xtensor 和 RcppThread 的材料

https://thecoatlessprofessor.com/programming/unofficial-rcpp-api-documentation

https://docs.opencv.org/4.0.1/d7/dbd/group__imgproc.html

https://xtensor.readthedocs.io/en/latest/

https://xtensor.readthedocs.io/en/latest/file_loading.html#loading-json-data-into-xtensor

https://cran.r-project.org/web/packages/RcppThread/vignettes/RcppThread-vignette.pdf

為了編譯使用系統檔案並動態連結系統上安裝的函式庫的文件,我們使用了套件中實現的插件機制 反傾銷。 為了自動查找路徑和標誌,我們使用了流行的 Linux 實用程式 包配置.

使用 OpenCV 庫的 Rcpp 插件的實現

Rcpp::registerPlugin("opencv", function() {
  # Возможные названия пакета
  pkg_config_name <- c("opencv", "opencv4")
  # Бинарный файл утилиты pkg-config
  pkg_config_bin <- Sys.which("pkg-config")
  # Проврека наличия утилиты в системе
  checkmate::assert_file_exists(pkg_config_bin, access = "x")
  # Проверка наличия файла настроек OpenCV для pkg-config
  check <- sapply(pkg_config_name, 
                  function(pkg) system(paste(pkg_config_bin, pkg)))
  if (all(check != 0)) {
    stop("OpenCV config for the pkg-config not found", call. = FALSE)
  }

  pkg_config_name <- pkg_config_name[check == 0]
  list(env = list(
    PKG_CXXFLAGS = system(paste(pkg_config_bin, "--cflags", pkg_config_name), 
                          intern = TRUE),
    PKG_LIBS = system(paste(pkg_config_bin, "--libs", pkg_config_name), 
                      intern = TRUE)
  ))
})

作為插件運行的結果,以下值將在編譯過程中被替換:

Rcpp:::.plugins$opencv()$env

# $PKG_CXXFLAGS
# [1] "-I/usr/include/opencv"
#
# $PKG_LIBS
# [1] "-lopencv_shape -lopencv_stitching -lopencv_superres -lopencv_videostab -lopencv_aruco -lopencv_bgsegm -lopencv_bioinspired -lopencv_ccalib -lopencv_datasets -lopencv_dpm -lopencv_face -lopencv_freetype -lopencv_fuzzy -lopencv_hdf -lopencv_line_descriptor -lopencv_optflow -lopencv_video -lopencv_plot -lopencv_reg -lopencv_saliency -lopencv_stereo -lopencv_structured_light -lopencv_phase_unwrapping -lopencv_rgbd -lopencv_viz -lopencv_surface_matching -lopencv_text -lopencv_ximgproc -lopencv_calib3d -lopencv_features2d -lopencv_flann -lopencv_xobjdetect -lopencv_objdetect -lopencv_ml -lopencv_xphoto -lopencv_highgui -lopencv_videoio -lopencv_imgcodecs -lopencv_photo -lopencv_imgproc -lopencv_core"

劇透下面給出了解析 JSON 並產生批次傳輸到模型的實作程式碼。 首先新增本機項目目錄用於搜尋頭檔(ndjson需要):

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

C++實作JSON到張量的轉換

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

這段程式碼應該放在文件中 src/cv_xt.cpp 並使用命令編譯 Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); 也需要工作 nlohmann/json.hpp儲存庫。 程式碼分為幾個函數:

  • to_xt — 用於轉換影像矩陣的模板化函數 (cv::Mat) 到一個張量 xt::xtensor;

  • parse_json — 此函數解析 JSON 字串,提取點的座標,將它們打包到向量中;

  • ocv_draw_lines — 從所得的點向量中,繪製多色線條;

  • process — 結合了上述功能,也增加了縮放結果影像的能力;

  • cpp_process_json_str - 函數的包裝 process,將結果匯出到 R 物件(多維數組);

  • cpp_process_json_vector - 函數的包裝 cpp_process_json_str,它允許您以多線程模式處理字串向量。

為了繪製多色線條,使用了 HSV 顏色模型,然後轉換為 RGB。 我們來測試一下結果:

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

Quick Draw Doodle 辨識:如何與 R、C++ 和神經網路交朋友
R和C++的實現速度比較

res_bench <- bench::mark(
  r_process_json_str(tmp_data[4, drawing], scale = 0.5),
  cpp_process_json_str(tmp_data[4, drawing], scale = 0.5),
  check = FALSE,
  min_iterations = 100
)
# Параметры бенчмарка
cols <- c("expression", "min", "median", "max", "itr/sec", "total_time", "n_itr")
res_bench[, cols]

#   expression                min     median       max `itr/sec` total_time  n_itr
#   <chr>                <bch:tm>   <bch:tm>  <bch:tm>     <dbl>   <bch:tm>  <int>
# 1 r_process_json_str     3.49ms     3.55ms    4.47ms      273.      490ms    134
# 2 cpp_process_json_str   1.94ms     2.02ms    5.32ms      489.      497ms    243

library(ggplot2)
# Проведение замера
res_bench <- bench::press(
  batch_size = 2^(4:10),
  {
    .data <- tmp_data[sample(seq_len(.N), batch_size), drawing]
    bench::mark(
      r_process_json_vector(.data, scale = 0.5),
      cpp_process_json_vector(.data,  scale = 0.5),
      min_iterations = 50,
      check = FALSE
    )
  }
)

res_bench[, cols]

#    expression   batch_size      min   median      max `itr/sec` total_time n_itr
#    <chr>             <dbl> <bch:tm> <bch:tm> <bch:tm>     <dbl>   <bch:tm> <int>
#  1 r                   16   50.61ms  53.34ms  54.82ms    19.1     471.13ms     9
#  2 cpp                 16    4.46ms   5.39ms   7.78ms   192.      474.09ms    91
#  3 r                   32   105.7ms 109.74ms 212.26ms     7.69        6.5s    50
#  4 cpp                 32    7.76ms  10.97ms  15.23ms    95.6     522.78ms    50
#  5 r                   64  211.41ms 226.18ms 332.65ms     3.85      12.99s    50
#  6 cpp                 64   25.09ms  27.34ms  32.04ms    36.0        1.39s    50
#  7 r                  128   534.5ms 627.92ms 659.08ms     1.61      31.03s    50
#  8 cpp                128   56.37ms  58.46ms  66.03ms    16.9        2.95s    50
#  9 r                  256     1.15s    1.18s    1.29s     0.851     58.78s    50
# 10 cpp                256  114.97ms 117.39ms 130.09ms     8.45       5.92s    50
# 11 r                  512     2.09s    2.15s    2.32s     0.463       1.8m    50
# 12 cpp                512  230.81ms  235.6ms 261.99ms     4.18      11.97s    50
# 13 r                 1024        4s    4.22s     4.4s     0.238       3.5m    50
# 14 cpp               1024  410.48ms 431.43ms 462.44ms     2.33      21.45s    50

ggplot(res_bench, aes(x = factor(batch_size), y = median, 
                      group =  expression, color = expression)) +
  geom_point() +
  geom_line() +
  ylab("median time, s") +
  theme_minimal() +
  scale_color_discrete(name = "", labels = c("cpp", "r")) +
  theme(legend.position = "bottom") 

Quick Draw Doodle 辨識:如何與 R、C++ 和神經網路交朋友

正如您所看到的,速度的提升非常顯著,並且不可能透過並行化 R 程式碼來趕上 C++ 程式碼。

3. 用於從資料庫卸載批次的迭代器

R 在處理適合 RAM 的資料方面享有盛譽,而 Python 更具有迭代資料處理的特點,可讓您輕鬆自然地實現核外計算(使用外部記憶體的計算)。 對於我們所描述的問題來說,一個經典且相關的例子是透過梯度下降法訓練的深度神經網絡,在每一步使用一小部分觀察或小批量來逼近梯度。

用 Python 編寫的深度學習框架有一些特殊的類,可以根據資料實現迭代器:表格、資料夾中的圖片、二進位格式等。您可以使用現成的選項或為特定任務編寫自己的選項。 在 R 中我們可以利用 Python 函式庫的所有功能 凱拉斯 其各種後端使用同名的包,而包又在包的頂部工作 網狀。 後者值得單獨寫一篇長篇文章; 它不僅允許您從 R 運行 Python 程式碼,還允許您在 R 和 Python 會話之間傳輸對象,自動執行所有必要的類型轉換。

透過使用 MonetDBLite,我們擺脫了將所有資料儲存在 RAM 中的需要,所有「神經網路」工作都將由 Python 中的原始程式碼執行,我們只需要在資料上編寫一個迭代器,因為沒有準備好對於R 或Python 中的這種情況。 它本質上只有兩個要求:它必須在無限循環中返回批次並在迭代之間保存其狀態(後者在 R 中是使用閉包以最簡單的方式實現的)。 以前,需要在迭代器內明確地將 R 數組轉換為 numpy 數組,但當前版本的套件 凱拉斯 她自己做。

訓練和驗證資料的迭代器如下:

用於訓練和驗證資料的迭代器

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

此函數將一個與資料庫連接的變數、使用的行數、類別數、批次大小、規模(scale = 1 對應256x256像素的渲染影像, scale = 0.5 — 128x128 像素),顏色指示器(color = FALSE 使用時指定灰階渲染 color = TRUE 每個筆劃都以新顏色繪製)以及在 imagenet 上預先訓練的網路的預處理指示器。 需要後者才能將像素值從區間 [0, 1] 縮放到區間 [-1, 1],這是在訓練提供的時使用的 凱拉斯 楷模。

外部函數包含參數類型檢查、一個表 data.table 隨機混合行號 samples_index 和批次號碼、計數器和最大批次數,以及用於從資料庫卸載資料的 SQL 表達式。 此外,我們定義了內部函數的快速模擬 keras::to_categorical()。 我們幾乎使用了所有資料進行訓練,留下XNUMX%用於驗證,因此紀元大小受到參數的限制 steps_per_epoch 當被調用時 keras::fit_generator(),以及條件 if (i > max_i) 僅適用於驗證迭代器。

在內部函數中,檢索下一批的行索引,隨著批次計數器的增加從資料庫中卸載記錄,JSON 解析(函數 cpp_process_json_vector(),用 C++ 編寫)並建立與圖片對應的陣列。 然後創建帶有類別標籤的one-hot向量,將帶有像素值和標籤的數組組合成一個列表,這就是返回值。 為了加快工作速度,我們在表中建立索引 data.table 並透過連結進行修改 - 沒有這些包“晶片” 數據表 很難想像如何在 R 中有效地處理大量資料。

Core i5筆記型電腦的速度測量結果如下:

迭代器基準測試

library(Rcpp)
library(keras)
library(ggplot2)

source("utils/rcpp.R")
source("utils/keras_iterator.R")

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

ind <- seq_len(DBI::dbGetQuery(con, "SELECT count(*) FROM doodles")[[1L]])
num_classes <- DBI::dbGetQuery(con, "SELECT max(label_int) + 1 FROM doodles")[[1L]]

# Индексы для обучающей выборки
train_ind <- sample(ind, floor(length(ind) * 0.995))
# Индексы для проверочной выборки
val_ind <- ind[-train_ind]
rm(ind)
# Коэффициент масштаба
scale <- 0.5

# Проведение замера
res_bench <- bench::press(
  batch_size = 2^(4:10),
  {
    it1 <- train_generator(
      db_connection = con,
      samples_index = train_ind,
      num_classes = num_classes,
      batch_size = batch_size,
      scale = scale
    )
    bench::mark(
      it1(),
      min_iterations = 50L
    )
  }
)
# Параметры бенчмарка
cols <- c("batch_size", "min", "median", "max", "itr/sec", "total_time", "n_itr")
res_bench[, cols]

#   batch_size      min   median      max `itr/sec` total_time n_itr
#        <dbl> <bch:tm> <bch:tm> <bch:tm>     <dbl>   <bch:tm> <int>
# 1         16     25ms  64.36ms   92.2ms     15.9       3.09s    49
# 2         32   48.4ms 118.13ms 197.24ms     8.17       5.88s    48
# 3         64   69.3ms 117.93ms 181.14ms     8.57       5.83s    50
# 4        128  157.2ms 240.74ms 503.87ms     3.85      12.71s    49
# 5        256  359.3ms 613.52ms 988.73ms     1.54       30.5s    47
# 6        512  884.7ms    1.53s    2.07s     0.674      1.11m    45
# 7       1024     2.7s    3.83s    5.47s     0.261      2.81m    44

ggplot(res_bench, aes(x = factor(batch_size), y = median, group = 1)) +
    geom_point() +
    geom_line() +
    ylab("median time, s") +
    theme_minimal()

DBI::dbDisconnect(con, shutdown = TRUE)

Quick Draw Doodle 辨識:如何與 R、C++ 和神經網路交朋友

如果您有足夠的 RAM,則可以透過將資料庫轉移到相同 RAM(32 GB 足以完成我們的任務)來顯著加快資料庫的運行速度。 在Linux中,分割區是預設掛載的 /dev/shm,佔用 RAM 容量的一半。 您可以透過編輯突出顯示更多內容 /etc/fstab得到像這樣的記錄 tmpfs /dev/shm tmpfs defaults,size=25g 0 0。 請務必重新啟動並透過執行命令檢查結果 df -h.

測試資料的迭代器看起來簡單得多,因為測試資料集完全適合 RAM:

測試資料的迭代器

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.模型架構的選擇

第一個使用的架構是 移動網絡v1,其特徵在中討論 資訊. 它是標準配置 凱拉斯 並且,相應地,可以在 R 的同名包中使用。但是當嘗試將其與單通道圖像一起使用時,出現了奇怪的事情:輸入張量必須始終具有維度 (batch, height, width, 3),即通道數不能改變。 Python 中沒有這樣的限制,因此我們按照原始文章(沒有 keras 版本中的 dropout),匆忙地編寫了該架構的自己的實作:

Mobilenet v1 架構

library(keras)

top_3_categorical_accuracy <- custom_metric(
    name = "top_3_categorical_accuracy",
    metric_fn = function(y_true, y_pred) {
         metric_top_k_categorical_accuracy(y_true, y_pred, k = 3)
    }
)

layer_sep_conv_bn <- function(object, 
                              filters,
                              alpha = 1,
                              depth_multiplier = 1,
                              strides = c(2, 2)) {

  # NB! depth_multiplier !=  resolution multiplier
  # https://github.com/keras-team/keras/issues/10349

  layer_depthwise_conv_2d(
    object = object,
    kernel_size = c(3, 3), 
    strides = strides,
    padding = "same",
    depth_multiplier = depth_multiplier
  ) %>%
  layer_batch_normalization() %>% 
  layer_activation_relu() %>%
  layer_conv_2d(
    filters = filters * alpha,
    kernel_size = c(1, 1), 
    strides = c(1, 1)
  ) %>%
  layer_batch_normalization() %>% 
  layer_activation_relu() 
}

get_mobilenet_v1 <- function(input_shape = c(224, 224, 1),
                             num_classes = 340,
                             alpha = 1,
                             depth_multiplier = 1,
                             optimizer = optimizer_adam(lr = 0.002),
                             loss = "categorical_crossentropy",
                             metrics = c("categorical_crossentropy",
                                         top_3_categorical_accuracy)) {

  inputs <- layer_input(shape = input_shape)

  outputs <- inputs %>%
    layer_conv_2d(filters = 32, kernel_size = c(3, 3), strides = c(2, 2), padding = "same") %>%
    layer_batch_normalization() %>% 
    layer_activation_relu() %>%
    layer_sep_conv_bn(filters = 64, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 128, strides = c(2, 2)) %>%
    layer_sep_conv_bn(filters = 128, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 256, strides = c(2, 2)) %>%
    layer_sep_conv_bn(filters = 256, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(2, 2)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 512, strides = c(1, 1)) %>%
    layer_sep_conv_bn(filters = 1024, strides = c(2, 2)) %>%
    layer_sep_conv_bn(filters = 1024, strides = c(1, 1)) %>%
    layer_global_average_pooling_2d() %>%
    layer_dense(units = num_classes) %>%
    layer_activation_softmax()

    model <- keras_model(
      inputs = inputs,
      outputs = outputs
    )

    model %>% compile(
      optimizer = optimizer,
      loss = loss,
      metrics = metrics
    )

    return(model)
}

這種方法的缺點是顯而易見的。 我想測試很多模型,但相反,我不想手動重寫每個架構。 我們也被剝奪了使用在 imagenet 上預先訓練的模型權重的機會。 像往常一樣,研究文件會有所幫助。 功能 get_config() 允許您以適合編輯的形式獲取模型的描述(base_model_conf$layers - 常規 R 列表),以及函數 from_config() 執行到模型物件的反向轉換:

base_model_conf <- get_config(base_model)
base_model_conf$layers[[1]]$config$batch_input_shape[[4]] <- 1L
base_model <- from_config(base_model_conf)

現在編寫一個通用函數來獲取任何提供的內容並不困難 凱拉斯 在 imagenet 上訓練有或沒有權重的模型:

載入現成架構的函數

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

使用單通道影像時,不使用預訓練權重。 這可以解決:使用該功能 get_weights() 以 R 數組列表的形式取得模型權重,更改此列表第一個元素的維度(透過採用一個顏色通道或對所有三個通道求平均值),然後使用下列函數將權重加載回模型中 set_weights()。 我們從未添加此功能,因為在現階段已經很明顯,處理彩色圖片的效率更高。

我們使用 mobilenet 版本 1 和 2 以及 resnet34 進行了大部分實驗。 SE-ResNeXt 等較現代的架構在本次比賽中表現出色。 不幸的是,我們沒有現成的實作可供使用,我們也沒有編寫自己的實作(但我們肯定會編寫)。

5. 腳本參數化

為了方便起見,開始訓練的所有程式碼都被設計為單一腳本,並使用參數化 醫生 如下所示:

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)

醫生 代表實施 http://docopt.org/ 在 R 的幫助下,可以使用簡單的命令啟動腳本,例如 Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db,如果文件 train_nn.R 是可執行的(此命令將開始訓練模型 resnet50 對於尺寸為 128x128 像素的三色影像,資料庫必須位於資料夾中 /home/andrey/doodle_db)。 您可以將學習速度、優化器類型和任何其他可自訂參數新增至清單。 在準備出版物的過程中,事實證明,該架構 mobilenet_v2 從目前版本開始 凱拉斯 在R中使用 不得 由於 R 包中未考慮到更改,我們正在等待他們修復它。

與 RStudio 中更傳統的腳本啟動相比,這種方法可以顯著加快不同模型的實驗速度(我們注意到該套件是可能的替代方案) 特夫倫斯)。 但主要優點是能夠輕鬆管理 Docker 中或僅在伺服器上啟動腳本,而無需為此安裝 RStudio。

6. 腳本的 Docker 化

我們使用 Docker 來確保團隊成員之間訓練模型環境的可移植性以及在雲端中的快速部署。 您可以開始熟悉這個工具,這對於 R 程式設計師來說相對不常見,方法是: 系列出版物或 視訊課程.

Docker 可讓您從頭開始建立自己的映像,並使用其他映像作為建立自己的映像的基礎。 在分析可用選項時,我們得出的結論是,安裝 NVIDIA、CUDA+cuDNN 驅動程式和 Python 程式庫是鏡像中相當多的部分,因此我們決定以官方鏡像為基礎 tensorflow/tensorflow:1.12.0-gpu,在那裡添加必要的 R 套件。

最終的 docker 檔案如下所示:

Dockerfile

FROM tensorflow/tensorflow:1.12.0-gpu

MAINTAINER Artem Klevtsov <[email protected]>

SHELL ["/bin/bash", "-c"]

ARG LOCALE="en_US.UTF-8"
ARG APT_PKG="libopencv-dev r-base r-base-dev littler"
ARG R_BIN_PKG="futile.logger checkmate data.table rcpp rapidjsonr dbi keras jsonlite curl digest remotes"
ARG R_SRC_PKG="xtensor RcppThread docopt MonetDBLite"
ARG PY_PIP_PKG="keras"
ARG DIRS="/db /app /app/data /app/models /app/logs"

RUN source /etc/os-release && 
    echo "deb https://cloud.r-project.org/bin/linux/ubuntu ${UBUNTU_CODENAME}-cran35/" > /etc/apt/sources.list.d/cran35.list && 
    apt-key adv --keyserver keyserver.ubuntu.com --recv-keys E084DAB9 && 
    add-apt-repository -y ppa:marutter/c2d4u3.5 && 
    add-apt-repository -y ppa:timsc/opencv-3.4 && 
    apt-get update && 
    apt-get install -y locales && 
    locale-gen ${LOCALE} && 
    apt-get install -y --no-install-recommends ${APT_PKG} && 
    ln -s /usr/lib/R/site-library/littler/examples/install.r /usr/local/bin/install.r && 
    ln -s /usr/lib/R/site-library/littler/examples/install2.r /usr/local/bin/install2.r && 
    ln -s /usr/lib/R/site-library/littler/examples/installGithub.r /usr/local/bin/installGithub.r && 
    echo 'options(Ncpus = parallel::detectCores())' >> /etc/R/Rprofile.site && 
    echo 'options(repos = c(CRAN = "https://cloud.r-project.org"))' >> /etc/R/Rprofile.site && 
    apt-get install -y $(printf "r-cran-%s " ${R_BIN_PKG}) && 
    install.r ${R_SRC_PKG} && 
    pip install ${PY_PIP_PKG} && 
    mkdir -p ${DIRS} && 
    chmod 777 ${DIRS} && 
    rm -rf /tmp/downloaded_packages/ /tmp/*.rds && 
    rm -rf /var/lib/apt/lists/*

COPY utils /app/utils
COPY src /app/src
COPY tests /app/tests
COPY bin/*.R /app/

ENV DBDIR="/db"
ENV CUDA_HOME="/usr/local/cuda"
ENV PATH="/app:${PATH}"

WORKDIR /app

VOLUME /db
VOLUME /app

CMD bash

為了方便,使用的包被放入變數中; 大部分編寫的腳本在組裝期間被複製到容器內。 我們還將命令 shell 更改為 /bin/bash 為了方便使用內容 /etc/os-release。 這避免了在程式碼中指定作業系統版本的需要。

此外,還編寫了一個小型 bash 腳本,讓您可以使用各種命令啟動容器。 例如,這些可以是先前放置在容器內的用於訓練神經網路的腳本,或是用於偵錯和監視容器操作的命令 shell:

啟動容器的腳本

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

如果這個 bash 腳本不帶參數運行,則該腳本將在容器內被調用 train_nn.R 使用預設值; 如果第一個位置參數是“bash”,那麼容器將與指令 shell 互動啟動。 在所有其他情況下,位置參數的值都會被替換: CMD="Rscript /app/train_nn.R $@".

值得注意的是,來源資料和資料庫的目錄以及保存訓練模型的目錄都從主機系統安裝在容器內,這使您可以存取腳本的結果,而無需進行不必要的操作。

7. 在 Google Cloud 上使用多個 GPU

比賽的特點之一是非常吵雜的數據(請參閱標題圖片,借自 ODS slack 的@Leigh.plt)。 大批量有助於解決這個問題,在具有 1 個 GPU 的 PC 上進行實驗後,我們決定在雲端中的多個 GPU 上掌握訓練模型。 使用過 GoogleCloud (很好的基礎知識指南)由於可用配置選擇眾多、價格合理以及 300 美元獎金。 出於貪婪,我訂購了一個帶有 SSD 和大量 RAM 的 4xV100 實例,這是一個很大的錯誤。 這樣的機器很快就會耗盡資金;如果沒有經過驗證的管道,你可能會破產。 出於教育目的,最好選擇 K80。 但是大量的 RAM 派上了用場 - 雲端 SSD 的效能並沒有給人留下深刻的印象,因此資料庫被轉移到 dev/shm.

最令人感興趣的是負責使用多個 GPU 的程式碼片段。 首先,模型是使用上下文管理器在 CPU 上建立的,就像在 Python 中一樣:

with(tensorflow::tf$device("/cpu:0"), {
  model_cpu <- get_model(
    name = model_name,
    input_shape = input_shape,
    weights = weights,
    metrics =(top_3_categorical_accuracy,
    compile = FALSE
  )
})

然後將未編譯的(這很重要)模型複製到給定數量的可用 GPU,只有在此之後才對其進行編譯:

model <- keras::multi_gpu_model(model_cpu, gpus = n_gpu)
keras::compile(
  object = model,
  optimizer = keras::optimizer_adam(lr = 0.0004),
  loss = "categorical_crossentropy",
  metrics = c(top_3_categorical_accuracy)
)

凍結除最後一層之外的所有層、訓練最後一層、解凍並為多個 GPU 重新訓練整個模型的經典技術無法實現。

訓練是在沒有使用的情況下進行監測的。 張量板,限制我們在每個紀元之後記錄日誌並使用資訊豐富的名稱保存模型:

回調

# Шаблон имени файла лога
log_file_tmpl <- file.path("logs", sprintf(
  "%s_%d_%dch_%s.csv",
  model_name,
  dim_size,
  channels,
  format(Sys.time(), "%Y%m%d%H%M%OS")
))
# Шаблон имени файла модели
model_file_tmpl <- file.path("models", sprintf(
  "%s_%d_%dch_{epoch:02d}_{val_loss:.2f}.h5",
  model_name,
  dim_size,
  channels
))

callbacks_list <- list(
  keras::callback_csv_logger(
    filename = log_file_tmpl
  ),
  keras::callback_early_stopping(
    monitor = "val_loss",
    min_delta = 1e-4,
    patience = 8,
    verbose = 1,
    mode = "min"
  ),
  keras::callback_reduce_lr_on_plateau(
    monitor = "val_loss",
    factor = 0.5, # уменьшаем lr в 2 раза
    patience = 4,
    verbose = 1,
    min_delta = 1e-4,
    mode = "min"
  ),
  keras::callback_model_checkpoint(
    filepath = model_file_tmpl,
    monitor = "val_loss",
    save_best_only = FALSE,
    save_weights_only = FALSE,
    mode = "min"
  )
)

8. 代替結論

我們遇到的一些問題尚未解決:

  • в 凱拉斯 沒有現成的自動搜尋最佳學習率的函數(模擬 lr_finder 在圖書館裡 ); 經過一些努力,可以將第三方實現移植到 R,例如, ;
  • 由於前一點的原因,在使用多個 GPU 時無法選擇正確的訓練速度;
  • 缺乏現代神經網路架構,尤其是在 imagenet 上預先訓練的神經網路架構;
  • 沒有一個循環政策和歧視性學習率(餘弦退火是應我們的要求 實施的, 謝謝 斯基丹).

從這次比賽中學到了哪些有用的東西:

  • 在功耗相對較低的硬體上,您可以輕鬆處理大量(RAM 大小的許多倍)資料量。 塑膠袋 數據表 由於表的就地修改而節省了內存,從而避免了複製它們,並且如果正確使用,它的功能幾乎總是表現出我們已知的所有腳本語言工具中的最高速度。 在許多情況下,將資料保存在資料庫中可以讓您根本不需要考慮將整個資料集壓縮到 RAM 中的必要性。
  • R 中的慢速函式可以使用 C++ 中的快速函式來替換 反傾銷。 如果除了使用 Rcpp線程Rcpp並行,我們得到了跨平台的多執行緒實現,因此不需要在R層級並行化程式碼。
  • 包裹 反傾銷 無需深入了解 C++ 即可使用,概述了所需的最低要求 這裡。 許多很酷的 C 庫的頭文件,例如 x張量 CRAN 上可用,也就是說,正在形成一個基礎設施,用於實施將現成的高效能 C++ 程式碼整合到 R 中的專案。 額外的便利性是 RStudio 中的語法突出顯示和靜態 C++ 程式碼分析器。
  • 醫生 允許您執行帶有參數的獨立腳本。 這對於在遠端伺服器上使用很方便,包括。 在碼頭工人下。 在 RStudio 中,進行多個小時的實驗來訓練神經網路並不方便,並且在伺服器本身上安裝 IDE 並不總是合理的。
  • Docker 確保了具有不同版本作業系統和程式庫的開發人員之間的程式碼可移植性和結果的可重複性,以及在伺服器上的易於執行性。 您只需一個命令即可啟動整個訓練管道。
  • Google Cloud 是一種在昂貴的硬體上進行實驗的經濟實惠的方式,但您需要仔細選擇配置。
  • 測量單一程式碼片段的速度非常有用,特別是在結合 R 和 C++ 以及使用套件時 長凳 - 也很容易。

總的來說,這次經驗非常有價值,我們將繼續努力解決提出的一些問題。

來源: www.habr.com

添加評論