Nhận dạng Doodle Vẽ nhanh: cách kết bạn với R, C++ và mạng lưới thần kinh

Nhận dạng Doodle Vẽ nhanh: cách kết bạn với R, C++ và mạng lưới thần kinh

Này Habr!

Mùa thu năm ngoái, Kaggle đã tổ chức một cuộc thi phân loại các bức tranh vẽ tay, Nhận dạng Doodle Vẽ nhanh, trong đó, cùng với những cuộc thi khác, một nhóm các nhà khoa học R đã tham gia: Artem Klevtsova, quản lý Philippa и Andrey Ogurtsov. Chúng tôi sẽ không mô tả chi tiết cuộc thi; điều đó đã được thực hiện trong ấn phẩm gần đây.

Lần này việc farm huy chương không thành công nhưng đã rút ra được rất nhiều kinh nghiệm quý báu nên mình muốn kể cho cộng đồng nghe một số điều thú vị và hữu ích nhất trên Kagle và trong công việc hàng ngày. Trong số các chủ đề được thảo luận: cuộc sống khó khăn nếu không có OpenCV, phân tích cú pháp JSON (các ví dụ này kiểm tra việc tích hợp mã C++ vào các tập lệnh hoặc gói trong R bằng cách sử dụng Rcpp), tham số hóa các tập lệnh và docker hóa giải pháp cuối cùng. Tất cả mã từ thông báo ở dạng phù hợp để thực thi đều có sẵn trong kho lưu trữ.

Содержание:

  1. Tải dữ liệu từ CSV vào MonetDB một cách hiệu quả
  2. Chuẩn bị lô
  3. Trình vòng lặp để dỡ các lô khỏi cơ sở dữ liệu
  4. Chọn kiến ​​trúc mô hình
  5. Tham số hóa tập lệnh
  6. Dockerization của các tập lệnh
  7. Sử dụng nhiều GPU trên Google Cloud
  8. Thay vì một kết luận

1. Tải dữ liệu từ CSV vào cơ sở dữ liệu MonetDB một cách hiệu quả

Dữ liệu trong cuộc thi này được cung cấp không phải ở dạng hình ảnh làm sẵn mà ở dạng 340 tệp CSV (một tệp cho mỗi lớp) chứa JSON có tọa độ điểm. Bằng cách kết nối những điểm này với các đường thẳng, chúng ta sẽ có được hình ảnh cuối cùng có kích thước 256x256 pixel. Ngoài ra, đối với mỗi bản ghi còn có một nhãn cho biết liệu bức ảnh có được bộ phân loại sử dụng tại thời điểm thu thập dữ liệu nhận dạng chính xác hay không, mã gồm hai chữ cái của quốc gia cư trú của tác giả bức ảnh, mã định danh duy nhất, dấu thời gian và tên lớp khớp với tên tệp. Phiên bản đơn giản hóa của dữ liệu gốc nặng 7.4 GB trong kho lưu trữ và khoảng 20 GB sau khi giải nén, toàn bộ dữ liệu sau khi giải nén chiếm 240 GB. Ban tổ chức đảm bảo rằng cả hai phiên bản đều sao chép giống nhau, nghĩa là phiên bản đầy đủ là dư thừa. Trong mọi trường hợp, việc lưu trữ 50 triệu hình ảnh trong tệp đồ họa hoặc dưới dạng mảng ngay lập tức bị coi là không có lợi và chúng tôi đã quyết định hợp nhất tất cả các tệp CSV từ kho lưu trữ train_simplified.zip vào cơ sở dữ liệu với việc tạo ra các hình ảnh tiếp theo có kích thước được yêu cầu “nhanh chóng” cho mỗi lô.

Một hệ thống đã được kiểm chứng tốt đã được chọn làm DBMS MonetDB, cụ thể là việc triển khai R dưới dạng một gói MonetDBLite. Gói này bao gồm một phiên bản nhúng của máy chủ cơ sở dữ liệu và cho phép bạn chọn máy chủ trực tiếp từ phiên R và làm việc với nó ở đó. Việc tạo cơ sở dữ liệu và kết nối với nó được thực hiện bằng một lệnh:

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

Chúng ta sẽ cần tạo hai bảng: một bảng cho tất cả dữ liệu, bảng còn lại dành cho thông tin dịch vụ về các tệp đã tải xuống (hữu ích nếu có sự cố xảy ra và quá trình phải được tiếp tục sau khi tải xuống một số tệp):

Tạo bảng

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

Cách nhanh nhất để tải dữ liệu vào cơ sở dữ liệu là sao chép trực tiếp tệp CSV bằng lệnh SQL COPY OFFSET 2 INTO tablename FROM path USING DELIMITERS ',','n','"' NULL AS '' BEST EFFORTĐâu tablename - tên bảng và path - đường dẫn đến tập tin. Trong khi làm việc với kho lưu trữ, người ta phát hiện ra rằng việc triển khai tích hợp unzip trong R không hoạt động chính xác với một số tệp từ kho lưu trữ, vì vậy chúng tôi đã sử dụng hệ thống unzip (dùng tham số getOption("unzip")).

Chức năng ghi vào cơ sở dữ liệu

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

Nếu bạn cần chuyển đổi bảng trước khi ghi nó vào cơ sở dữ liệu, chỉ cần truyền đối số vào là đủ preprocess chức năng sẽ biến đổi dữ liệu.

Mã để tải dữ liệu tuần tự vào cơ sở dữ liệu:

Ghi dữ liệu vào cơ sở dữ liệu

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

Thời gian tải dữ liệu có thể thay đổi tùy thuộc vào đặc tính tốc độ của ổ đĩa được sử dụng. Trong trường hợp của chúng tôi, việc đọc và ghi trong một ổ SSD hoặc từ ổ đĩa flash (tệp nguồn) sang ổ SSD (DB) chỉ mất chưa đến 10 phút.

Phải mất thêm vài giây để tạo một cột có nhãn lớp số nguyên và cột chỉ mục (ORDERED INDEX) với số dòng mà các quan sát sẽ được lấy mẫu khi tạo lô:

Tạo cột và chỉ mục bổ sung

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

Để giải quyết vấn đề tạo một lô một cách nhanh chóng, chúng tôi cần đạt được tốc độ tối đa khi trích xuất các hàng ngẫu nhiên từ bảng doodles. Đối với điều này, chúng tôi đã sử dụng 3 thủ thuật. Đầu tiên là giảm kích thước của loại lưu trữ ID quan sát. Trong tập dữ liệu gốc, loại cần thiết để lưu trữ ID là bigint, nhưng số lượng quan sát giúp có thể khớp các định danh của chúng, bằng số thứ tự, vào loại int. Việc tìm kiếm nhanh hơn nhiều trong trường hợp này. Bí quyết thứ hai là sử dụng ORDERED INDEX - chúng tôi đi đến quyết định này theo kinh nghiệm, đã trải qua tất cả những gì có sẵn tùy chọn. Thứ ba là sử dụng các truy vấn được tham số hóa. Bản chất của phương pháp là thực hiện lệnh một lần PREPARE với việc sử dụng biểu thức đã chuẩn bị sau này khi tạo một loạt truy vấn cùng loại, nhưng trên thực tế, có một lợi thế so với một biểu thức đơn giản SELECT hóa ra nằm trong phạm vi sai số thống kê.

Quá trình tải dữ liệu lên tiêu tốn không quá 450 MB RAM. Nghĩa là, cách tiếp cận được mô tả cho phép bạn di chuyển các tập dữ liệu nặng hàng chục gigabyte trên hầu hết mọi phần cứng giá rẻ, bao gồm cả một số thiết bị bo mạch đơn, điều này khá thú vị.

Tất cả những gì còn lại là đo tốc độ truy xuất dữ liệu (ngẫu nhiên) và đánh giá tỷ lệ khi lấy mẫu các lô có kích thước khác nhau:

Điểm chuẩn cơ sở dữ liệu

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)

Nhận dạng Doodle Vẽ nhanh: cách kết bạn với R, C++ và mạng lưới thần kinh

2. Chuẩn bị lô

Toàn bộ quá trình chuẩn bị hàng loạt bao gồm các bước sau:

  1. Phân tích cú pháp một số JSON chứa vectơ của chuỗi có tọa độ điểm.
  2. Vẽ các đường màu dựa trên tọa độ của các điểm trên ảnh có kích thước được yêu cầu (ví dụ: 256×256 hoặc 128×128).
  3. Chuyển đổi hình ảnh thu được thành một tenxơ.

Là một phần của cuộc cạnh tranh giữa các hạt nhân Python, vấn đề đã được giải quyết chủ yếu bằng cách sử dụng OpenCV. Một trong những điểm tương tự đơn giản và rõ ràng nhất trong R sẽ như thế này:

Triển khai chuyển đổi JSON sang Tensor trong 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)
}

Việc vẽ được thực hiện bằng các công cụ R tiêu chuẩn và được lưu vào PNG tạm thời được lưu trong RAM (trên Linux, các thư mục R tạm thời được đặt trong thư mục /tmp, được gắn vào RAM). Sau đó, tệp này được đọc dưới dạng mảng ba chiều với các số từ 0 đến 1. Điều này rất quan trọng vì BMP thông thường hơn sẽ được đọc thành mảng thô với mã màu hex.

Hãy kiểm tra kết quả:

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

Nhận dạng Doodle Vẽ nhanh: cách kết bạn với R, C++ và mạng lưới thần kinh

Bản thân lô sẽ được hình thành như sau:

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

Việc triển khai này có vẻ không tối ưu đối với chúng tôi, vì việc hình thành các lô lớn mất nhiều thời gian và chúng tôi quyết định tận dụng kinh nghiệm của các đồng nghiệp bằng cách sử dụng một thư viện mạnh mẽ OpenCV. Vào thời điểm đó không có gói làm sẵn cho R (hiện tại không có gói nào), do đó việc triển khai tối thiểu chức năng cần thiết đã được viết bằng C++ với sự tích hợp vào mã R bằng cách sử dụng Rcpp.

Để giải quyết vấn đề, các gói và thư viện sau đã được sử dụng:

  1. OpenCV để làm việc với hình ảnh và đường vẽ. Đã sử dụng các thư viện hệ thống và tệp tiêu đề được cài đặt sẵn cũng như liên kết động.

  2. xtensor để làm việc với mảng đa chiều và tensor. Chúng tôi đã sử dụng các tệp tiêu đề có trong gói R cùng tên. Thư viện cho phép bạn làm việc với các mảng nhiều chiều, cả theo thứ tự hàng chính và thứ tự cột.

  3. ndjson để phân tích cú pháp JSON. Thư viện này được sử dụng trong xtensor tự động nếu nó có trong dự án.

  4. RcppChủ đề để tổ chức xử lý đa luồng một vectơ từ JSON. Đã sử dụng các tệp tiêu đề được cung cấp bởi gói này. Từ phổ biến hơn RcppSong song Gói này, trong số những thứ khác, có cơ chế ngắt vòng lặp tích hợp.

Cần lưu ý rằng xtensor hóa ra là một ơn trời: ngoài thực tế là nó có chức năng mở rộng và hiệu suất cao, các nhà phát triển của nó tỏ ra khá nhạy bén và trả lời các câu hỏi một cách nhanh chóng và chi tiết. Với sự giúp đỡ của họ, có thể thực hiện các phép biến đổi ma trận OpenCV thành các tenxơ xtensor, cũng như cách kết hợp các tenxơ hình ảnh 3 chiều thành một tenxơ 4 chiều có kích thước chính xác (chính lô).

Tài liệu học Rcpp, xtensor và 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

Để biên dịch các file sử dụng file hệ thống và liên kết động với các thư viện được cài đặt trên hệ thống, chúng tôi đã sử dụng cơ chế plugin được triển khai trong gói Rcpp. Để tự động tìm đường dẫn và cờ, chúng tôi đã sử dụng tiện ích Linux phổ biến pkg-config.

Triển khai plugin Rcpp để sử dụng thư viện OpenCV

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

Do hoạt động của plugin, các giá trị sau sẽ được thay thế trong quá trình biên dịch:

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"

Mã triển khai để phân tích cú pháp JSON và tạo một lô để truyền tới mô hình được cung cấp trong phần giới thiệu. Đầu tiên, thêm thư mục dự án cục bộ để tìm kiếm tệp tiêu đề (cần thiết cho ndjson):

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

Triển khai chuyển đổi JSON sang tensor trong 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;
}

Mã này nên được đặt trong tập tin src/cv_xt.cpp và biên dịch bằng lệnh Rcpp::sourceCpp(file = "src/cv_xt.cpp", env = .GlobalEnv); cũng cần thiết cho công việc nlohmann/json.hpp của kho. Mã này được chia thành nhiều chức năng:

  • to_xt - một hàm tạo khuôn mẫu để chuyển đổi ma trận hình ảnh (cv::Mat) đến tensor xt::xtensor;

  • parse_json - hàm phân tích một chuỗi JSON, trích xuất tọa độ của các điểm, đóng gói chúng thành một vectơ;

  • ocv_draw_lines — từ vectơ kết quả của các điểm, vẽ các đường nhiều màu;

  • process — kết hợp các chức năng trên và cũng bổ sung khả năng chia tỷ lệ hình ảnh thu được;

  • cpp_process_json_str - bao bọc hàm process, xuất kết quả sang đối tượng R (mảng đa chiều);

  • cpp_process_json_vector - bao bọc hàm cpp_process_json_str, cho phép bạn xử lý một vectơ chuỗi ở chế độ đa luồng.

Để vẽ các đường nhiều màu, mô hình màu HSV đã được sử dụng, sau đó chuyển đổi sang RGB. Hãy kiểm tra kết quả:

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

Nhận dạng Doodle Vẽ nhanh: cách kết bạn với R, C++ và mạng lưới thần kinh
So sánh tốc độ triển khai trong R và 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") 

Nhận dạng Doodle Vẽ nhanh: cách kết bạn với R, C++ và mạng lưới thần kinh

Như bạn có thể thấy, tốc độ tăng lên rất đáng kể và không thể bắt kịp mã C++ bằng cách song song hóa mã R.

3. Trình lặp để dỡ các lô khỏi cơ sở dữ liệu

R nổi tiếng rất xứng đáng trong việc xử lý dữ liệu phù hợp với RAM, trong khi Python nổi bật hơn ở khả năng xử lý dữ liệu lặp, cho phép bạn thực hiện các phép tính ngoài lõi (tính toán sử dụng bộ nhớ ngoài) một cách dễ dàng và tự nhiên. Một ví dụ kinh điển và phù hợp với chúng ta trong bối cảnh của vấn đề được mô tả là mạng lưới thần kinh sâu được đào tạo bằng phương pháp giảm độ dốc với xấp xỉ độ dốc ở mỗi bước bằng cách sử dụng một phần nhỏ các quan sát hoặc lô nhỏ.

Các khung học sâu được viết bằng Python có các lớp đặc biệt triển khai các trình vòng lặp dựa trên dữ liệu: bảng, hình ảnh trong thư mục, định dạng nhị phân, v.v. Bạn có thể sử dụng các tùy chọn có sẵn hoặc tự viết cho các tác vụ cụ thể. Trong R chúng ta có thể tận dụng tất cả các tính năng của thư viện Python máy ảnh với các chương trình phụ trợ khác nhau sử dụng gói cùng tên, gói này lần lượt hoạt động trên đầu gói thử lại. Phần sau xứng đáng có một bài viết dài riêng biệt; nó không chỉ cho phép bạn chạy mã Python từ R mà còn cho phép bạn chuyển các đối tượng giữa các phiên R và Python, tự động thực hiện tất cả các chuyển đổi loại cần thiết.

Chúng tôi đã loại bỏ nhu cầu lưu trữ tất cả dữ liệu trong RAM bằng cách sử dụng MonetDBLite, tất cả công việc “mạng thần kinh” sẽ được thực hiện bằng mã gốc trong Python, chúng tôi chỉ cần viết một trình vòng lặp trên dữ liệu, vì chưa có gì sẵn sàng đối với tình huống như vậy trong R hoặc Python. Về cơ bản chỉ có hai yêu cầu đối với nó: nó phải trả về các lô trong một vòng lặp vô tận và lưu trạng thái của nó giữa các lần lặp (yêu cầu sau trong R được triển khai theo cách đơn giản nhất bằng cách sử dụng các bao đóng). Trước đây, cần phải chuyển đổi rõ ràng các mảng R thành các mảng có nhiều mảng bên trong trình vòng lặp, nhưng phiên bản hiện tại của gói máy ảnh tự mình làm việc đó.

Trình vòng lặp cho dữ liệu đào tạo và xác nhận hóa ra như sau:

Trình vòng lặp cho dữ liệu đào tạo và xác nhận

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

Hàm lấy đầu vào là một biến có kết nối với cơ sở dữ liệu, số dòng được sử dụng, số lớp, kích thước lô, tỷ lệ (scale = 1 tương ứng với việc hiển thị hình ảnh có kích thước 256x256 pixel, scale = 0.5 — 128x128 pixel), chỉ báo màu (color = FALSE chỉ định hiển thị ở thang độ xám khi được sử dụng color = TRUE mỗi nét được vẽ bằng một màu mới) và chỉ báo tiền xử lý cho các mạng được đào tạo trước trên imagenet. Cái sau là cần thiết để chia tỷ lệ các giá trị pixel từ khoảng [0, 1] đến khoảng [-1, 1], được sử dụng khi huấn luyện phần được cung cấp máy ảnh các mô hình.

Hàm bên ngoài chứa việc kiểm tra loại đối số, một bảng data.table với số dòng trộn ngẫu nhiên từ samples_index và số lô, bộ đếm và số lô tối đa, cũng như biểu thức SQL để dỡ dữ liệu khỏi cơ sở dữ liệu. Ngoài ra, chúng tôi đã xác định một hàm tương tự nhanh bên trong keras::to_categorical(). Chúng tôi đã sử dụng gần như tất cả dữ liệu để đào tạo, để lại nửa phần trăm để xác thực, do đó kích thước kỷ nguyên bị giới hạn bởi tham số steps_per_epoch khi được gọi keras::fit_generator(), và điều kiện if (i > max_i) chỉ hoạt động cho trình vòng lặp xác thực.

Trong hàm nội bộ, các chỉ mục hàng được truy xuất cho lô tiếp theo, các bản ghi được dỡ khỏi cơ sở dữ liệu với bộ đếm lô tăng dần, phân tích cú pháp JSON (hàm cpp_process_json_vector(), viết bằng C++) và tạo các mảng tương ứng với hình ảnh. Sau đó, các vectơ one-hot với nhãn lớp được tạo, các mảng có giá trị pixel và nhãn được kết hợp thành một danh sách, đó là giá trị trả về. Để tăng tốc công việc, chúng tôi đã sử dụng việc tạo chỉ mục trong bảng data.table và sửa đổi thông qua liên kết - không có các gói “chip” này bảng dữ liệu Thật khó để tưởng tượng làm việc hiệu quả với bất kỳ lượng dữ liệu đáng kể nào trong R.

Kết quả đo tốc độ trên laptop Core i5 như sau:

Điểm chuẩn của trình vòng lặp

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)

Nhận dạng Doodle Vẽ nhanh: cách kết bạn với R, C++ và mạng lưới thần kinh

Nếu bạn có đủ RAM, bạn có thể tăng tốc hoạt động của cơ sở dữ liệu một cách đáng kể bằng cách chuyển nó sang cùng RAM này (32 GB là đủ cho nhiệm vụ của chúng tôi). Trong Linux, phân vùng được gắn theo mặc định /dev/shm, chiếm tới một nửa dung lượng RAM. Bạn có thể làm nổi bật hơn bằng cách chỉnh sửa /etc/fstabđể có được một kỷ lục như tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Hãy nhớ khởi động lại và kiểm tra kết quả bằng cách chạy lệnh df -h.

Trình lặp cho dữ liệu thử nghiệm trông đơn giản hơn nhiều vì tập dữ liệu thử nghiệm hoàn toàn phù hợp với RAM:

Trình lặp cho dữ liệu thử nghiệm

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. Lựa chọn kiến ​​trúc mô hình

Kiến trúc đầu tiên được sử dụng là mạng di động v1, các tính năng được thảo luận trong Điều này tin nhắn. Nó được bao gồm như là tiêu chuẩn máy ảnh và theo đó, có sẵn trong gói cùng tên cho R. Nhưng khi cố gắng sử dụng nó với các hình ảnh một kênh, một điều kỳ lạ đã xảy ra: tensor đầu vào phải luôn có kích thước (batch, height, width, 3), tức là không thể thay đổi số lượng kênh. Không có giới hạn nào như vậy trong Python, vì vậy chúng tôi đã gấp rút viết cách triển khai kiến ​​trúc này của riêng mình, theo bài viết gốc (không có phần bỏ qua trong phiên bản máy ảnh):

Kiến trúc 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)
}

Những nhược điểm của phương pháp này là rõ ràng. Tôi muốn thử nghiệm rất nhiều mô hình, nhưng ngược lại, tôi không muốn viết lại từng kiến ​​trúc một cách thủ công. Chúng tôi cũng bị tước đi cơ hội sử dụng trọng lượng của các mô hình được đào tạo trước trên imagenet. Như thường lệ, nghiên cứu tài liệu đã giúp ích. Chức năng get_config() cho phép bạn nhận được mô tả về mô hình ở dạng phù hợp để chỉnh sửa (base_model_conf$layers - danh sách R thông thường) và hàm from_config() thực hiện chuyển đổi ngược lại thành đối tượng mô hình:

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)

Bây giờ không khó để viết một hàm phổ quát để có được bất kỳ hàm nào được cung cấp máy ảnh mô hình có hoặc không có trọng lượng được đào tạo trên imagenet:

Chức năng tải các kiến ​​trúc làm sẵn

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

Khi sử dụng hình ảnh một kênh, không sử dụng trọng số đã được huấn luyện trước. Điều này có thể được khắc phục: sử dụng hàm get_weights() lấy trọng số mô hình dưới dạng danh sách các mảng R, thay đổi kích thước của phần tử đầu tiên của danh sách này (bằng cách lấy một kênh màu hoặc lấy trung bình cả ba), sau đó tải trọng số trở lại mô hình bằng hàm set_weights(). Chúng tôi chưa bao giờ thêm chức năng này vì ở giai đoạn này, rõ ràng là làm việc với ảnh màu sẽ hiệu quả hơn.

Chúng tôi đã thực hiện hầu hết các thử nghiệm bằng mobilenet phiên bản 1 và 2, cũng như resnet34. Các kiến ​​trúc hiện đại hơn như SE-ResNeXt hoạt động tốt trong cuộc thi này. Thật không may, chúng tôi không có sẵn các triển khai sẵn có và chúng tôi cũng không viết các triển khai của riêng mình (nhưng chúng tôi chắc chắn sẽ viết).

5. Tham số hóa tập lệnh

Để thuận tiện, tất cả mã để bắt đầu đào tạo được thiết kế dưới dạng một tập lệnh duy nhất, được tham số hóa bằng cách sử dụng tài liệu như sau:

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)

Gói tài liệu thể hiện việc thực hiện http://docopt.org/ dành cho R. Với sự trợ giúp của nó, các tập lệnh được khởi chạy bằng các lệnh đơn giản như Rscript bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db hoặc ./bin/train_nn.R -m resnet50 -c -d /home/andrey/doodle_db, nếu tập tin train_nn.R có thể thực thi được (lệnh này sẽ bắt đầu huấn luyện mô hình resnet50 trên hình ảnh ba màu có kích thước 128x128 pixel, cơ sở dữ liệu phải được đặt trong thư mục /home/andrey/doodle_db). Bạn có thể thêm tốc độ học tập, loại trình tối ưu hóa và bất kỳ thông số tùy chỉnh nào khác vào danh sách. Trong quá trình chuẩn bị xuất bản, hóa ra kiến ​​trúc mobilenet_v2 từ phiên bản hiện tại máy ảnh trong R sử dụng phải không do những thay đổi không được tính đến trong gói R, chúng tôi đang chờ họ khắc phục.

Cách tiếp cận này giúp tăng tốc đáng kể các thử nghiệm với các mô hình khác nhau so với việc khởi chạy các tập lệnh truyền thống hơn trong RStudio (chúng tôi lưu ý gói này là một giải pháp thay thế khả thi tfrun). Nhưng ưu điểm chính là khả năng dễ dàng quản lý việc khởi chạy tập lệnh trong Docker hoặc đơn giản là trên máy chủ mà không cần cài đặt RStudio cho việc này.

6. Docker hóa các tập lệnh

Chúng tôi đã sử dụng Docker để đảm bảo tính di động của môi trường cho các mô hình đào tạo giữa các thành viên trong nhóm và để triển khai nhanh chóng trên đám mây. Bạn có thể bắt đầu làm quen với công cụ này, điều này tương đối bất thường đối với một lập trình viên R, với điều này loạt ấn phẩm hoặc khóa học video.

Docker cho phép bạn vừa tạo hình ảnh của riêng mình từ đầu vừa sử dụng các hình ảnh khác làm cơ sở để tạo hình ảnh của riêng bạn. Khi phân tích các tùy chọn có sẵn, chúng tôi đi đến kết luận rằng việc cài đặt trình điều khiển NVIDIA, CUDA+cuDNN và thư viện Python là một phần khá lớn của hình ảnh và chúng tôi quyết định lấy hình ảnh chính thức làm cơ sở tensorflow/tensorflow:1.12.0-gpu, thêm các gói R cần thiết vào đó.

Tệp docker cuối cùng trông như thế này:

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

Để thuận tiện, các gói được sử dụng được đưa vào các biến; phần lớn các tập lệnh được viết được sao chép bên trong các thùng chứa trong quá trình lắp ráp. Chúng tôi cũng đã thay đổi shell lệnh thành /bin/bash để dễ dàng sử dụng nội dung /etc/os-release. Điều này tránh được việc phải chỉ định phiên bản hệ điều hành trong mã.

Ngoài ra, một tập lệnh bash nhỏ đã được viết cho phép bạn khởi chạy một vùng chứa bằng nhiều lệnh khác nhau. Ví dụ: đây có thể là các tập lệnh để đào tạo mạng thần kinh đã được đặt trước đó bên trong vùng chứa hoặc trình bao lệnh để gỡ lỗi và giám sát hoạt động của vùng chứa:

Tập lệnh khởi chạy container

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

Nếu tập lệnh bash này được chạy mà không có tham số, tập lệnh sẽ được gọi bên trong vùng chứa train_nn.R với các giá trị mặc định; nếu đối số vị trí đầu tiên là "bash", thì vùng chứa sẽ bắt đầu tương tác bằng shell lệnh. Trong tất cả các trường hợp khác, giá trị của đối số vị trí được thay thế: CMD="Rscript /app/train_nn.R $@".

Điều đáng chú ý là các thư mục chứa dữ liệu nguồn và cơ sở dữ liệu, cũng như thư mục lưu các mô hình đã đào tạo, được gắn bên trong vùng chứa từ hệ thống máy chủ, cho phép bạn truy cập kết quả của tập lệnh mà không cần các thao tác không cần thiết.

7. Sử dụng nhiều GPU trên Google Cloud

Một trong những đặc điểm của cuộc thi là dữ liệu rất nhiễu (xem ảnh tiêu đề, mượn từ @Leigh.plt từ ODS Slack). Các lô lớn giúp giải quyết vấn đề này và sau khi thử nghiệm trên PC có 1 GPU, chúng tôi quyết định thành thạo các mô hình đào tạo trên một số GPU trên đám mây. GoogleCloud đã qua sử dụng (hướng dẫn tốt về những điều cơ bản) do có nhiều lựa chọn về cấu hình có sẵn, giá cả hợp lý và tiền thưởng 300 USD. Vì lòng tham, tôi đã đặt mua một phiên bản 4xV100 với ổ SSD và rất nhiều RAM, và đó là một sai lầm lớn. Một cỗ máy như vậy ngốn tiền một cách nhanh chóng; bạn có thể phá sản khi thử nghiệm mà không có một hệ thống đã được chứng minh. Vì mục đích giáo dục, tốt hơn nên dùng K80. Nhưng dung lượng RAM lớn lại có ích - SSD đám mây không gây ấn tượng với hiệu suất của nó, vì vậy cơ sở dữ liệu đã được chuyển sang dev/shm.

Điều đáng quan tâm nhất là đoạn mã chịu trách nhiệm sử dụng nhiều GPU. Đầu tiên, mô hình được tạo trên CPU bằng trình quản lý bối cảnh, giống như trong 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
  )
})

Sau đó, mô hình chưa được biên dịch (điều này rất quan trọng) sẽ được sao chép vào một số GPU có sẵn nhất định và chỉ sau đó nó mới được biên dịch:

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

Không thể triển khai kỹ thuật cổ điển là đóng băng tất cả các lớp ngoại trừ lớp cuối cùng, đào tạo lớp cuối cùng, giải phóng và đào tạo lại toàn bộ mô hình cho một số GPU.

Đào tạo đã được theo dõi mà không cần sử dụng. bảng căng, giới hạn bản thân trong việc ghi lại nhật ký và lưu các mô hình với các tên thông tin sau mỗi kỷ nguyên:

Cuộc gọi lại

# Шаблон имени файла лога
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. Thay vì kết luận

Một số vấn đề chúng tôi gặp phải vẫn chưa được khắc phục:

  • в máy ảnh không có sẵn chức năng nào để tự động tìm kiếm tốc độ học tập tối ưu (tương tự lr_finder trong thư viện nhanh.ai); Với một số nỗ lực, có thể chuyển các triển khai của bên thứ ba sang R, ví dụ: điều này;
  • do điểm trước đó, không thể chọn tốc độ đào tạo chính xác khi sử dụng nhiều GPU;
  • thiếu kiến ​​trúc mạng thần kinh hiện đại, đặc biệt là những kiến ​​trúc được đào tạo trước trên imagenet;
  • không có chính sách một chu kỳ và tỷ lệ học tập phân biệt đối xử (ủ cosine là theo yêu cầu của chúng tôi thực hiện, cảm ơn skeydan).

Những điều hữu ích đã học được từ cuộc thi này:

  • Trên phần cứng có công suất tương đối thấp, bạn có thể làm việc với khối lượng dữ liệu khá (gấp nhiều lần kích thước RAM) mà không gặp khó khăn. Túi nhựa bảng dữ liệu tiết kiệm bộ nhớ do sửa đổi bảng tại chỗ, tránh sao chép chúng và khi được sử dụng đúng cách, các khả năng của nó hầu như luôn thể hiện tốc độ cao nhất trong số tất cả các công cụ mà chúng ta biết đến đối với ngôn ngữ kịch bản. Trong nhiều trường hợp, việc lưu dữ liệu vào cơ sở dữ liệu cho phép bạn không cần phải suy nghĩ gì về việc cần phải nén toàn bộ tập dữ liệu vào RAM.
  • Các hàm chậm trong R có thể được thay thế bằng các hàm nhanh trong C++ bằng gói Rcpp. Nếu ngoài việc sử dụng RcppChủ đề hoặc RcppSong song, chúng tôi nhận được các triển khai đa luồng đa nền tảng, do đó không cần phải song song hóa mã ở cấp độ R.
  • bưu kiện Rcpp có thể được sử dụng mà không cần có kiến ​​thức nghiêm túc về C++, mức tối thiểu bắt buộc được nêu rõ đây. Tệp tiêu đề cho một số thư viện C thú vị như xtensor có sẵn trên CRAN, nghĩa là cơ sở hạ tầng đang được hình thành để triển khai các dự án tích hợp mã C++ hiệu suất cao được tạo sẵn vào R. Tiện ích bổ sung là đánh dấu cú pháp và bộ phân tích mã C++ tĩnh trong RStudio.
  • tài liệu cho phép bạn chạy các tập lệnh độc lập với các tham số. Điều này thuận tiện để sử dụng trên máy chủ từ xa, bao gồm. dưới docker. Trong RStudio, thật bất tiện khi tiến hành nhiều giờ thử nghiệm với các mạng thần kinh huấn luyện và việc cài đặt IDE trên máy chủ không phải lúc nào cũng hợp lý.
  • Docker đảm bảo tính di động của mã và khả năng tái tạo kết quả giữa các nhà phát triển với các phiên bản hệ điều hành và thư viện khác nhau, cũng như dễ dàng thực thi trên máy chủ. Bạn có thể khởi chạy toàn bộ quy trình đào tạo chỉ bằng một lệnh.
  • Google Cloud là một cách tiết kiệm ngân sách để thử nghiệm trên phần cứng đắt tiền nhưng bạn cần chọn cấu hình một cách cẩn thận.
  • Việc đo tốc độ của từng đoạn mã riêng lẻ rất hữu ích, đặc biệt khi kết hợp R và C++ và với gói băng ghế dự bị - cũng rất dễ dàng.

Nhìn chung, trải nghiệm này rất bổ ích và chúng tôi tiếp tục nỗ lực giải quyết một số vấn đề đã nêu.

Nguồn: www.habr.com

Thêm một lời nhận xét