Quick Draw Doodle Recognition. ինչպես ընկերանալ R, C++ և նեյրոնային ցանցերի հետ

Quick Draw Doodle Recognition. ինչպես ընկերանալ R, C++ և նեյրոնային ցանցերի հետ

Հե՜յ Հաբր։

Անցյալ աշնանը Kaggle-ը կազմակերպեց մրցույթ՝ ձեռքով նկարված նկարները դասակարգելու համար՝ Quick Draw Doodle Recognition, որին, ի թիվս այլոց, մասնակցեց R- գիտնականների թիմը. Արտեմ Կլևցովա, Ֆիլիպայի մենեջեր и Անդրեյ Օգուրցով. Մրցույթը մանրամասն չենք նկարագրի, դա արդեն արվել է վերջին հրապարակումը.

Այս անգամ դա չստացվեց շքանշանների մշակման հետ, բայց ձեռք բերվեց շատ արժեքավոր փորձ, ուստի ես կցանկանայի համայնքին պատմել մի շարք ամենահետաքրքիր և օգտակար բաների մասին Kagle-ում և առօրյա աշխատանքում: Քննարկվող թեմաներից՝ դժվար կյանք առանց Opencv, JSON վերլուծություն (այս օրինակները ուսումնասիրում են C++ կոդի ինտեգրումը R-ում սկրիպտների կամ փաթեթների մեջ՝ օգտագործելով Rcpp), սկրիպտների պարամետրացում և վերջնական լուծման դոկերիզացիա։ Հաղորդագրության բոլոր ծածկագիրը կատարման համար հարմար ձևով հասանելի է պահոցներ.

Բովանդակությունը:

  1. Արդյունավետորեն բեռնեք տվյալները CSV-ից MonetDB-ում
  2. Խմբաքանակների պատրաստում
  3. Iterators՝ խմբաքանակները տվյալների բազայից բեռնաթափելու համար
  4. Մոդելային ճարտարապետության ընտրություն
  5. Սցենարի պարամետրացում
  6. Սցենարների Dockerization
  7. Օգտագործելով բազմաթիվ GPU-ներ Google Cloud-ում
  8. Փոխարենը մի եզրակացության

1. Արդյունավետորեն բեռնեք տվյալները CSV-ից MonetDB տվյալների բազայում

Այս մրցույթում տվյալները տրամադրվում են ոչ թե պատրաստի պատկերների, այլ 340 CSV ֆայլերի տեսքով (յուրաքանչյուր դասի համար մեկ ֆայլ), որոնք պարունակում են JSON՝ կետային կոորդինատներով։ Այս կետերը գծերով միացնելով՝ ստանում ենք վերջնական պատկեր՝ 256x256 պիքսել չափերով։ Նաև յուրաքանչյուր գրառման համար կա պիտակ, որը ցույց է տալիս, թե արդյոք նկարը ճիշտ է ճանաչվել տվյալների հավաքագրման պահին օգտագործված դասակարգչի կողմից, նկարի հեղինակի բնակության երկրի երկտառ ծածկագիր, եզակի նույնացուցիչ, ժամանակի դրոշմ: և դասի անուն, որը համապատասխանում է ֆայլի անվանը: Բնօրինակ տվյալների պարզեցված տարբերակը կշռում է 7.4 ԳԲ արխիվում և մոտավորապես 20 ԳԲ փաթեթավորումից հետո, փաթեթավորումից հետո ամբողջական տվյալները զբաղեցնում են 240 ԳԲ: Կազմակերպիչները վստահեցրել են, որ երկու տարբերակներն էլ վերարտադրեն նույն նկարները, ինչը նշանակում է, որ ամբողջական տարբերակը ավելորդ է: Ամեն դեպքում, գրաֆիկական ֆայլերում կամ զանգվածների տեսքով 50 միլիոն պատկեր պահելը անմիջապես համարվեց անշահավետ, և մենք որոշեցինք միավորել բոլոր CSV ֆայլերը արխիվից։ train_simplified.zip տվյալների բազայում՝ յուրաքանչյուր խմբաքանակի համար պահանջվող չափի պատկերների հետագա ստեղծմամբ:

Որպես DBMS ընտրվել է լավ ապացուցված համակարգ MonetDB, մասնավորապես՝ R-ի համար որպես փաթեթի իրականացում MonetDBLite. Փաթեթը ներառում է տվյալների բազայի սերվերի ներկառուցված տարբերակը և թույլ է տալիս վերցնել սերվերը անմիջապես 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"
    )
  )
}

Տվյալների բազա բեռնելու ամենաարագ ճանապարհը CSV ֆայլերի ուղղակի պատճենումն էր՝ օգտագործելով SQL - հրամանը 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 ՄԲ RAM: Այսինքն՝ նկարագրված մոտեցումը թույլ է տալիս տասնյակ գիգաբայթ կշռող տվյալների հավաքածուներ տեղափոխել գրեթե ցանկացած բյուջետային սարքաշարի վրա, ներառյալ որոշ մեկ տախտակի սարքեր, ինչը բավականին հիանալի է:

Մնում է միայն չափել (պատահական) տվյալների առբերման արագությունը և գնահատել մասշտաբը տարբեր չափերի խմբաքանակների նմուշառման ժամանակ.

Տվյալների բազայի չափանիշ

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 Recognition. ինչպես ընկերանալ R, C++ և նեյրոնային ցանցերի հետ

2. Խմբաքանակների պատրաստում

Խմբաքանակի պատրաստման ամբողջ գործընթացը բաղկացած է հետևյալ քայլերից.

  1. Մի քանի JSON-ների վերլուծություն, որոնք պարունակում են տողերի վեկտորներ կետերի կոորդինատներով:
  2. Պահանջվող չափի պատկերի վրա կետերի կոորդինատների հիման վրա գունավոր գծեր նկարելը (օրինակ՝ 256×256 կամ 128×128):
  3. Ստացված պատկերները վերածելով թենզորի:

Python միջուկների միջև մրցակցության շրջանակներում խնդիրը լուծվել է հիմնականում օգտագործելով Opencv. R-ի ամենապարզ և ակնհայտ անալոգներից մեկը այսպիսի տեսք կունենա.

JSON-ի տենզորի փոխակերպման իրականացում 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)
}

Նկարչությունը կատարվում է ստանդարտ 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 Recognition. ինչպես ընկերանալ 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 կոդի մեջ ինտեգրվելու միջոցով՝ օգտագործելով. Rcpp.

Խնդիրը լուծելու համար օգտագործվել են հետևյալ փաթեթները և գրադարանները.

  1. Opencv պատկերների հետ աշխատելու և գծեր գծելու համար: Օգտագործված նախապես տեղադրված համակարգի գրադարաններ և վերնագրերի ֆայլեր, ինչպես նաև դինամիկ կապակցում:

  2. xtensor բազմաչափ զանգվածների և թենզորների հետ աշխատելու համար։ Մենք օգտագործել ենք վերնագրի ֆայլեր, որոնք ներառված են համանուն R փաթեթում։ Գրադարանը թույլ է տալիս աշխատել բազմաչափ զանգվածների հետ՝ և՛ տողերի հիմնական, և՛ սյունակների հիմնական հերթականությամբ:

  3. նջսոն JSON վերլուծության համար: Այս գրադարանը օգտագործվում է xtensor ավտոմատ կերպով, եթե այն առկա է նախագծում:

  4. RcppThread JSON-ից վեկտորի բազմաթելային մշակումը կազմակերպելու համար: Օգտագործել է այս փաթեթի կողմից տրամադրված վերնագրի ֆայլերը: Ավելի հայտնիներից RcppԶուգահեռ Փաթեթը, ի թիվս այլ բաների, ունի ներկառուցված հանգույցի ընդհատման մեխանիզմ:

Պետք է նշել, որ xtensor ի լրումն այն բանի, որ այն ունի լայնածավալ ֆունկցիոնալություն և բարձր կատարողականություն, նրա մշակողները պարզվել են, որ բավականին արձագանքող են և արագ և մանրամասն պատասխանել են հարցերին: Նրանց օգնությամբ հնարավոր եղավ իրականացնել OpenCV մատրիցների փոխակերպումները xtensor-ի թենզորների, ինչպես նաև եռաչափ պատկերի թենզորների համակցման միջոց ճիշտ չափի 3-չափ տենզորի մեջ (ինքն խմբաքանակը):

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

Համակարգային ֆայլեր օգտագործող և համակարգում տեղադրված գրադարանների հետ դինամիկ կապակցող ֆայլեր կազմելու համար մենք օգտագործեցինք փաթեթում ներդրված plugin մեխանիզմը: Rcpp. Ավտոմատ ճանապարհներ և դրոշներ գտնելու համար մենք օգտագործեցինք Linux-ի հանրահայտ կոմունալ ծրագիրը pkg- կոնֆիգուրացիա.

Rcpp plugin-ի ներդրում 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)
  ))
})

Փլագինի աշխատանքի արդյունքում կազմման գործընթացում կփոխարինվեն հետևյալ արժեքները.

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 Recognition. ինչպես ընկերանալ 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 Recognition. ինչպես ընկերանալ R, C++ և նեյրոնային ցանցերի հետ

Ինչպես տեսնում եք, արագության բարձրացումը շատ զգալի է ստացվել, և հնարավոր չէ հասնել C++ կոդին՝ զուգահեռացնելով R կոդը։

3. Իտերատորներ՝ տվյալների բազայից խմբաքանակները բեռնաթափելու համար

R-ն արժանի համբավ ունի տվյալների մշակման համար, որոնք տեղավորվում են RAM-ում, մինչդեռ Python-ն ավելի շատ բնութագրվում է տվյալների կրկնվող մշակմամբ, ինչը թույլ է տալիս հեշտությամբ և բնականաբար իրականացնել առանց հիմնական հաշվարկներ (հաշվարկներ՝ օգտագործելով արտաքին հիշողություն): Նկարագրված խնդրի համատեքստում մեզ համար դասական և համապատասխան օրինակ է խորը նեյրոնային ցանցերը, որոնք վարժվել են գրադիենտ ծագման մեթոդով, յուրաքանչյուր քայլում գրադիենտի մոտավոր գնահատմամբ՝ օգտագործելով դիտումների փոքր մասը կամ մինի խմբաքանակը:

Python-ում գրված խորը ուսուցման շրջանակներն ունեն հատուկ դասեր, որոնք իրականացնում են տվյալների հիման վրա կրկնողներ՝ աղյուսակներ, նկարներ թղթապանակներում, երկուական ձևաչափեր և այլն: Դուք կարող եք օգտագործել պատրաստի տարբերակները կամ գրել ձերը կոնկրետ առաջադրանքների համար: R-ում մենք կարող ենք օգտվել Python գրադարանի բոլոր հնարավորություններից կապեր իր տարբեր հետնամասերով՝ օգտագործելով համանուն փաթեթը, որն իր հերթին աշխատում է փաթեթի վերևում ցանցավորել. Վերջինս արժանի է առանձին ծավալուն հոդվածի. այն ոչ միայն թույլ է տալիս գործարկել Python կոդը R-ից, այլ նաև թույլ է տալիս օբյեկտներ փոխանցել R և Python նիստերի միջև՝ ավտոմատ կերպով կատարելով բոլոր անհրաժեշտ տեսակի փոխարկումները:

Մենք ձերբազատվեցինք բոլոր տվյալները RAM-ում պահելու անհրաժեշտությունից՝ օգտագործելով MonetDBLite, ամբողջ «նյարդային ցանցի» աշխատանքը կկատարվի Python-ի բնօրինակ կոդով, պարզապես պետք է տվյալների վրա գրել կրկնող, քանի որ պատրաստ ոչինչ չկա: R կամ Python-ում նման իրավիճակի համար: Դրա համար, ըստ էության, միայն երկու պահանջ կա. այն պետք է վերադարձնի խմբաքանակները անվերջ օղակում և պահպանի իր վիճակը կրկնությունների միջև (վերջինս R-ում իրականացվում է ամենապարզ ձևով՝ օգտագործելով փակումները): Նախկինում պահանջվում էր բացահայտորեն փոխարկել R զանգվածները իտերատորի ներսում անփույթ զանգվածների, սակայն փաթեթի ընթացիկ տարբերակը կապեր դա ինքն է անում:

Վերապատրաստման և վավերացման տվյալների կրկնիչը հետևյալն է.

Կրկնվող ուսուցման և վավերացման տվյալների համար

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(). Մենք օգտագործեցինք գրեթե բոլոր տվյալները վերապատրաստման համար, թողնելով կես տոկոս վավերացման համար, ուստի դարաշրջանի չափը սահմանափակվեց պարամետրով steps_per_epoch երբ կոչվում է keras::fit_generator(), և վիճակը if (i > max_i) աշխատել է միայն վավերացման կրկնիչի համար:

Ներքին գործառույթում տողերի ինդեքսները վերցվում են հաջորդ խմբաքանակի համար, գրառումները բեռնաթափվում են տվյալների բազայից՝ մեծացող խմբաքանակի հաշվիչով, JSON վերլուծություն (գործառույթ cpp_process_json_vector(), գրված է C++) և ստեղծելով նկարներին համապատասխան զանգվածներ։ Այնուհետև ստեղծվում են դասի պիտակներով մեկ տաք վեկտորներ, պիքսելային արժեքներով զանգվածները և պիտակները միավորվում են ցուցակի մեջ, որը վերադարձի արժեքն է: Աշխատանքն արագացնելու համար մենք օգտագործեցինք աղյուսակների ինդեքսների ստեղծումը data.table և փոփոխումը հղման միջոցով՝ առանց այս փաթեթի «չիպերի» տվյալներ.աղյուսակ Բավական դժվար է պատկերացնել արդյունավետ աշխատել ցանկացած զգալի քանակությամբ տվյալների հետ Ռ.

Core i5 նոութբուքի արագության չափումների արդյունքները հետևյալն են.

Iterator չափանիշ

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 Recognition. ինչպես ընկերանալ R, C++ և նեյրոնային ցանցերի հետ

Եթե ​​ունեք բավարար քանակությամբ RAM, կարող եք լրջորեն արագացնել տվյալների բազայի աշխատանքը՝ այն փոխանցելով նույն RAM-ին (մեր առաջադրանքի համար բավարար է 32 ԳԲ)։ Linux-ում բաժանումը տեղադրված է լռելյայն /dev/shm, զբաղեցնելով RAM-ի մինչև կեսը: Դուք կարող եք ավելին ընդգծել՝ խմբագրելով /etc/fstabնման ռեկորդ ստանալու համար tmpfs /dev/shm tmpfs defaults,size=25g 0 0. Համոզվեք, որ վերագործարկեք և ստուգեք արդյունքը՝ գործարկելով հրամանը df -h.

Փորձարկման տվյալների կրկնիչը շատ ավելի պարզ է թվում, քանի որ թեստային տվյալների հավաքածուն ամբողջությամբ տեղավորվում է RAM-ում.

Iterator թեստային տվյալների համար

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. Մոդելային ճարտարապետության ընտրություն

Օգտագործված առաջին ճարտարապետությունը եղել է mobilenet v1, որի առանձնահատկությունները քննարկվում են սա է հաղորդագրություն։ Այն ներառված է որպես ստանդարտ կապեր և, համապատասխանաբար, հասանելի է R-ի համանուն փաթեթում: Բայց երբ փորձում էին այն օգտագործել մեկ ալիքով պատկերներով, պարզվեց մի տարօրինակ բան. մուտքային տենզորը միշտ պետք է ունենա չափսեր. (batch, height, width, 3), այսինքն՝ ալիքների թիվը հնարավոր չէ փոխել։ Python-ում նման սահմանափակում չկա, այնպես որ մենք շտապեցինք և գրեցինք այս ճարտարապետության մեր սեփական իրականացումը, հետևելով բնօրինակ հոդվածին (առանց թողարկման, որը կա keras տարբերակում).

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-ում սկրիպտների ավելի ավանդական գործարկման հետ (մենք նշում ենք փաթեթը որպես հնարավոր այլընտրանք tfruns) Բայց հիմնական առավելությունը Docker-ում կամ պարզապես սերվերի վրա սկրիպտների գործարկումը հեշտությամբ կառավարելու հնարավորությունն է՝ առանց դրա համար RStudio տեղադրելու:

6. Սցենարների Dockerization

Մենք օգտագործեցինք 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

Հարմարության համար օգտագործված փաթեթները դրվեցին փոփոխականների մեջ. Գրված սցենարների մեծ մասը պատճենվում է բեռնարկղերի ներսում հավաքման ժամանակ: Մենք նաև փոխեցինք հրամանի վահանակը /bin/bash բովանդակության օգտագործման հեշտության համար /etc/os-release. Սա խուսափեց կոդի մեջ OS-ի տարբերակը նշելու անհրաժեշտությունից:

Բացի այդ, գրվել է փոքրիկ bash սցենար, որը թույլ է տալիս գործարկել կոնտեյներ տարբեր հրամաններով: Օրինակ, դրանք կարող են լինել նեյրոնային ցանցերի ուսուցման սկրիպտներ, որոնք նախկինում տեղադրված էին կոնտեյների ներսում, կամ հրամանի վահանակ՝ վրիպազերծելու և կոնտեյների աշխատանքը վերահսկելու համար.

Սցենար՝ բեռնարկղը գործարկելու համար

#!/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» է, ապա կոնտեյները կսկսվի ինտերակտիվ կերպով հրամանի վահանակով: Բոլոր մյուս դեպքերում դիրքային փաստարկների արժեքները փոխարինվում են. CMD="Rscript /app/train_nn.R $@".

Հարկ է նշել, որ աղբյուրի տվյալների և տվյալների բազայի դիրեկտորիաները, ինչպես նաև վարժեցված մոդելները պահելու գրացուցակը տեղադրված են հյուրընկալող համակարգից կոնտեյների ներսում, ինչը թույլ է տալիս մուտք գործել սկրիպտների արդյունքները առանց ավելորդ մանիպուլյացիաների:

7. Google Cloud-ում բազմաթիվ GPU-ների օգտագործում

Մրցույթի առանձնահատկություններից մեկը շատ աղմկոտ տվյալներն էին (տե՛ս վերնագրի նկարը, որը վերցված է @Leigh.plt-ից ODS slack-ից): Խոշոր խմբաքանակներն օգնում են պայքարել դրա դեմ, և 1 GPU ունեցող ԱՀ-ի վրա փորձարկումներից հետո մենք որոշեցինք տիրապետել ամպի մի քանի GPU-ների վերապատրաստման մոդելներին: Օգտագործված GoogleCloud (լավ ուղեցույց հիմունքների համար) հասանելի կոնֆիգուրացիաների մեծ ընտրության, մատչելի գների և $300 բոնուսի շնորհիվ: Ագահությունից ես պատվիրեցի 4xV100 օրինակ SSD-ով և տոննա RAM-ով, և դա մեծ սխալ էր: Նման մեքենան արագ է ուտում փողը, դուք կարող եք փորձարկել առանց ապացուցված խողովակաշարի: Կրթական նպատակներով ավելի լավ է վերցնել K80-ը։ Բայց մեծ քանակությամբ RAM-ը հարմար եկավ. ամպային SSD-ը չտպավորեց իր կատարողականությամբ, ուստի տվյալների բազան փոխանցվեց dev/shm.

Ամենամեծ հետաքրքրությունը կոդերի հատվածն է, որը պատասխանատու է բազմաթիվ GPU-ների օգտագործման համար: Նախ, մոդելը ստեղծվում է պրոցեսորի վրա՝ օգտագործելով համատեքստի կառավարիչը, ինչպես 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. Եթե ​​ի լրումն օգտագործման RcppThread կամ RcppԶուգահեռ, մենք ստանում ենք միջպլատֆորմային բազմաշերտ իրականացումներ, ուստի R մակարդակում կոդը զուգահեռացնելու կարիք չկա։
  • Փաթեթ Rcpp կարող է օգտագործվել առանց C++-ի լուրջ իմացության, նախանշված է պահանջվող նվազագույնը այստեղ. Վերնագրի ֆայլեր մի շարք հետաքրքիր C-գրադարանների համար, ինչպիսիք են xtensor հասանելի է CRAN-ում, այսինքն՝ ձևավորվում է ենթակառուցվածք՝ նախագծերի իրականացման համար, որոնք ինտեգրում են պատրաստի բարձր արդյունավետության C++ ծածկագիրը R. Լրացուցիչ հարմարավետությունն է շարահյուսության ընդգծումը և ստատիկ C++ կոդերի անալիզատորը RStudio-ում:
  • դոկտ թույլ է տալիս գործարկել ինքնուրույն սկրիպտներ պարամետրերով: Սա հարմար է հեռավոր սերվերի վրա օգտագործելու համար, ներառյալ. դոկերի տակ: RStudio-ում անհարմար է նեյրոնային ցանցերի վերապատրաստման հետ կապված բազմաթիվ ժամերով փորձեր անցկացնելը, և IDE-ի տեղադրումը հենց սերվերի վրա միշտ չէ, որ արդարացված է:
  • Docker-ն ապահովում է կոդի տեղափոխելիությունը և արդյունքների վերարտադրելիությունը ՕՀ-ի և գրադարանների տարբեր տարբերակներով մշակողների միջև, ինչպես նաև սերվերների վրա կատարման հեշտությունը: Դուք կարող եք գործարկել ամբողջ ուսումնական խողովակաշարը միայն մեկ հրամանով:
  • Google Cloud-ը բյուջետային տարբերակ է թանկարժեք սարքավորումների վրա փորձարկելու համար, բայց դուք պետք է ուշադիր ընտրեք կազմաձևերը:
  • Կոդի առանձին հատվածների արագությունը չափելը շատ օգտակար է, հատկապես R-ը և C++-ը և փաթեթի հետ համատեղելը դազգահ - նույնպես շատ հեշտ:

Ընդհանուր առմամբ, այս փորձը շատ օգտակար էր, և մենք շարունակում ենք աշխատել բարձրացված որոշ խնդիրների լուծման ուղղությամբ:

Source: www.habr.com

Добавить комментарий