data.table: вичавлюємо максимум швидкості при роботі з даними в мові R

На ексклюзивних умовах представляємо для вас повний варіант статті з журналу Хакер, присвяченій розробці на R. Під катом ви дізнаєтеся, як вичавити максимум швидкості при роботі з табличними даними в мові R.

Примітка: орфографія і пунктуація автора збережені.
До чого зайві слова? Ти читаєш статтю про швидкість, тому давай відразу до суті! Якщо у тебе в проекті йде робота з великим об'ємом даних і на трансформацію таблиць витрачається більше часу, ніж хотілося, то
data.table
допоможе вирішити цю проблему. Стаття буде цікава тим, хто вже трохи знайомий з мовою R, а також розробникам, які його активно використовують, але ще не відкрили для себе пакет
data.table
.
Встановлюємо пакети
Все необхідне для нашої сьогоднішньої статті можна проінсталліть з допомогою відповідних функцій:
install.packages("data.table") 
install.packages("dplyr") 
install.packages("readr")

R атакує
В останні роки мова R заслужено набирає популярність в середовищі машинного навчання. Як правило, для роботи з цим підрозділом штучного інтелекту необхідно завантажити дані з декількох джерел, провести з ними перетворення для отримання навчальної вибірки, на її основі створити модель, а потім використовувати цю модель для пророкувань.
На словах все просто, але в реальному житті для формування «хорошою» і стійкої моделі потрібно безліч спроб, більшість з яких можуть бути абсолютно тупиковими. Мова R допомагає спростити процес створення такої моделі, так як це ефективний інструмент аналізу табличних даних. Для роботи з ними в R існує вбудований тип даних
data.frame
і величезна кількість алгоритмів і моделей, які його активно використовують. До того ж вся міць R полягає в можливості розширювати базову функціональність за допомогою сторонніх пакетів. У момент написання матеріалу їх кількість в офіційному репозиторії досягло 8914.
Але, як мовиться, немає межі досконалості. Велика кількість пакетів дозволяють полегшити роботу з самим типом даних
data.frame
. Зазвичай їх мета — спростити синтаксис для виконання найбільш поширених завдань. Тут не можна не згадати пакет
dplyr
, який вже став стандартом де-факто для роботи з
data.frame
, так як за рахунок нього читаність і зручність роботи з таблицями зросли в рази.
Перейдемо від теорії до практики і створимо
data.frame
DF
зі стовпцями
a
,
b
та .
DF <- data.frame(a=sample(1:10, 100, replace = TRUE), # Випадкові числа від 1 до 10
b=sample(1:5, 100, replace = TRUE), # Випадкові числа від 1 до 5
c=100:1) # Кількості від 100 до 1

Якщо ми хочемо:
  • вибрати стовпці
    a
    та ,
  • відфільтрувати рядки, де
    a
    = 2 і > 10,
  • створити нову колонку
    ас
    , що дорівнює сумі
    a
    та ,
  • записати результат в змінну
    DF2
    ,
базовий синтаксис на чистому
data.frame
буде такий:
DF2 <- DF[DF$a == 2 & DF$c > 10, c("a", "с")] # Фільтруємо рядки і стовпці
DF2$ac <- DF2$a + DF2$c # Створюємо нову колонку

З допомогою
dplyr
все набагато наочніше:
library(dplyr) # Завантажимо пакет dplyr
DF2 <- DF %>% select(a, c) %>% filter(a == 2, c > 10) %>% mutate(ac = a + c) 

Ці ж дії, але з коментарями:
DF2 <- # результат всього, що справа записати в DF2
DF %>% # взяти DF і передати далі (%>%)
select(a, c) %>% # вибрати колонки «a» і «с» і передати далі (%>%)
filter(a == 2, c > 10) %>% # відфільтрувати рядки і передати далі (%>%)
mutate(ac = a + c) # додати колонку «ac», що дорівнює сумі «а» і «с»

Є й альтернативний підхід для роботи з таблицями —
data.table
. Формально
data.table
— це теж
data.frame
, і його можна використовувати з існуючими функціями і пакетами, які часто нічого не знають про
data.table
і працюють виключно з
data.frame
. Цей «покращений»
data.frame
може виконувати багато типові задачі в кілька разів швидше свого прабатька. Виникає законне питання: де підступ? Цієї самої «засадой»
data.table
виявляється його синтаксис, який сильно відрізняється від оригінального. При цьому якщо
dplyr
з перших же секунд використання робить код легше для розуміння, то
data.table
перетворює код в чорну магію, і тільки роки вивчення чаклунських книг кілька днів практики з
data.table
дозволять повністю зрозуміти ідею нового синтаксису і принцип спрощення коду.
Пробуємо data.table
Для роботи з
data.table
необхідно підключити його пакет.
library(data.table) # Підключення пакета

У подальших прикладах ці виклики будуть опущені і буде вважатися, що пакет вже завантажений.
Так як дані дуже часто завантажуються з файлів CSV, то вже на цьому етапі
data.table
може дивувати. Для того щоб показати більш вимірні оцінки, візьмемо який-небудь досить великий файл CSV. В якості прикладу можна навести дані з одного з останніх змагань на Kaggle. Там ти знайдеш тренувальний файл CSV розміром в 1,27 Гбайт. Структура файлу дуже проста:
  • row_id
    — ідентифікатор події;
  • x
    ,
    y
    — координати;
  • accuracy
    — точність;
  • time
    — час;
  • place_id
    — ідентифікатор організації.
Спробуємо скористатися базовою функцією R —
read.csv
і виміряємо час, яке знадобиться для завантаження цього файлу (для цього звернемося до функції
system.time
):
system.time(
train_DF <- read.csv("train.csv")
)

Час виконання — 461,349 секунди. Достатньо, щоб сходити за кавою… Навіть якщо в майбутньому ти не захочеш користуватися
data.table
, все одно намагайся рідше застосовувати вбудовані функції читання CSV. Є хороша бібліотека
readr
, де все реалізовано набагато ефективніше, ніж у базових функціях. Подивимося її роботу на прикладі і підключимо пакет.
library(readr) 

Далі скористаємося функцією завантаження даних з CSV:
system.time(
train_DF <- read_csv("train.csv")
)

Час виконання — 38,067 секунди — значно швидше попереднього результату! Подивимося, на що здатний data.table:
system.time(
train_DT <- fread("train.csv")
)

Час виконання — 20,906 секунди, що майже в два рази швидше, ніж в
readr
, і в двадцять разів швидше, ніж в базовому методі.
У нашому прикладі різниця в швидкості завантаження для різних методів вийшла досить велика. Всередині кожного з використовуваних методів час лінійно залежить від обсягу файлу, але різниця в швидкості між цими методами сильно залежить від структури файлу (кількості і типів стовпців). Нижче наведено тестові виміри часу завантаження файлів.
Для файлу з трьох текстових колонок видно явну перевагу
fread
:
image
Якщо ж зчитуються не текстові, цифрові колонки, то різниця між
fread
та
read_csv
менш помітна:
image
Якщо після завантаження даних з файлу ти збираєшся далі працювати з
data.table
,
fread
відразу його повертає. При інших способах завантаження даних необхідно зробити
data.table
data.frame
, хоча це просто:
train_DF # Завантажений data.frame
train_DT <- data.table(train_DF) # data.table створений з `data.frame`

Більшість оптимізацій по швидкості
data.table
досягається за рахунок роботи з об'єктами за посиланням, додаткові копії об'єктів в пам'яті не створюються, а отже, економиться час і ресурси.
Наприклад, ту ж задачу створити
data.table
data.frame
можна було б вирішити однією командою на «прокачування», але треба пам'ятати, що початкове значення змінної буде втрачено.
train_DF # Завантажений data.frame
setDT(train_DF) # Тепер у змінній DF у нас вже міститься data.table

Отже, дані ми завантажили, пора з ними попрацювати. Будемо вважати, що у змінній
DT
вже є завантажений
data.table
. Автори пакету використовують таке позначення основних блоків
DT[i, j, by]
:
  • i — фільтр рядків;
  • j — вибір колонок або виконання вирази над вмістом
    DT
    ;
  • by — блок для групування даних.
Згадаймо найперший приклад, де ми використовували
data.frame
DF
, і на ньому протестуємо різні блоки. Почнемо з створення
data.table
data.frame
:
DT <- data.table(DF) # Зробимо примірник data.table з існуючого data.frame

Блок i — фільтр рядків
Це самий зрозумілий з блоків. Він служить для фільтра рядків
data.table
, і, якщо більше нічого додатково не потрібно, інші блоки можна не вказувати.
DT[a == 2] # Фільтр рядків де a == 2
DT[a == 2 & c > 10] # Фільтр рядків де a == 2 і c > 10

Блок j — вибір колонок або виконання вирази над вмістом data.table
У цьому блоці виконується обробка вмісту
data.table
з відфільтрованими рядками. Ти можеш просто попросити повернути стовпці, вказавши їх у списку
list
. Для зручності введений синонім
list
у вигляді точки (тобто
list (a, b)
еквівалентно
.(a, b)
). Всі існуючі в
data.table
стовпці доступні як «змінні» — тобі не треба працювати з ними як з рядками, і можна користуватися intellisense.
DT[, list(a, c)] # Повертає стовпці «а» і «с» для всіх рядків
DT[, .(a, c)] # Аналогічно попередньому

також Можна вказати додаткові колонки, які хочеш створити, та присвоїти їм необхідні значення:
DT[, .(a, c, ac = a+c)] 

Якщо все це з'єднати, можна виконати першу задачу, яку ми пробували вирішувати різними способами:
DT2 <- DT[a == 2 & c > 10, .(a, c, ac = a + c)]

Вибір колонок — всього лише частина можливостей блоку j. Також там можна міняти існуючий
data.table
. Наприклад, якщо ми хочемо додати нову колонку в існуючому
data.table
, а не в новій копії (як у попередньому прикладі), це можна зробити за допомогою спеціального синтаксису
:=
.
DT2[, ac_mult2 := ac * 2] # Створення всередині DT2 нового стовпця ac_mult2 = ac * 2

З допомогою цього оператора можна видаляти колонки, привласнюючи їм значення
NULL
.
DT2[, ac_mult2 := NULL] # Видалимо всередині DT2 стовпець ac_mult2 

Робота з ресурсами за посиланням здорово економить потужності, і вона набагато швидше, так як ми уникаємо створення копії одних і тих же таблиць з різними колонками. Але треба розуміти, що зміна за посиланням змінює сам об'єкт. Якщо тобі потрібна копія цих даних до іншої змінної, то треба явно вказати, що це окрема копія, а не посилання на той же об'єкт.
Розглянемо приклад:
DT3 <- DT2
DT3[, ac_mult2 := ac * 2] # Створюємо нову колонку

Може здатися, що ми поміняли тільки
DT3
, а
DT2
та
DT3
— це один об'єкт, і, звернувшись до
DT2
, ми побачимо там нову колонку. Це стосується не тільки видалення і створення стовпців, так як
data.table
використовує посилання в тому числі і для сортування. Так що виклик
setorder(DT3, "a")
вплине і на
DT2
.
Для створення копії можна скористатися функцією:
DT3 <- copy(DT2)
DT3[, ac_mult2 := NULL] 

Тепер
DT2
та
DT3
— це різні об'єкти, і ми видалили стовпець у
DT3
.
by — блок для групування даних
Цей блок групує дані на зразок
group_by
з пакету
dplyr
або
GROUP BY
в мові запитів SQL. Логіка звернення до
data.table
з угрупованням наступна:
  1. Блок i фільтрує рядки з повного
    data.table
    .
  2. Блок by групує дані, відфільтровані в блоці i, потрібних полів.
  3. Для кожної групи виконується блок j, який може або вибирати, або оновлювати дані.
Блок заповнюється наступним чином:
by=list(змінні для групування)
, але, як і в блоці j,
list
може бути замінений на точку, тобто
by=list(a, b)
еквівалентно
by=.(a, b)
. Якщо необхідно групувати тільки по одному полю, можна опустити використання списку і написати безпосередньо
by=a
:
DT[,.(max = max©), by=.(a,b)] 
# Для кожного значення «a» і «b» вивести в стовпці «max» максимальний «з» 
DT[,.(max = max©), by=a] 
# Для кожного значення «a» вивести в стовпці «max» максимальний «з»

найчастіша помилка тих, хто вчиться працювати з
data.table
, — це застосування звичних
data.frame
конструкцій до
data.table
. Це дуже хворе місце, і на пошук помилки можна витратити дуже багато часу. Якщо у нас в змінних
DF2
(
data.frame
)
DT2
(
data.table
) знаходяться абсолютно однакові дані, зазначені виклики повернуть абсолютно різні значення:
DF2[1:5,1:2]
## a c
## 1 2 95
## 2 2 94
## 3 2 92
## 4 2 80
## 5 2 65

DT2[1:5,1:2]
## [1] 1 2

Причина цього дуже проста:
  • логіка
    data.frame
    наступна —
    DF2[1:5,1:2]
    означає, що треба взяти перші п'ять рядків і повернути для них значення перших двох колонок;
  • логіка
    data.table
    відрізняється
    DT2[1:5,1:2]
    означає, що треба взяти перші п'ять рядків і передати їх в блок j. Блок j просто поверне
    1
    та
    2
    .
Якщо треба звернутися до
data.table
в форматі
data.frame
, необхідно явно вказати це з допомогою додаткового значення:
DT2[1:5,1:2, with = FALSE]
## a c
## 1: 2 95
## 2: 2 94
## 3: 2 92
## 4: 2 80
## 5: 2 65

Швидкість виконання
Давай переконаємося, що вивчення цього синтаксису має сенс. Повернемося до прикладу з великим файлом CSV. В
train_DF
завантажений
data.frame
, а
train_DT
, відповідно,
data.table
.
У використовуваному прикладі
place_id
є цілим числом великої довжини (
integer64
), але про це «здогадався» тільки
fread
. Інші методи завантажили це поле як число з плаваючою комою, і нам треба буде провести перетворення поля
place_id
train_DF
, щоб порівняти швидкості.
install.packages("bit64") # Пакет для підтримки типу integer64
library(bit64)
train_DF$place_id <- as.integer64(train_DF$place_id)

Припустимо, перед нами поставлено завдання порахувати кількість згадок кожного
place_id
.
dplyr
з звичайним
data.frame
це зайняло 13,751 секунди:
count <- 
train_DF %>% # Вибираємо вміст train_DF
group_by(place_id) %>% # Групуємо за place_id
summarise(length(place_id)) # Вважаємо кількість елементів у групі 

При цьому
data.table
робить те ж саме за 2,578 секунди:
system.time(
count2 <- train_DT[,.(.N), by = place_id] 
# .N - вбудована функція, що показує кількість елементів в групі
)

Ускладнимо задачу — для всіх
place_id
порахуємо кількість, медіану
x
та
y
, а потім упорядкуємо за кількістю в зворотному порядку.
data.frame
c
dplyr
справляються з цим за 27,386 секунди:
system.time(
count <- 
train_DF %>% # Вибираємо train_DF
group_by(place_id) %>% # Групуємо за place_id
summarise(count = length(place_id), # Вважаємо кількість елементів в групі 
mx = median(x), # Вважаємо медіану x в групі 
my = median(y)) %>% # Вважаємо медіану y в групі 
arrange(-count) # Сортуємо у зворотний бік по count
)

data.table
ж впорався набагато швидше — 12,414 секунди:
system.time(
count2 <- train_DT[,.(count=.N, 
mx = median(x), my = median(y)),
by = place_id][order(-count)]
)

Тестові виміри часу виконання простої групування даних за допомогою
dplyr
та
data.table
:
image
Замість висновків
Це все лише поверхневий опис функціональності
data.table
, але його достатньо, щоб почати користуватися цим пакетом. Зараз розвивається пакет
dtplyr
, який позиціонується як реалізація
dplyr
,
data.table
, але поки він ще дуже молодий (версія 0.0.1). У будь-якому випадку розуміння особливостей роботи
data.table
необхідно до того, щоб користуватися додатковими «обгортками».
Про автора
Станіслав Чистяков — експерт з хмарним технологіям і машинного навчання.
WWW від автора
Дуже раджу почитати статті, що входять до складу пакету:
WWW від журналу Хакер
Тема мови R не вперше піднімається в нашому журналі. Підкинемо тобі ще пару лінків на статті по темі:
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.