9  За пределами base R: tidyverse и data.table

Автор

И.С. Поздняков

Как вы уже, наверное, убедились, базовый R умеет очень много, в том числе и для работы с данными. Однако какие-то операции всё равно выполнить довольно непросто.

Возьмем, например, задачу агрегации (aggregation): вам нужно посчитать средний рост супергероев отдельно для мужчин и для женщин (а еще и для NA, за компанию). Три группы еще ничего, а если бы их было 10, 50 или 200? В базовом R для этого есть специальная функция aggregate(), но она довольно неудобная.

Поэтому стали появляться пакеты, которые пытаются сделать агрегацию и другие непростые операции максимально безболезненными. Основных таких пакетов два: {data.table} и {tidyverse}. Это огромные пакеты, которые значительно изменяют работу в R, в том числе в плане стиля и используемой парадигмы. Тем не менее, в основе своей лежит всё то, что мы прошли раньше.

9.1 Подход data.table

{data.table} — это распространенный пакет, который позволяет анализировать датафреймы максимально быстро и с помощью очень лаконичного кода.

install.packages("data.table")

Давайте импортируем наш набор данных про супергероев. Для этого воспользуемся функцией fread() из пакета {data.table}. Она нам уже знакома как функция для импорта больших наборов данных (см. Глава 6.8).

«f» в fread() означает «fast and friendly»: эта функция очень быстрая и довольно хорошо угадывает формат текстовой таблицы.

library(data.table)

Attaching package: 'data.table'
The following object is masked from 'package:base':

    %notin%
heroes_dt <-
  fread(
    "https://raw.githubusercontent.com/Pozdniakov/tidy_stats/master/data/heroes_information.csv",
    na.strings = c("NA", "-", "-99")
  )

Функция fread() создает не просто датафрейм, а дататейбл (data.table):

heroes_dt
        V1            name Gender Eye color              Race       Hair color
     <int>          <char> <char>    <char>            <char>           <char>
  1:     0          A-Bomb   Male    yellow             Human          No Hair
  2:     1      Abe Sapien   Male      blue     Icthyo Sapien          No Hair
  3:     2        Abin Sur   Male      blue           Ungaran          No Hair
  4:     3     Abomination   Male     green Human / Radiation          No Hair
  5:     4         Abraxas   Male      blue     Cosmic Entity            Black
 ---                                                                          
730:   729 Yellowjacket II Female      blue             Human Strawberry Blond
731:   730            Ymir   Male     white       Frost Giant          No Hair
732:   731            Yoda   Male     brown    Yoda's species            White
733:   732         Zatanna Female      blue             Human            Black
734:   733            Zoom   Male       red              <NA>            Brown
     Height         Publisher Skin color Alignment Weight
      <num>            <char>     <char>    <char>  <int>
  1:  203.0     Marvel Comics       <NA>      good    441
  2:  191.0 Dark Horse Comics       blue      good     65
  3:  185.0         DC Comics        red      good     90
  4:  203.0     Marvel Comics       <NA>       bad    441
  5:     NA     Marvel Comics       <NA>       bad     NA
 ---                                                     
730:  165.0     Marvel Comics       <NA>      good     52
731:  304.8     Marvel Comics      white      good     NA
732:   66.0      George Lucas      green      good     17
733:  170.0         DC Comics       <NA>      good     57
734:  185.0         DC Comics       <NA>       bad     81
class(heroes_dt)
[1] "data.table" "data.frame"

Дататейбл — это «улучшенный» датафрейм: с ним работают и все те функции, которые мы применяли для датафрейма, и специальные функции для дататейбла, а кое-что устроено немного по-другому. Например, оператор [, т.е. квадратные скобки.

Давайте посмотрим повнимательнее, как это происходит на примере расчета среднего роста супергероев с группировкой по полу:

heroes_dt[, mean(Height, na.rm = TRUE), by = Gender]
   Gender       V1
   <char>    <num>
1:   Male 191.9749
2: Female 174.6840
3:   <NA> 177.0667

Сразу же усложним задачу: возьмем только хороших (у кого в колонке Alignment стоит "good"), а потом еще отсортируем по среднему росту.

heroes_dt[Alignment == "good", 
          .(mean_height = mean(Height, na.rm = TRUE)), 
          by = Gender][
            order(-mean_height)
          ]
   Gender mean_height
   <char>       <num>
1:   Male    188.9601
2:   <NA>    179.5000
3: Female    174.7607

Уух! Выглядит монструозно, да? Зато как мы ловко всё сделали, используя минимальное количество знаков! Вот что здесь необычного для нас:

  • Не нужно прописывать heroes_dt$Alignment, поиск переменной будет начинаться с колонок дататейбла.

  • Там, где мы раньше выбирали колонки, мы можем сразу вести расчёты.

  • Внутри квадратных скобок появилась вторая запятая, т.е. третье поле, в котором мы прописали группировку.

  • Несколько операций прописываются путем соединения квадратных скобочек, код превращается в эдакий паровозик1.

И это не все отличия!

На сайте пакета {data.table} особое внимание уделяется скорости {data.table}, а в качестве доказательства приводится бенчмарк db-benchmark, где сравниваются по скорости различные инструменты для работы с данными. {data.table} почти на порядок обгоняет как {dplyr}, так и питоновский pandas — самый используемый пакет для анализа данных в Python.

Разработчики {data.table} делают особый акцент на «консервативности» пакета: у него нет никаких зависимостей (в этом плане пакет {data.table} обгоняет большинство российских экспатов в Тбилиси). Ему достаточно очень старой версии R, и функционирование пакета не будет ломаться из-за выкинутых устаревших функций. В общем, {data.table} очень суров и уважаем программистами. Он и не особо пытается понравиться рядовым пользователям. Зато, освоив его, вы сможете творить магию: то, что с помощью базового R, tidyverse или Python будет выполняться очень долго (если выполнится вообще), {data.table} сможет сделать гораздо быстрее, иногда в десятки и сотни раз!

Мощно, не правда ли? Чем же может ответить tidyverse?

9.2 Подход tidyverse

Давайте посмотрим, как будет выглядеть решение тех же задач (отбор строк по условию, агрегация и сортировка) в tidyverse.

install.packages("tidyverse")
library(tidyverse)
── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
✔ dplyr     1.2.1     ✔ readr     2.2.0
✔ forcats   1.0.1     ✔ stringr   1.6.0
✔ ggplot2   4.0.3     ✔ tibble    3.3.1
✔ lubridate 1.9.5     ✔ tidyr     1.3.2
✔ purrr     1.2.2     
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ dplyr::between()     masks data.table::between()
✖ dplyr::filter()      masks stats::filter()
✖ dplyr::first()       masks data.table::first()
✖ lubridate::hour()    masks data.table::hour()
✖ lubridate::isoweek() masks data.table::isoweek()
✖ lubridate::isoyear() masks data.table::isoyear()
✖ dplyr::lag()         masks stats::lag()
✖ dplyr::last()        masks data.table::last()
✖ lubridate::mday()    masks data.table::mday()
✖ lubridate::minute()  masks data.table::minute()
✖ lubridate::month()   masks data.table::month()
✖ lubridate::quarter() masks data.table::quarter()
✖ lubridate::second()  masks data.table::second()
✖ purrr::transpose()   masks data.table::transpose()
✖ lubridate::wday()    masks data.table::wday()
✖ lubridate::week()    masks data.table::week()
✖ lubridate::yday()    masks data.table::yday()
✖ lubridate::year()    masks data.table::year()
ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors

Не пугайтесь сообщений, всё в порядке. Дело в том, что пакет {tidyverse} — это не просто пакет, а «пакет с пакетами» (да-да, как у вас дома), который подключает сразу несколько других пакетов, которые составляют ядро tidyverse. Список и версии этих пакетов {tidyverse} выводит при подключении. Разные пакеты tidyverse мы очень детально разберем позже (см. Глава 10), а сейчас просто посмотрите, как это всё выглядит.

Кроме того, {tidyverse} показывает конфликты функций. Помните, мы обсуждали неприятную ситуацию, которая возникает, если в двух подключенных пакетах совпадают названия функций (Глава 5.5)? {tidyverse} любезно нас предупреждает о подобных конфликтах, а еще и дает подсказку, как использовать функцию из того пакета, что нужен именно нам. Например, вот эта строка означает, что есть функция between() и в пакете {dplyr} (из tidyverse), и в {data.table}:

dplyr::between()     masks data.table::between()

После подключения tidyverse функция between() из {dplyr} будет маскировать одноименную функцию из {data.table}. Если же вам нужна именно функция between() из {data.table}, то вызвать вы ее можете с помощью оператора :::

data.table::between()

Как мы обсуждали в Глава 5.5, чтобы код не зависел от порядка подключения пакетов, к таким функциям лучше всегда обращаться явно через ::.

К счастью, обычно замаскированных функций немного: хотя у tidyverse и {data.table} много функций с одинаковыми названиями, на практике их подключают вместе нечасто. Тем не менее, есть две функции, которые всегда будут выдавать конфликты:

dplyr::filter()      masks stats::filter()
dplyr::lag()         masks stats::lag()

Это связано с тем, что в tidyverse есть две функции, которые совпадают названиями со встроенными функциями в R2.

Ок, пакет мы подключили. Ну а теперь импортируем все те же данные про супергероев, используя инструменты tidyverse:

heroes_tbl <- read_csv("https://raw.githubusercontent.com/Pozdniakov/tidy_stats/master/data/heroes_information.csv",
    na = c("NA", "-", "-99"))

Функция read_csv() (не путать с функцией из базового R — read.csv()!) возвращает тиббл (tibble) — «улучшенный» датафрейм, примерно как это было с дататейблом.

heroes_tbl
# A tibble: 734 × 11
    ...1 name          Gender `Eye color` Race     `Hair color` Height Publisher
   <dbl> <chr>         <chr>  <chr>       <chr>    <chr>         <dbl> <chr>    
 1     0 A-Bomb        Male   yellow      Human    No Hair         203 Marvel C…
 2     1 Abe Sapien    Male   blue        Icthyo … No Hair         191 Dark Hor…
 3     2 Abin Sur      Male   blue        Ungaran  No Hair         185 DC Comics
 4     3 Abomination   Male   green       Human /… No Hair         203 Marvel C…
 5     4 Abraxas       Male   blue        Cosmic … Black            NA Marvel C…
 6     5 Absorbing Man Male   blue        Human    No Hair         193 Marvel C…
 7     6 Adam Monroe   Male   blue        <NA>     Blond            NA NBC - He…
 8     7 Adam Strange  Male   blue        Human    Blond           185 DC Comics
 9     8 Agent 13      Female blue        <NA>     Blond           173 Marvel C…
10     9 Agent Bob     Male   brown       Human    Brown           178 Marvel C…
# ℹ 724 more rows
# ℹ 3 more variables: `Skin color` <chr>, Alignment <chr>, Weight <dbl>
class(heroes_tbl)
[1] "spec_tbl_df" "tbl_df"      "tbl"         "data.frame" 

Теперь же сделаем то же самое с нашими данными, что мы делали с помощью {data.table}:

heroes_tbl %>%
  filter(Alignment == "good") %>%
  group_by(Gender) %>%
  summarise(mean_height = mean(Height, na.rm = TRUE)) %>%
  arrange(desc(mean_height))
# A tibble: 3 × 2
  Gender mean_height
  <chr>        <dbl>
1 Male          189.
2 <NA>          180.
3 Female        175.

Это очень сильно отличается от того, как мы работали раньше! Хотя в основе лежит всё тот же R. Код, написанный в tidyverse, нарочито многословен (особенно по сравнению с {data.table}): каждая отдельная операция имеет свою функцию. Писать нужно больше, зато это гораздо легче: меньше нужно думать, какими хитрыми трюками сделать преобразование данных. Нужно просто разделить весь процесс на отдельные операции и последовательно прописать их. Код получается аккуратный и очень читаемый, даже для человека, который не знает tidyverse или даже R в целом. И этот новый оператор %>% выглядит довольно понятно: его можно прочитать как «затем».

Заметьте, что tidyverse выводит очень подробные сообщения, которые даже выглядят очень красиво: со всякими иконками, красивым форматированием. Разработчики tidyverse работают над тем, чтобы делать свой интерфейс максимально понятным для пользователя: говорящие сами за себя названия функций, куча удобных фишек на все случаи жизни.

tidyverse постоянно обновляется, регулярно появляются новые функции, а старые заменяются более удобными. И это не всегда плюс: обновив пакеты, установленные год назад, вы можете обнаружить, что старый код перестал работать! Мол, мы тут придумали, как сделать лучше, переписывайте код заново (или используйте старые версии пакетов).

Разработчики tidyverse в целом не гонятся за высокой скоростью выполнения кода. Часто можно заметить, что новые функции работают довольно медленно. Но если у вас строчек меньше миллиона, то разницу в скорости с {data.table} вы едва ли заметите.

Команда разработчиков tidyverse работает на компанию Posit (бывшая RStudio). Поэтому в RStudio вы найдете несколько «шпаргалок» для tidyverse, но не для {data.table}. Они также активно работают над популяризацией tidyverse, стараясь сделать вход в него максимально комфортным, особенно для людей без опыта программирования.

9.3 data.table vs tidyverse

Так что же лучше: {data.table} или tidyverse? Это один из самых частых споров в R-комьюнити. У обоих подходов есть плюсы, которые можно обсуждать вечно. Сегодня tidyverse выигрывает в популярности, особенно за пределами русскоязычного пространства.

В последнее время {data.table} и tidyverse всё меньше противостоят друг другу и всё больше взаимодополняют. Например, некоторые используют в качестве основного инструмента tidyverse, но при работе с данными побольше переключаются на {data.table}3. Кроме того, сами разработчики tidyverse пытаются приладить суперскоростной {data.table} к tidyverse: пакет {dtplyr} позволяет «переводить» код, написанный в tidyverse, в код на {data.table}.

Таким образом, выбирая из tidyverse и {data.table}, начинать лучше с более удобного и популярного tidyverse, чем и займемся далее.


  1. Это работает и в базовом R, но именно в {data.table} это очень частая конструкция.↩︎

  2. Эти функции используются относительно редко, и Хэдли Уикхэм (создатель {dplyr}) в докладе «Tidyverse: the greatest hits» (LondonR, 2019) отнёс перекрытие filter() и lag() к «неисправимым ошибкам» tidyverse (Chan, 2019).↩︎

  3. Автор книги поступает именно так =)↩︎