Skip to contents

Данное руководство также доступно на следующих языках: en | fr | ru

Описание пакета data.table: его синтаксис, содержание и функции; выбор подмножества (subset) строк, выбор столбцов и проведение на них арифметических действий (select and compute), объединение в группы по определенному признаку. Опыт использования структуры данных data.frame из базового R будет полезен, но не необходим для понимания описания.


Анализ данных с использованием data.table

Действия манипуляции данных (subset, group, update, join и т.д.) связаны между собой. Объединение этих родственных действий позволяет:

  • использовать краткий и последовательный синтакс независимо от набора действий, необходимых для достижения конечной цели.

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

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

Вкратце, этот пакет для вас, если вы заинтересованы в радикальном сокращении времени, затрачиваемого на написание кода и обсчет данных. Принципы data.table это позволяют, что мы и стремимся продемонстрировать в данном описании.

Данные

В этом документе мы используем данные NYC-flights14 из пакета flights (доступен только на GitHub). Он содержит данные о своевременности рейсов от Бюро транспортной статистики для всех рейсов, вылетевших из аэропортов Нью-Йорка в 2014 году (вдохновлено nycflights13). Данные доступны только за период с января по октябрь 2014 года.

Мы можем использовать функцию чтения файлов fread из data.table, чтобы загрузить рейсы (flights) следующим образом:

input <- if (file.exists("../flights14.csv")) {
   "../flights14.csv"
} else {
  "https://raw.githubusercontent.com/Rdatatable/data.table/master/vignettes/flights14.csv"
}
flights <- fread(input)
flights
#          year month   day dep_delay arr_delay carrier origin   dest air_time distance  hour
#         <int> <int> <int>     <int>     <int>  <char> <char> <char>    <int>    <int> <int>
#      1:  2014     1     1        14        13      AA    JFK    LAX      359     2475     9
#      2:  2014     1     1        -3        13      AA    JFK    LAX      363     2475    11
#      3:  2014     1     1         2         9      AA    JFK    LAX      351     2475    19
#      4:  2014     1     1        -8       -26      AA    LGA    PBI      157     1035     7
#      5:  2014     1     1         2         1      AA    JFK    LAX      350     2475    13
#     ---                                                                                    
# 253312:  2014    10    31         1       -30      UA    LGA    IAH      201     1416    14
# 253313:  2014    10    31        -5       -14      UA    EWR    IAH      189     1400     8
# 253314:  2014    10    31        -8        16      MQ    LGA    RDU       83      431    11
# 253315:  2014    10    31        -4        15      MQ    LGA    DTW       75      502    11
# 253316:  2014    10    31        -5         1      MQ    LGA    SDF      110      659     8
dim(flights)
# [1] 253316     11

Примечание: fread напрямую поддерживает URL-адреса с http и https, а также команды операционной системы, такие как вывод sed и awk. Примеры можно найти в справке (?fread).

Введение

В этом описании мы

  1. Начнем с основ - что такое data.table, его общая структура, как выбирать строки, как выбирать и вычислять значения по столбцам;

  2. Затем мы рассмотрим выполнение агрегации данных по группам.

1. Основы

a) Что такое data.table?

data.table — это пакет R, который предоставляет расширенную версию data.frame, стандартной структуры данных для хранения данных в базовом (base) R. В разделе Данные выше мы увидели, как создать data.table с помощью функции fread(), но также можно создать его, используя функцию data.table(). Вот пример:

DT = data.table(
  ID = c("b","b","b","a","a","c"),
  a = 1:6,
  b = 7:12,
  c = 13:18
)
DT
#        ID     a     b     c
#    <char> <int> <int> <int>
# 1:      b     1     7    13
# 2:      b     2     8    14
# 3:      b     3     9    15
# 4:      a     4    10    16
# 5:      a     5    11    17
# 6:      c     6    12    18
class(DT$ID)
# [1] "character"

Вы также можете преобразовать существующие объекты в data.table с помощью setDT() (для структур data.frame и list) или as.data.table() (для других структур). Для получения более подробной информации о различиях (выходит за рамки этого руководства) обратитесь к ?setDT и ?as.data.table.

Примечания:

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

  • Когда количество строк для вывода превышает глобальную опцию datatable.print.nrows (по умолчанию r getOption("datatable.print.nrows")), автоматически выводятся только первые 5 и последние 5 строк (как видно в разделе Данные). Для большого data.frame можно заметить, что приходится очень долго ждать, пока вся таблица будет напечатана. Это ограничение помогает справиться с данной проблемой, и вы можете узнать число выводимых строк следующим образом:

    getOption("datatable.print.nrows")
  • data.table никогда не устанавливает и не использует имена строк. Мы увидим, почему это так, в руководстве vignette("datatable-keys-fast-subset", package="data.table").

b) Общая структура - в чем заключается усовершенствование data.table?

По сравнению с data.frame, с использованием оператора [ ... ] в рамках data.table можно делать гораздо больше, чем просто выбирать строки и столбцы (примечание: мы также можем называть запись внутри DT[...] «запросом к DT», по аналогии с SQL). Для понимания этого сначала нужно рассмотреть общую форму синтаксиса data.table, как показано ниже:

DT[i, j, by]

##   R:                 i                 j        by
## SQL:  where | order by   select | update  group by

Для пользователей с опытом работы в SQL этот синтаксис может выглядеть знакомо.

Прочитать это (вслух) можно так:

Взять DT, выбрать/переставить строки, используя i, затем вычислить j, сгруппировав по by.

Начнем с рассмотрения i и j — выбора строк и операций со столбцами.

в) Выбор строк в i

– Вывести все рейсы, вылетевшие из аэропорта “JFK” в июне.

ans <- flights[origin == "JFK" & month == 6L]
head(ans)
#     year month   day dep_delay arr_delay carrier origin   dest air_time distance  hour
#    <int> <int> <int>     <int>     <int>  <char> <char> <char>    <int>    <int> <int>
# 1:  2014     6     1        -9        -5      AA    JFK    LAX      324     2475     8
# 2:  2014     6     1       -10       -13      AA    JFK    LAX      329     2475    12
# 3:  2014     6     1        18        -1      AA    JFK    LAX      326     2475     7
# 4:  2014     6     1        -6       -16      AA    JFK    LAX      320     2475    10
# 5:  2014     6     1        -4       -45      AA    JFK    LAX      326     2475    18
# 6:  2014     6     1        -6       -23      AA    JFK    LAX      329     2475    14
  • В рамках data.table к столбцам можно обращаться, как если бы они были переменными, как в SQL или Stata. Поэтому мы просто ссылаемся на origin и month, как на переменные. Не нужно добавлять префикс flights$ каждый раз. Тем не менее, flights$origin и flights$month также будет работать без проблем.

  • Вычисляются индексы строк, удовлетворяющие условию origin == "JFK" & month == 6L, и, поскольку больше ничего не требуется делать, все столбцы из flights в строках, соответствующих этим индексам строк, просто возвращаются как data.table.

  • Запятая после условия в i не требуется, тем не менее, flights[origin == "JFK" & month == 6L, ] тоже будет работать без проблем. В data.frame, однако, запятая обязательна.

– Вывести первые две строки из flights.

ans <- flights[1:2]
ans
#     year month   day dep_delay arr_delay carrier origin   dest air_time distance  hour
#    <int> <int> <int>     <int>     <int>  <char> <char> <char>    <int>    <int> <int>
# 1:  2014     1     1        14        13      AA    JFK    LAX      359     2475     9
# 2:  2014     1     1        -3        13      AA    JFK    LAX      363     2475    11
  • В этом случае условия нет. Индексы строк уже указаны в i. Поэтому возвращается data.table со всеми столбцами из flights в строках, соответствующих этим индексам строк.

– Отсортировать flights по столбцу origin в возрастающем порядке, а затем по столбцу dest в убывающем порядке:

Для этого мы можем использовать функцию R order().

ans <- flights[order(origin, -dest)]
head(ans)
#     year month   day dep_delay arr_delay carrier origin   dest air_time distance  hour
#    <int> <int> <int>     <int>     <int>  <char> <char> <char>    <int>    <int> <int>
# 1:  2014     1     5         6        49      EV    EWR    XNA      195     1131     8
# 2:  2014     1     6         7        13      EV    EWR    XNA      190     1131     8
# 3:  2014     1     7        -6       -13      EV    EWR    XNA      179     1131     8
# 4:  2014     1     8        -7       -12      EV    EWR    XNA      184     1131     8
# 5:  2014     1     9        16         7      EV    EWR    XNA      181     1131     8
# 6:  2014     1    13        66        66      EV    EWR    XNA      188     1131     9

order() оптимизировано внутри функции

  • Мы можем использовать “-” для столбцов типа character в рамках data.table для сортировки в порядке убывания.

  • Кроме того, order(...) в рамках data.table использует внутреннюю быструю сортировку forder(). Эта сортировка показала настолько значительное улучшение по сравнению с base::order в R, что проект R принял алгоритм data.table как стандартный способ сортировки начиная с версии R 3.3.0 в 2016 году (для справки см. ?sort и R Release NEWS).

Мы более подробно обсудим быструю сортировку data.table в руководстве data.table internals.

d) Выбрать столбец/столбцы в j

– Выбрать столбец arr_delay, но вывести его как вектор.

ans <- flights[, arr_delay]
head(ans)
# [1]  13  13   9 -26   1   0
  • Поскольку к столбцам можно обращаться как к переменным в рамках data.table, мы напрямую ссылаемся на переменную, по которой хотим выбрать строки. Поскольку нам нужны все строки, мы просто пропускаем i.

  • Это возвращает все строки из столбца arr_delay.

– Выбрать столбец arr_delay и вернуть его в виде data.table.

ans <- flights[, list(arr_delay)]
head(ans)
#    arr_delay
#        <int>
# 1:        13
# 2:        13
# 3:         9
# 4:       -26
# 5:         1
# 6:         0
  • Мы оборачиваем переменные (имена столбцов) в list(), что позволяет получить в ответ объект data.table. При выборе одного столбца, не обёрнутого в list(), будет возвращён один вектор, как в предыдущем примере.

  • data.table также позволяет заворачивать столбцы в .() вместо list(). Это синоним для list(): эти конструкции значат одно и то же. Используйте ту, которая кажется более удобной; далее мы будем использовать .() для краткости.

data.table (как и data.frame) также является списком (list) с условием, что у него есть атрибут class, а каждый его элемент имеет одинаковую длину. Возможность возвращать list с помощью j позволяет эффективно преобразовывать и возвращать data.table.

Подсказка:

Пока выражение j возвращает список (list), каждый элемент списка будет преобразован в столбец в результирующем data.table. Это делает j очень мощным инструментом, как мы скоро увидим. Также это очень важно понимать, когда вы захотите составлять более сложные запросы.

– Выбрать оба столбца: arr_delay и dep_delay.

ans <- flights[, .(arr_delay, dep_delay)]
head(ans)
#    arr_delay dep_delay
#        <int>     <int>
# 1:        13        14
# 2:        13        -3
# 3:         9         2
# 4:       -26        -8
# 5:         1         2
# 6:         0         4

## другой вариант
# ans <- flights[, list(arr_delay, dep_delay)]
  • Оберните оба столбца в .() или list(). Этого достаточно.

– Выбрать оба столбца: arr_delay и dep_delay, и переименовать их в delay_arr и delay_dep.

Поскольку .() — это просто псевдоним для list(), мы можем присваивать имена столбцам так же, как при создании list.

ans <- flights[, .(delay_arr = arr_delay, delay_dep = dep_delay)]
head(ans)
#    delay_arr delay_dep
#        <int>     <int>
# 1:        13        14
# 2:        13        -3
# 3:         9         2
# 4:       -26        -8
# 5:         1         2
# 6:         0         4

е) Вычисление или выполнение в j

– Сколько рейсов имели общую задержку < 0?

ans <- flights[, sum( (arr_delay + dep_delay) < 0 )]
ans
# [1] 141814

Что здесь происходит?

  • j в data.table может выполнять не только выбор столбцов, но и выражения, то есть вычисления на основе столбцов. Это неудивительно, так как столбцы можно рассматривать как переменные. Следовательно, мы можем вычислять значения, вызывая функции для этих переменных. Именно это мы здесь и делаем.

ж) Выбор в i и выполнение в j

– Рассчитать среднее время задержки прибытия и отправления для всех рейсов, вылетевших из аэропорта “JFK” в июне.

ans <- flights[origin == "JFK" & month == 6L,
               .(m_arr = mean(arr_delay), m_dep = mean(dep_delay))]
ans
#       m_arr    m_dep
#       <num>    <num>
# 1: 5.839349 9.807884
  • Сначала мы выбираем строки в i, чтобы найти соответствующие индексы строк, в которых аэропорт origin равен "JFK", а month равен 6L. Мы пока не выбираем целый data.table, соответствующий этим строкам.

  • Теперь мы видим, что в j используется только два столбца. Нам нужно вычислить их mean(). Поэтому мы выбираем только те столбцы, которые соотносятся с соответствующими строками, и вычисляем их mean().

Поскольку три основные компонента запроса (i, j и by) находятся вместе внутри [...], data.table видит все три и может оптимизировать запрос целиком до выполнения, а не оптимизировать каждый компонент отдельно. Таким образом, мы можем избежать выбора всего набора данных (то есть, выбора столбцов кроме arr_delay и dep_delay), что повышает как скорость, так и эффективность использования памяти.

– Сколько рейсов было вылетело в 2014 году из аэропорта “JFK” в июне?

ans <- flights[origin == "JFK" & month == 6L, length(dest)]
ans
# [1] 8422

Функция length() требует аргумента. Нам нужно вычислить количество строк в выбранном подмножестве. Мы могли бы использовать любой другой столбец в качестве аргумента для length(). Этот подход напоминает SELECT COUNT(dest) FROM flights WHERE origin = 'JFK' AND month = 6 в SQL.

Этот тип действий встречается довольно часто, особенно при группировке (как мы увидим в следующем разделе), и поэтому data.table предоставляет специальный символ .N для этого.

з) Обработка несуществующих элементов в i

– Что происходит при запросе несуществующих элементов?

При запросе data.table для элементов, которые не существуют, поведение зависит от используемого метода.

setkeyv(flights, "origin")
  • Выбор на основе ключа: dt["d"]

    Это выполняет правое соединение по ключевому столбцу x, в результате чего получается строка с d и NA для столбцов, которые не найдены. При использовании setkeyv таблица сортируется по указанным ключам и создается внутренний индекс, позволяющий выполнять бинарный поиск для эффективного выбора подмножеств.

    flights["XYZ"]
    # Возвращает:
    #    origin year month day dep_time sched_dep_time dep_delay arr_time sched_arr_time arr_delay carrier flight tailnum ...
    # 1:    XYZ   NA    NA  NA       NA             NA        NA       NA             NA        NA      NA     NA      NA ...
  • Логический выбор: dt[x == "d"]

    Это выполняет стандартную операцию выбора, которая не находит совпадающих строк и, следовательно, возвращает пустой data.table.

      flights[origin == "XYZ"]
    # Возвращает:
    # Empty data.table (0 rows and 19 cols): year,month,day,dep_time,sched_dep_time,dep_delay,arr_time,sched_arr_time,arr_delay,...
  • Точное соответствие с использованием nomatch=NULL

    Для точного соответствия без NA для несуществующих элементов используйте nomatch=NULL:

    flights["XYZ", nomatch=NULL]
    # Возвращает:
    # Empty data.table (0 rows and 19 cols): year,month,day,dep_time,sched_dep_time,dep_delay,arr_time,sched_arr_time,arr_delay,...

Понимание этих особенностей поможет избежать путаницы при работе с несуществующими элементами в ваших данных.

Специальный символ .N:

.N — это специальная встроенная переменная, которая содержит количество наблюдений в текущей группе. Она особенно полезна при использовании с by, как мы увидим в следующем разделе. В отсутствие операций группировки она просто возвращает количество строк в выбранном подмножестве.

Теперь, когда мы знаем это, мы можем выполнить ту же задачу, используя .N, следующим образом:

ans <- flights[origin == "JFK" & month == 6L, .N]
ans
# [1] 8422
  • Снова выбираем строки в i, чтобы получить индексы строк, где аэропорт origin равен “JFK”, а month равен 6.

  • Мы видим, что в j используется только .N и никакие другие столбцы. Поэтому подмножество не отображается целиком. Мы просто возвращаем количество строк в подмножестве (что эквивалентно длине индексов строк).

  • Обратите внимание, что мы не оборачивали .N в list() или .(). Поэтому возвращается вектор.

Мы могли бы выполнить то же действие, используя nrow(flights[origin == "JFK" & month == 6L]). Однако сначала нужно было бы выбрать весь data.table, соответствующий индексам строк в i, а затем возвращать количество строк с помощью nrow(), что является ненужным и неэффективным. Мы подробно рассмотрим это и другие аспекты оптимизации в руководстве дизайн data.table.

и) Отлично! Но как я могу ссылаться на столбцы по именам в j (как в data.frame)?

Если вы явно указываете имена столбцов, разницы по сравнению с data.frame нет (начиная с версии 1.9.8).

– Выбрать оба столбца: arr_delay и dep_delay способом data.frame.

ans <- flights[, c("arr_delay", "dep_delay")]
head(ans)
#    arr_delay dep_delay
#        <int>     <int>
# 1:        13        14
# 2:        13        -3
# 3:         9         2
# 4:       -26        -8
# 5:         1         2
# 6:         0         4

Если вы сохранили нужные столбцы в векторе символов, есть два варианта: использовать префикс .. или использовать аргумент with.

– Выбрать столбцы по именам из переменной, используя префикс ..

select_cols = c("arr_delay", "dep_delay")
flights[ , ..select_cols]
#         arr_delay dep_delay
#             <int>     <int>
#      1:        13        14
#      2:        13        -3
#      3:         9         2
#      4:       -26        -8
#      5:         1         2
#     ---                    
# 253312:       -30         1
# 253313:       -14        -5
# 253314:        16        -8
# 253315:        15        -4
# 253316:         1        -5

Знакомым с терминалом Unix префикс .. напоминает команду “на уровень выше”, что аналогично происходящему здесь — .. указывает data.table искать переменную select_cols “на уровне выше”, то есть, в данном случае, в глобальном пространстве переменных.

– Выбрать столбцы по именам из переменной, используя with = FALSE

flights[ , select_cols, with = FALSE]
#         arr_delay dep_delay
#             <int>     <int>
#      1:        13        14
#      2:        13        -3
#      3:         9         2
#      4:       -26        -8
#      5:         1         2
#     ---                    
# 253312:       -30         1
# 253313:       -14        -5
# 253314:        16        -8
# 253315:        15        -4
# 253316:         1        -5

Аргумент называется with, как и функция R with(), из-за схожей функциональности. Допустим, у вас есть data.frame DF, и вы хотите выбрать все строки, где x > 1. В base R вы можете сделать следующее:

DF = data.frame(x = c(1,1,1,2,2,3,3,3), y = 1:8)

## (1) обычный способ
DF[DF$x > 1, ] # для data.frame эта запятая также понадобится
#   x y
# 4 2 4
# 5 2 5
# 6 3 6
# 7 3 7
# 8 3 8

## (2) с использованием with()
DF[with(DF, x > 1), ]
#   x y
# 4 2 4
# 5 2 5
# 6 3 6
# 7 3 7
# 8 3 8
  • Использование with() в (2) позволяет обращаться к столбцу x в DF, как если бы это была переменная.

    Поэтому аргумент называется with в data.table. Установка with = FALSE отключает возможность ссылаться на столбцы как на переменные, возвращая режим “data.frame”.

  • Мы также можем исключить столбцы, используя - или !. Например:

    ## этот код не выполняется
    
    # вернуть все столбцы, кроме arr_delay и dep_delay
    ans <- flights[, !c("arr_delay", "dep_delay")]
    # либо
    ans <- flights[, -c("arr_delay", "dep_delay")]
  • Начиная с версии v1.9.5+, мы также можем выбирать столбцы, указывая начальное и конечное имена столбцов, например, year:day для выбора первых трёх столбцов.

    ## этот код не выполняется
    
    # вернуть year, month, day
    ans <- flights[, year:day]
    # вернуть day, month, year
    ans <- flights[, day:year]
    # вернуть все столбцы, кроме year, month, day
    ans <- flights[, -(year:day)]
    ans <- flights[, !(year:day)]

    Это особенно удобно при работе в интерактивном режиме.

with = TRUE в data.table является значением по умолчанию, поскольку это позволяет j обрабатывать выражения — особенно в сочетании с by, как мы вскоре увидим.

2. Объединения

Мы уже рассмотрели i и j в общем виде data.table в предыдущем разделе. В этом разделе мы увидим, как их можно объединить с by, чтобы выполнять действия по группам. Давайте рассмотрим несколько примеров.

а) Группировка с использованием by

– Как узнать количество рейсов, соответствующих каждому аэропорту отправления?

ans <- flights[, .(.N), by = .(origin)]
ans
#    origin     N
#    <char> <int>
# 1:    JFK 81483
# 2:    LGA 84433
# 3:    EWR 87400

## либо со строковым вектором в 'by'
# ans <- flights[, .(.N), by = "origin"]
  • Нам известно, что .N является специальной переменной, которая содержит количество строк в текущей группе. Группировка по origin позволяет получить количество строк, .N, для каждой группы.

  • Выполнив head(flights), вы увидите, что аэропорты отправления упорядочены как “JFK”, “LGA”, и “EWR”. Исходный порядок переменных группировки сохраняется. Это важно помнить.

  • Поскольку мы не указали имя для столбца, возвращаемого в j, он был автоматически назван N, поскольку был распознан специальный символ .N.

  • by также принимает вектор символов с именами столбцов. Это особенно полезно для программного кодирования, например, при создании функции с колонками группировки (в виде вектора символов) в качестве аргумента функции.

  • Когда есть только один столбец или выражение, на которое нужно ссылаться в j и by, мы можем опустить обозначение .(). Это сделано только для удобства. Вместо этого можно сделать:

    ans <- flights[, .N, by = origin]
    ans
    #    origin     N
    #    <char> <int>
    # 1:    JFK 81483
    # 2:    LGA 84433
    # 3:    EWR 87400

    Мы будем использовать эту более удобную форму и в дальнейших случаях, где она будет применима.

– Как рассчитать количество рейсов для каждого аэропорта отправления для кода перевозчика "AA"?

Уникальный код перевозчика "AA" соответствует American Airlines Inc.

ans <- flights[carrier == "AA", .N, by = origin]
ans
#    origin     N
#    <char> <int>
# 1:    JFK 11923
# 2:    LGA 11730
# 3:    EWR  2649
  • Сначала мы получаем индексы строк для выражения carrier == "AA" из i.

  • Используя эти индексы строк, мы получаем количество строк при группировке по origin. Опять же, никакие столбцы здесь не отображаются, поскольку j-выражение не требует фактической выборки столбцов, что делает данное действие быстрым и экономит память.

– Как получить общее количество рейсов для каждой пары origin, dest для кода перевозчика "AA"?

ans <- flights[carrier == "AA", .N, by = .(origin, dest)]
head(ans)
#    origin   dest     N
#    <char> <char> <int>
# 1:    JFK    LAX  3387
# 2:    LGA    PBI   245
# 3:    EWR    LAX    62
# 4:    JFK    MIA  1876
# 5:    JFK    SEA   298
# 6:    EWR    MIA   848

## или, как вариант, с использованием вектора символов в `by`
# ans <- flights[carrier == "AA", .N, by = c("origin", "dest")]
  • by может принимать несколько столбцов. Мы просто указываем все столбцы, по которым нужно сгруппировать данные. Обратите внимание на использование .() в by — это всего лишь сокращение для list(), и вместо него здесь также можно использовать list(). В этом руководстве мы будем придерживаться .().

– Как получить среднюю задержку прибытия и отправления для каждой пары origin, dest для каждого месяца для кода перевозчика "AA"?

ans <- flights[carrier == "AA",
        .(mean(arr_delay), mean(dep_delay)),
        by = .(origin, dest, month)]
ans
#      origin   dest month         V1         V2
#      <char> <char> <int>      <num>      <num>
#   1:    JFK    LAX     1   6.590361 14.2289157
#   2:    LGA    PBI     1  -7.758621  0.3103448
#   3:    EWR    LAX     1   1.366667  7.5000000
#   4:    JFK    MIA     1  15.720670 18.7430168
#   5:    JFK    SEA     1  14.357143 30.7500000
#  ---                                          
# 196:    LGA    MIA    10  -6.251799 -1.4208633
# 197:    JFK    MIA    10  -1.880184  6.6774194
# 198:    EWR    PHX    10  -3.032258 -4.2903226
# 199:    JFK    MCO    10 -10.048387 -1.6129032
# 200:    JFK    DCA    10  16.483871 15.5161290
  • Поскольку мы не задали имена для столбцов в выражениях j, они были автоматически сгенерированы как V1 и V2.

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

Что если мы захотим отсортировать результат по столбцам группировки origin, dest и month?

b) Сортировка по by: keyby

data.table намеренно сохраняет исходный порядок групп. В некоторых случаях сохранение исходного порядка является важным. Однако иногда нам необходимо автоматически отсортировать данные по переменным, которые используются для группировки.

– Тогда как же мы можем отсортировать данные по всем переменным группировки напрямую?

ans <- flights[carrier == "AA",
        .(mean(arr_delay), mean(dep_delay)),
        keyby = .(origin, dest, month)]
ans
# Key: <origin, dest, month>
#      origin   dest month         V1         V2
#      <char> <char> <int>      <num>      <num>
#   1:    EWR    DFW     1   6.427673 10.0125786
#   2:    EWR    DFW     2  10.536765 11.3455882
#   3:    EWR    DFW     3  12.865031  8.0797546
#   4:    EWR    DFW     4  17.792683 12.9207317
#   5:    EWR    DFW     5  18.487805 18.6829268
#  ---                                          
# 196:    LGA    PBI     1  -7.758621  0.3103448
# 197:    LGA    PBI     2  -7.865385  2.4038462
# 198:    LGA    PBI     3  -5.754098  3.0327869
# 199:    LGA    PBI     4 -13.966667 -4.7333333
# 200:    LGA    PBI     5 -10.357143 -6.8571429
  • Все, что мы сделали, это заменили by на keyby. Это автоматически сортирует результат по переменным группировки в порядке возрастания. На самом деле, из-за внутренней реализации by, которая сначала требует сортировки перед восстановлением исходного порядка таблицы, keyby обычно работает быстрее, так как не требует этого второго шага.

Ключи сортировки: На самом деле keyby делает немного больше, чем просто сортировка. Он также устанавливает ключ после сортировки, добавляя атрибут с названием sorted.

Мы подробнее рассмотрим keys в руководстве vignette("datatable-keys-fast-subset", package="data.table"). Пока что вам нужно знать, что вы можете использовать keyby, чтобы автоматически отсортировать результат по столбцам, указанным в by.

c) Цепочки вызовов

Давайте ещё раз рассмотрим задачу получения общего количества рейсов для каждой пары origin, dest для перевозчика “AA”.

ans <- flights[carrier == "AA", .N, by = .(origin, dest)]

– Как мы можем отсортировать ans по столбцу origin в порядке возрастания и по столбцу dest в порядке убывания?

Мы можем сохранить промежуточный результат в переменной, а затем использовать order(origin, -dest) для этой переменной. Это выглядит довольно просто.

ans <- ans[order(origin, -dest)]
head(ans)
#    origin   dest     N
#    <char> <char> <int>
# 1:    EWR    PHX   121
# 2:    EWR    MIA   848
# 3:    EWR    LAX    62
# 4:    EWR    DFW  1618
# 5:    JFK    STT   229
# 6:    JFK    SJU   690
  • Вспомним, что мы можем использовать - для столбца character в функции order() в рамках data.table. Это возможно благодаря внутренней оптимизации запросов в data.table.

  • Также вспомним, что order(...) в рамках data.table автоматически оптимизируется для использования внутреннего быстрого алгоритма сортировки forder() для повышения скорости.

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

ans <- flights[carrier == "AA", .N, by = .(origin, dest)][order(origin, -dest)]
head(ans, 10)
#     origin   dest     N
#     <char> <char> <int>
#  1:    EWR    PHX   121
#  2:    EWR    MIA   848
#  3:    EWR    LAX    62
#  4:    EWR    DFW  1618
#  5:    JFK    STT   229
#  6:    JFK    SJU   690
#  7:    JFK    SFO  1312
#  8:    JFK    SEA   298
#  9:    JFK    SAN   299
# 10:    JFK    ORD   432
  • Мы можем соединять выражения одно за другим, формируя цепочку действий, то есть DT[ ... ][ ... ][ ... ].

  • Либо вы можете также соединять их вертикально:

    DT[ ...
       ][ ...
         ][ ...
           ]

d) Выражения в by

– Может ли by принимать выражения, или он принимает только столбцы?

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

ans <- flights[, .N, .(dep_delay>0, arr_delay>0)]
ans
#    dep_delay arr_delay      N
#       <lgcl>    <lgcl>  <int>
# 1:      TRUE      TRUE  72836
# 2:     FALSE      TRUE  34583
# 3:     FALSE     FALSE 119304
# 4:      TRUE     FALSE  26593
  • Последняя строка соответствует dep_delay > 0 = TRUE и arr_delay > 0 = FALSE. Мы видим, что 26593 рейсы вылетели с опозданием, но прибыли раньше (или вовремя).

  • Обратите внимание, что мы не задали имена для by-expression. Поэтому имена были присвоены автоматически. Как и в случае с j, вы можете назвать эти выражения так же, как и для элементов любого list, например, DT[, .N, .(dep_delayed = dep_delay>0, arr_delayed = arr_delay>0)].

  • Вы можете предоставить другие столбцы вместе с выражениями, например: DT[, .N, by = .(a, b>0)].

e) Множественные столбцы в j - .SD

– Нужно ли вычислять mean() для каждого столбца по отдельности?

Конечно, неудобно набирать mean(myCol) для каждого столбца по отдельности. Что если у вас 100 столбцов, для которых нужно вычислить mean()?

Как сделать это эффективно и лаконично? Для этого вспомните этот совет - “Если выражение в j возвращает list, каждый элемент списка будет преобразован в столбец в результирующем data.table. Если мы можем ссылаться на подмножество данных для каждой группы как на переменную во время группировки, мы можем использовать уже знакомую базовую функцию lapply() для обработки всех столбцов этой переменной. Никаких новых названий, специфичных для data.table, учить не нужно.

Специальный символ .SD:

data.table предоставляет специальный символ .SD, который означает подмножество данных (Subset of Data). Это data.table, который содержит данные для текущей группы, определенной с помощью by.

Помните, что data.table внутренне является list, в котором все столбцы имеют одинаковую длину.

Давайте используем data.table DT из предыдущего примера, чтобы увидеть, как выглядит .SD.

DT
#        ID     a     b     c
#    <char> <int> <int> <int>
# 1:      b     1     7    13
# 2:      b     2     8    14
# 3:      b     3     9    15
# 4:      a     4    10    16
# 5:      a     5    11    17
# 6:      c     6    12    18

DT[, print(.SD), by = ID]
#        a     b     c
#    <int> <int> <int>
# 1:     1     7    13
# 2:     2     8    14
# 3:     3     9    15
#        a     b     c
#    <int> <int> <int>
# 1:     4    10    16
# 2:     5    11    17
#        a     b     c
#    <int> <int> <int>
# 1:     6    12    18
# Empty data.table (0 rows and 1 cols): ID
  • .SD содержит все столбцы, кроме столбцов группировки по умолчанию.

  • Он также создаётся с сохранением оригинального порядка — сначала данные для ID = "b", затем ID = "a", и наконец ID = "c".

Для выполнения вычислений на (многих) столбцах можно использовать базовую функцию R lapply().

DT[, lapply(.SD, mean), by = ID]
#        ID     a     b     c
#    <char> <num> <num> <num>
# 1:      b   2.0   8.0  14.0
# 2:      a   4.5  10.5  16.5
# 3:      c   6.0  12.0  18.0
  • .SD содержит строки, соответствующие столбцам a, b и c для данной группы. Мы вычисляем mean() для каждой из этих колонок, используя уже знакомую функцию lapply() из базового R.

  • Каждая группа возвращает список из трёх элементов с вычисленными средними значениями, которые становятся столбцами в результирующем data.table.

  • Поскольку lapply() возвращает list, то нет необходимости оборачивать его дополнительным .() (при необходимости обратитесь к этой подсказке).

Мы почти закончили. Осталось прояснить только одну вещь. В нашем data.table flights мы хотели рассчитать mean() только для двух столбцов - arr_delay и dep_delay. Однако по умолчанию .SD будет содержать все столбцы, кроме группирующих переменных.

– Как указать только те столбцы, для которых мы хотим вычислить mean()?

.SDcols

С помощью аргумента .SDcols. Он принимает как имена столбцов, так и их индексы. Например, .SDcols = c("arr_delay", "dep_delay") гарантирует, что .SD будет содержать только эти два столбца для каждой группы.

Аналогично пункту и), вы также можете указать столбцы для удаления вместо столбцов для сохранения, используя - или !. Также можно выбирать последовательные столбцы как colA:colB и исключать их как !(colA:colB) или -(colA:colB).

Теперь давайте попробуем использовать .SD вместе с .SDcols, чтобы вычислить mean() для столбцов arr_delay и dep_delay, сгруппированных по origin, dest и month.

flights[carrier == "AA",                       ## Для перелётов авиакомпанией "AA":
        lapply(.SD, mean),                     ## посчитать среднее
        by = .(origin, dest, month),           ## для каждой комбинации 'origin,dest,month'
        .SDcols = c("arr_delay", "dep_delay")] ## для столбцов, указанных в .SDcols
#      origin   dest month  arr_delay  dep_delay
#      <char> <char> <int>      <num>      <num>
#   1:    JFK    LAX     1   6.590361 14.2289157
#   2:    LGA    PBI     1  -7.758621  0.3103448
#   3:    EWR    LAX     1   1.366667  7.5000000
#   4:    JFK    MIA     1  15.720670 18.7430168
#   5:    JFK    SEA     1  14.357143 30.7500000
#  ---                                          
# 196:    LGA    MIA    10  -6.251799 -1.4208633
# 197:    JFK    MIA    10  -1.880184  6.6774194
# 198:    EWR    PHX    10  -3.032258 -4.2903226
# 199:    JFK    MCO    10 -10.048387 -1.6129032
# 200:    JFK    DCA    10  16.483871 15.5161290

f) Подмножество .SD для каждой группы:

– Как вернуть первые две строки для каждого месяца?

ans <- flights[, head(.SD, 2), by = month]
head(ans)
#    month  year   day dep_delay arr_delay carrier origin   dest air_time distance  hour
#    <int> <int> <int>     <int>     <int>  <char> <char> <char>    <int>    <int> <int>
# 1:     1  2014     1        14        13      AA    JFK    LAX      359     2475     9
# 2:     1  2014     1        -3        13      AA    JFK    LAX      363     2475    11
# 3:     2  2014     1        -1         1      AA    JFK    LAX      358     2475     8
# 4:     2  2014     1        -5         3      AA    JFK    LAX      358     2475    11
# 5:     3  2014     1       -11        36      AA    JFK    LAX      375     2475     8
# 6:     3  2014     1        -3        14      AA    JFK    LAX      368     2475    11
  • .SD — это data.table, который содержит все строки для этой группы. Мы просто выбираем первые две строки, как уже обсуждалось здесь.

  • Для каждой группы head(.SD, 2) возвращает первые две строки в виде data.table, который также является list, поэтому нет необходимости оборачивать его в .().

g) Почему j настолько многофункционален?

Таким образом мы обеспечиваем консистентный синтаксис и продолжаем использовать уже существующие (и знакомые) базовые функции, вместо того чтобы изучать новые. Для иллюстрации используем data.table DT, который мы создали в самом начале в разделе Что такое data.table?.

– Как мы можем объединить столбцы a и b для каждой группы в ID?

DT[, .(val = c(a,b)), by = ID]
#         ID   val
#     <char> <int>
#  1:      b     1
#  2:      b     2
#  3:      b     3
#  4:      b     7
#  5:      b     8
#  6:      b     9
#  7:      a     4
#  8:      a     5
#  9:      a    10
# 10:      a    11
# 11:      c     6
# 12:      c    12
  • Вот и всё. Не требуется никакого специального синтаксиса. Всё, что нам нужно знать, это базовая функция c(), которая объединяет векторы, и предыдущий совет.

– Что если мы хотим, чтобы все значения столбцов a и b были объединены, но возвращены как столбец-список?

DT[, .(val = list(c(a,b))), by = ID]
#        ID         val
#    <char>      <list>
# 1:      b 1,2,3,7,8,9
# 2:      a  4, 5,10,11
# 3:      c        6,12
  • Здесь мы сначала объединяем значения с помощью c(a,b) для каждой группы, а затем оборачиваем это в list(). Таким образом, для каждой группы мы возвращаем список всех объединённых значений.

  • Обратите внимание, что запятые здесь используются только для отображения. Столбец типа “список” может содержать любые объекты в каждой ячейке, и в этом примере каждая ячейка сама по себе является вектором, причём некоторые ячейки содержат более длинные векторы, чем другие.

Когда вы начнёте привыкать к использованию синтаксиса в j, вы поймёте, насколько это мощный инструмент. Чтобы попрактиковаться с ним и попробовать разные вещи, вы можете поэкспериментировать с помощью функции print().

Например:

## обратите внимание на разницу между
DT[, print(c(a,b)), by = ID] # (1)
# [1] 1 2 3 7 8 9
# [1]  4  5 10 11
# [1]  6 12
# Empty data.table (0 rows and 1 cols): ID

## и
DT[, print(list(c(a,b))), by = ID] # (2)
# [[1]]
# [1] 1 2 3 7 8 9
# 
# [[1]]
# [1]  4  5 10 11
# 
# [[1]]
# [1]  6 12
# Empty data.table (0 rows and 1 cols): ID

В случае (1) для каждой группы возвращается по вектору вектор, длины которых равны 6, 4, 2. Однако (2) возвращает список длиной 1 для каждой группы, где первый элемент содержит векторы длиной 6, 4, 2. Поэтому (1) даёт общую длину 6 + 4 + 2 = 12, тогда как (2) возвращает 1 + 1 + 1 = 3.

При помощи аргумента j можно поместить внутрь data.table любой список. Например, при построении статистических моделей на группах строк список с этими моделями может стать столбцом data.table. Такой код лаконичен и легко читается.

## Удаётся ли дальним перелётам сократить отставание лучше, чем ближним?
## Различается ли сокращение отставания по месяцам?
flights[, `:=`(makeup = dep_delay - arr_delay)]

makeup.models <- flights[, .(fit = list(lm(makeup ~ distance))), by = .(month)]
makeup.models[, .(coefdist = coef(fit[[1]])[2], rsq = summary(fit[[1]])$r.squared), by = .(month)]
#     month      coefdist          rsq
#     <int>         <num>        <num>
#  1:     1  0.0042864543 2.664617e-02
#  2:     2 -0.0036042523 2.211601e-02
#  3:     3  0.0012742633 3.661327e-03
#  4:     4  0.0018003305 5.912241e-03
#  5:     5  0.0021486474 7.794517e-03
#  6:     6 -0.0000427658 3.261486e-06
#  7:     7  0.0028011128 1.199733e-02
#  8:     8  0.0029923379 1.910536e-02
#  9:     9  0.0014305778 4.917775e-03
# 10:    10  0.0022125344 1.099980e-02

С использованием data.frame требуется более сложный код, чтобы добиться такого же результата.

setDF(flights)
flights.split <- split(flights, f = flights$month)
makeup.models.list <- lapply(flights.split, function(df) c(month = df$month[1], fit = list(lm(makeup ~ distance, data = df))))
makeup.models.df <- do.call(rbind, makeup.models.list)
sapply(makeup.models.df[, "fit"], function(model) c(coefdist = coef(model)[2], rsq =  summary(model)$r.squared)) |> t() |> data.frame()
#    coefdist.distance          rsq
# 1       0.0042864543 2.664617e-02
# 2      -0.0036042523 2.211601e-02
# 3       0.0012742633 3.661327e-03
# 4       0.0018003305 5.912241e-03
# 5       0.0021486474 7.794517e-03
# 6      -0.0000427658 3.261486e-06
# 7       0.0028011128 1.199733e-02
# 8       0.0029923379 1.910536e-02
# 9       0.0014305778 4.917775e-03
# 10      0.0022125344 1.099980e-02
setDT(flights)

Подведение итогов

Общий синтаксис data.table выглядит следующим образом:

DT[i, j, by]

Как мы теперь видим,

Использование i:

  • Мы можем выбирать строки аналогично data.frame, но при этом не нужно повторно использовать DT$, так как столбцы в рамках data.table рассматриваются как переменные.

  • Мы также можем сортировать data.table с помощью order(), который внутренне использует быстрый метод сортировки data.table для повышения производительности.

Мы можем сделать гораздо больше в i, установив ключи для data.table, что позволит нам очень быстро извлекать подмножества и выполнять соединения. Мы рассмотрим это в разделах vignette("datatable-keys-fast-subset", package="data.table") и vignette("datatable-joins", package="data.table").

Использование j:

  1. Выберите столбцы способом data.table: DT[, .(colA, colB)].

  2. Выберите столбцы способом data.frame: DT[, c("colA", "colB")].

  3. Выполните вычисления по столбцам: DT[, .(sum(colA), mean(colB))].

  4. Укажите имена, если это необходимо: DT[, .(sA = sum(colA), mB = mean(colB))].

  5. Сочетание с i: DT[colA > value, sum(colB)].

Использование by:

  • Используя by, мы можем группировать по столбцам, указывая список столбцов, вектор имен столбцов или даже выражения. Гибкость j, в сочетании с by и i, делает синтаксис очень многофункциональным.

  • by может обрабатывать несколько столбцов, а также выражения.

  • Мы можем использовать keyby для группировки по столбцам, чтобы автоматически сортировать результат группировки.

  • Мы можем использовать .SD и .SDcols в j, чтобы работать с несколькими столбцами, используя уже знакомые базовые функции. Вот несколько примеров:

    1. DT[, lapply(.SD, fun), by = ..., .SDcols = ...] - применяет fun ко всем столбцам, указанным в .SDcols, при группировке по столбцам, указанным в by.

    2. DT[, head(.SD, 2), by = ...] - возвращает первые две строки для каждой группы.

    3. DT[col > val, head(.SD, 1), by = ...] - объединяет i с j и by.

Также не забывайте:

Если j возвращает list, каждый элемент этого списка станет столбцом в результирующей data.table.

В следующем руководстве (vignette("datatable-reference-semantics", package="data.table")) мы рассмотрим, как добавлять/обновлять/удалять столбцы по ссылке и как комбинировать эти операции с i и by.