require(data.table)
knitr::opts_chunk$set(
  comment = "#",
    error = FALSE,
     tidy = FALSE,
    cache = FALSE,
 collapse = TRUE
)
.old.th = setDTthreads(1)

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


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

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

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

Данные {#data}

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

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

options(width = 100L)
input <- if (file.exists("../flights14.csv")) {
   "../flights14.csv"
} else {
  "https://raw.githubusercontent.com/Rdatatable/data.table/master/vignettes/flights14.csv"
}
flights <- fread(input)
flights
dim(flights)

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

Введение

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

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

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

1. Основы {#basics-1}

a) Что такое data.table? {#what-is-datatable-1a}

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
class(DT$ID)

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

Примечания:

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

По сравнению с 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 {#subset-i-1c}

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

ans <- flights[origin == "JFK" & month == 6L]
head(ans)

-- Вывести первые две строки из flights. {#subset-rows-integer}

ans <- flights[1:2]
ans

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

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

ans <- flights[order(origin, -dest)]
head(ans)

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

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

d) Выбрать столбец/столбцы в j {#select-j-1d}

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

ans <- flights[, arr_delay]
head(ans)

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

ans <- flights[, list(arr_delay)]
head(ans)

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

Подсказка: {#tip-1}

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

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

ans <- flights[, .(arr_delay, dep_delay)]
head(ans)

## другой вариант
# ans <- flights[, list(arr_delay, dep_delay)]

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

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

ans <- flights[, .(delay_arr = arr_delay, delay_dep = dep_delay)]
head(ans)

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

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

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

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

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

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

ans <- flights[origin == "JFK" & month == 6L,
               .(m_arr = mean(arr_delay), m_dep = mean(dep_delay))]
ans

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

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

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

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

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

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

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

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

setkeyv(flights, "origin")

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

r 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 ...

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

r 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,...

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

r 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: {#special-N}

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

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

ans <- flights[origin == "JFK" & month == 6L, .N]
ans

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

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

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

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

ans <- flights[, c("arr_delay", "dep_delay")]
head(ans)

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

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

select_cols = c("arr_delay", "dep_delay")
flights[ , ..select_cols]

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

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

flights[ , select_cols, with = FALSE]

Аргумент называется 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 эта запятая также понадобится

## (2) с использованием with()
DF[with(DF, x > 1), ]

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

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

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

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

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

ans <- flights[, .(.N), by = .(origin)]
ans

## либо со строковым вектором в 'by'
# ans <- flights[, .(.N), by = "origin"]

-- Как рассчитать количество рейсов для каждого аэропорта отправления для кода перевозчика "AA"? {#origin-N}

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

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

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

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

## или, как вариант, с использованием вектора символов в `by`
# ans <- flights[carrier == "AA", .N, by = c("origin", "dest")]

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

ans <- flights[carrier == "AA",
        .(mean(arr_delay), mean(dep_delay)),
        by = .(origin, dest, month)]
ans

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

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

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

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

ans <- flights[carrier == "AA",
        .(mean(arr_delay), mean(dep_delay)),
        keyby = .(origin, dest, month)]
ans

Ключи сортировки: На самом деле 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)

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

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

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

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

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

ans <- flights[, .N, .(dep_delay>0, arr_delay>0)]
ans

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

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

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

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

Специальный символ .SD: {#special-SD}

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

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

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

DT

DT[, print(.SD), by = ID]

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

DT[, lapply(.SD, mean), by = ID]

Мы почти закончили. Осталось прояснить только одну вещь. В нашем 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

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

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

ans <- flights[, head(.SD, 2), by = month]
head(ans)

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

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

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

DT[, .(val = c(a,b)), by = ID]

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

DT[, .(val = list(c(a,b))), by = ID]

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

Например:

## обратите внимание на разницу между
DT[, print(c(a,b)), by = ID] # (1)

## и
DT[, print(list(c(a,b))), by = ID] # (2)

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

При помощи аргумента 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)]

С использованием 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()
setDT(flights)

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

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

DT[i, j, by]

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

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

Мы можем сделать гораздо больше в 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:

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

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

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


setDTthreads(.old.th)


Rdatatable/data.table documentation built on March 1, 2025, 6:11 a.m.