Уменьшите объем памяти данных. Таблица с сильно повторяющимся ключом

Я пишу пакет для анализа высокопроизводительных данных о поведении животных в R. Данные представляют собой многомерные временные ряды. Я решил представить их с помощью data.tables, который мне очень удобен.

Для одного животного у меня было бы что-то вроде этого:

one_animal_dt <- data.table(t=1:20, x=rnorm(20), y=rnorm(20))

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

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

Итак, концептуально, что-то вроде этого:

animal_list <- list()
animal_list[[1]] <- data.table(t=1:20, x=rnorm(20), y=rnorm(20),
                               treatment="A", date="2017-02-21 20:00:00", 
                               animal_id=1)
animal_list[[2]]  <- data.table(t=1:20, x=rnorm(20), y=rnorm(20),
                                treatment="B", date="2017-02-21 22:00:00",
                                animal_id=2)
# ...
final_dt <- rbindlist(animal_list)
setkeyv(final_dt,c("treatment", "date","animal_id"))

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

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

Есть ли способ сжать этот избыточно избыточный ключ без потери структуры (т.е. столбцов) таблицы? В идеале я не хочу принуждать своих пользователей к использованию самих JOIN.

Ответы

Ответ 1

Предположим, что мы являемся администратором базы данных, которому дана задача эффективно реализовать это в базе данных SQL. Одной из целей нормализации базы данных является сокращение избыточности.

Согласно описанию ОП, существует много (около 1 М) наблюдений на животных (многомерные продольные данные), в то время как количество животных кажется намного меньшим.

Таким образом, постоянные (или инвариантные) базовые данные каждого животного, например treatment, date, должны храниться отдельно от observations.

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

(Обратите внимание, что это основное отличие от ответа Mallick, который использует treatment как ключ, который не гарантированно будет уникальным, т.е. два животных могут получить такое же лечение и, кроме того, увеличивает избыточность.)

Отдельные таблицы эффективны с точки зрения памяти

В целях демонстрации более реалистичные "контрольные" данные создаются для 10 животных с 1 М наблюдениями для каждого животного:

library(data.table)   # CRAN version 1.10.4 used
# create observations
n_obs <- 1E6L
n_animals <-10L
set.seed(123L)
observations <- data.table(
  animal_id = rep(seq_len(n_animals), each = n_obs),
  t = rep(seq_len(n_obs), n_animals),
  x = rnorm(n_animals * n_obs), 
  y = rnorm(n_animals * n_obs))
# create animal base data
animals = data.table(
  animal_id = seq_len(n_animals),
  treatment = wakefield::string(n_animals),
  date = wakefield::date_stamp(n_animals, random = TRUE))

Здесь wakefield используется для создания фиктивных имен и дат. Обратите внимание, что animal_id имеет тип integer.

> str(observations)
Classes ‘data.table’ and 'data.frame':    10000000 obs. of  4 variables:
 $ animal_id: int  1 1 1 1 1 1 1 1 1 1 ...
 $ t        : int  1 2 3 4 5 6 7 8 9 10 ...
 $ x        : num  -0.5605 -0.2302 1.5587 0.0705 0.1293 ...
 $ y        : num  0.696 -0.537 -3.043 1.849 -1.085 ...
 - attr(*, ".internal.selfref")=<externalptr> 
> str(animals)
Classes ‘data.table’ and 'data.frame':    10 obs. of  3 variables:
 $ animal_id: int  1 2 3 4 5 6 7 8 9 10
 $ treatment:Classes 'variable', 'character'  atomic [1:10] MADxZ9c6fN ymoJHnvrRx ifdtywJ4jU Q7ZRwnQCsU ...
  .. ..- attr(*, "varname")= chr "String"
 $ date     : variable, format: "2017-07-02" "2016-10-02" ...
 - attr(*, ".internal.selfref")=<externalptr>

Комбинированный размер составляет около 240 Мбайт:

> object.size(observations)
240001568 bytes
> object.size(animals)
3280 bytes

Пусть это будет ссылкой и сравните с подходом OP final_dt:

# join both tables to create equivalent of final_dt
joined <- animals[observations, on = "animal_id"]

Теперь размер почти удвоен (400 Мбайт), что неэффективно.

> object.size(joined)
400003432 bytes

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

setkey(observations, animal_id)
setkey(animals, animal_id)
joined <- animals[observations] 

Как работать с отдельными таблицами?

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

Для последующего анализа мы можем агрегировать observations на животное, например,

observations[, .(.N, mean(x), mean(y)), by = animal_id]
    animal_id       N            V2            V3
 1:         1 1000000 -5.214370e-04 -0.0019643145
 2:         2 1000000 -1.555513e-03  0.0002489457
 3:         3 1000000  1.541233e-06 -0.0005317967
 4:         4 1000000  1.775802e-04  0.0016212182
 5:         5 1000000 -9.026074e-04  0.0015266330
 6:         6 1000000 -1.000892e-03  0.0003284044
 7:         7 1000000  1.770055e-04 -0.0018654386
 8:         8 1000000  1.919562e-03  0.0008605261
 9:         9 1000000  1.175696e-03  0.0005042170
10:        10 1000000  1.681614e-03  0.0020562628

и присоедините агрегаты с помощью animals

animals[observations[, .(.N, mean(x), mean(y)), by = animal_id]]
    animal_id  treatment       date       N            V2            V3
 1:         1 MADxZ9c6fN 2017-07-02 1000000 -5.214370e-04 -0.0019643145
 2:         2 ymoJHnvrRx 2016-10-02 1000000 -1.555513e-03  0.0002489457
 3:         3 ifdtywJ4jU 2016-10-02 1000000  1.541233e-06 -0.0005317967
 4:         4 Q7ZRwnQCsU 2017-02-02 1000000  1.775802e-04  0.0016212182
 5:         5 H2M4V9Dfxz 2017-04-02 1000000 -9.026074e-04  0.0015266330
 6:         6 29P3hFxqNY 2017-03-02 1000000 -1.000892e-03  0.0003284044
 7:         7 rBxjewyGML 2017-02-02 1000000  1.770055e-04 -0.0018654386
 8:         8 gQP8cZhcTT 2017-04-02 1000000  1.919562e-03  0.0008605261
 9:         9 0GEOseSshh 2017-07-02 1000000  1.175696e-03  0.0005042170
10:        10 x74yDs2MdT 2017-02-02 1000000  1.681614e-03  0.0020562628

ОП указал, что он не хочет принуждать своих пользователей к использованию самих подключений. Правда, набрав animals[observations], требуется больше нажатий клавиш, чем final_dt. Итак, это до OP, чтобы решить, стоит ли экономить память или нет.

Этот результат можно отфильтровать, например, если мы хотим сравнить животных с определенными характеристиками, например,

animals[observations[, .(.N, mean(x), mean(y)), by = animal_id]][date == as.Date("2017-07-02")]
   animal_id  treatment       date       N           V2           V3
1:         1 MADxZ9c6fN 2017-07-02 1000000 -0.000521437 -0.001964315
2:         9 0GEOseSshh 2017-07-02 1000000  0.001175696  0.000504217

Примеры использования OP

В этом коммменте ОП описал некоторые варианты использования, которые он хочет видеть прозрачно для своих пользователей:

  • Создание новых столбцов final_dt[, x2 := 1-x]: поскольку задействованы только оберванности, это переводится непосредственно на observations[, x2 := 1-x].
  • Выберите, используя различные критерии final_dt[t > 5 & treatment == "A"]: Здесь задействованы столбцы обеих таблиц. Это можно реализовать с помощью data.table по-разному (обратите внимание, что условия были изменены для фактических данных выборки):

    animals[observations][t < 5L & treatment %like% "MAD"]
    

    Это аналог ожидаемого синтаксиса, но медленнее, чем альтернатива ниже, потому что здесь условия фильтра применяются ко всем строкам полного соединения.

    Более быстрая альтернатива заключается в разделении условий фильтра, так что observations фильтруется до объединения, чтобы уменьшить набор результатов до того, как условия фильтра на столбцах базовых данных будут применены окончательно:

    animals[observations[t < 5L]][treatment %like% "MAD"]
    

    Обратите внимание, что это выглядит очень похоже на ожидаемый синтаксис (с одним нажатием клавиши меньше).

    Если это считается неприемлемым для пользователей, операция соединения может быть скрыта в функции:

    # function definition
    filter_dt <- function(ani_filter = "", obs_filter = "") {
      eval(parse(text = stringr::str_interp(
        'animals[observations[${obs_filter}]][${ani_filter}]')))
    }
    
    # called by user
    filter_dt("treatment %like% 'MAD'", "t < 5L")
    
       animal_id  treatment       date t           x          y
    1:         1 MADxZ9c6fN 2017-07-02 1 -0.56047565  0.6958622
    2:         1 MADxZ9c6fN 2017-07-02 2 -0.23017749 -0.5373377
    3:         1 MADxZ9c6fN 2017-07-02 3  1.55870831 -3.0425688
    4:         1 MADxZ9c6fN 2017-07-02 4  0.07050839  1.8488057
    

Использование факторов для уменьшения объема памяти

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

Mallick упомянул, что память может пропасть впустую, если целые числа случайно хранятся в виде чисел. Это можно продемонстрировать:

n <- 10000L
# integer vs numeric vs logical
test_obj_size <- data.table(
  rep(1, n),
  rep(1L, n),
  rep(TRUE, n))

str(test_obj_size)
Classes ‘data.table’ and 'data.frame':    10000 obs. of  3 variables:
 $ V1: num  1 1 1 1 1 1 1 1 1 1 ...
 $ V2: int  1 1 1 1 1 1 1 1 1 1 ...
 $ V3: logi  TRUE TRUE TRUE TRUE TRUE TRUE ...
 - attr(*, ".internal.selfref")=<externalptr>
sapply(test_obj_size, object.size)
   V1    V2    V3 
80040 40040 40040

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

Кроме того, потребление памяти символьных строк может быть уменьшено, если их принудить к коэффициенту:

# character vs factor
test_obj_size <- data.table(
  rep("A", n),
  rep("AAAAAAAAAAA", n),
  rep_len(LETTERS, n),
  factor(rep("A", n)),
  factor(rep("AAAAAAAAAAA", n)),
  factor(rep_len(LETTERS, n)))

str(test_obj_size)
Classes ‘data.table’ and 'data.frame':    10000 obs. of  6 variables:
 $ V1: chr  "A" "A" "A" "A" ...
 $ V2: chr  "AAAAAAAAAAA" "AAAAAAAAAAA" "AAAAAAAAAAA" "AAAAAAAAAAA" ...
 $ V3: chr  "A" "B" "C" "D" ...
 $ V4: Factor w/ 1 level "A": 1 1 1 1 1 1 1 1 1 1 ...
 $ V5: Factor w/ 1 level "AAAAAAAAAAA": 1 1 1 1 1 1 1 1 1 1 ...
 $ V6: Factor w/ 26 levels "A","B","C","D",..: 1 2 3 4 5 6 7 8 9 10 ...
 - attr(*, ".internal.selfref")=<externalptr>
sapply(test_obj_size, object.size)
   V1    V2    V3    V4    V5    V6 
80088 80096 81288 40456 40464 41856

Сохраняемый как фактор, требуется только половина памяти.

То же самое справедливо для классов date и POSIXct:

# Date & POSIXct vs factor
test_obj_size <- data.table(
  rep(as.Date(Sys.time()), n),
  rep(as.POSIXct(Sys.time()), n),
  factor(rep(as.Date(Sys.time()), n)),
  factor(rep(as.POSIXct(Sys.time()), n)))

str(test_obj_size)
Classes ‘data.table’ and 'data.frame':    10000 obs. of  4 variables:
 $ V1: Date, format: "2017-08-02" "2017-08-02" "2017-08-02" "2017-08-02" ...
 $ V2: POSIXct, format: "2017-08-02 18:25:55" "2017-08-02 18:25:55" "2017-08-02 18:25:55" "2017-08-02 18:25:55" ...
 $ V3: Factor w/ 1 level "2017-08-02": 1 1 1 1 1 1 1 1 1 1 ...
 $ V4: Factor w/ 1 level "2017-08-02 18:25:55": 1 1 1 1 1 1 1 1 1 1 ...
 - attr(*, ".internal.selfref")=<externalptr>
sapply(test_obj_size, object.size)
   V1    V2    V3    V4 
80248 80304 40464 40480

Обратите внимание, что data.table() отказывается создавать столбец класса POSIXlt, поскольку он хранится в 40 байт вместо 8 байтов.

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

Ответ 2

Вам следует рассмотреть возможность использования вложенных data.frame

library(tidyverse)

Используя пример игрушки, где я rbind 4 копии mtcars

new <- rbind(mtcars,mtcars,mtcars,mtcars) %>% 
         select(cyl,mpg)
object.size(new)
11384 bytes

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

grp <- rbind(mtcars,mtcars,mtcars,mtcars)%>% 
         select(cyl,mpg) %>% 
         group_by(cyl)
object.size(grp)    
14272 bytes

Если мы гнездо, данные также

alt <- rbind(mtcars,mtcars,mtcars,mtcars) %>% 
         select(cyl,mpg) %>% 
         group_by(cyl) %>% 
         nest(mpg)
object.size(alt)
4360 bytes

Вы значительно уменьшите размер объекта.

ПРИМЕЧАНИЕ У вас должно быть много повторяющихся значений для сохранения памяти в этом случае; например, nested единственная копия mtcars больше в размере памяти, чем одна нормальная копия mtcars

----- ВАШ ДЕЛО -----

alt1 <- final_dt %>%
         group_by(animal_id, treatment, date) %>%
         nest()

будет выглядеть как

alt1
  animal_id treatment                date              data
1         1         A 2017-02-21 20:00:00 <tibble [20 x 3]>
2         1         B 2017-02-21 22:00:00 <tibble [20 x 3]>

Ответ 3

Вот две возможности (используйте один, оба или ничего):

  • Убедитесь, что все типы столбцов являются наиболее эффективными с точки зрения памяти. Если у вас есть целые числа, которые хранятся как числовые символы, это может съесть много памяти.
  • Поскольку вы не хотите, чтобы ваши пользователи сами делали объединения самостоятельно, напишите короткую функцию, которая соединяется для них в зависимости от животных, которых они хотят. Просто поместите информацию о животных в одну таблицу данных и информацию о лечении в другую таблицу данных и выполните слияние функции

Сначала я просто разделю таблицы:

# Same code as in question to generate data
animal_list <- list()
animal_list[[1]] <- data.table(t=1:20, x=rnorm(20), y=rnorm(20),
                               treatment="A", date="2017-02-21 20:00:00", 
                               animal_id=1)
animal_list[[2]]  <- data.table(t=1:20, x=rnorm(20), y=rnorm(20),
                                treatment="B", date="2017-02-21 22:00:00",
                                animal_id=2)
# ...
final_dt <- rbindlist(animal_list)

# Separating into treatment and animal data.tables
animals_dt <- unique(final_dt[, .(date), key = animal_id])
treatments_dt <- final_dt[, .(t, x, y, treatment), key = animal_id]

Затем здесь функция, которая выполняет слияние для пользователей

get_animals <- function(animal_names) {
     output <- animals_dt[animal_id %in% animal_names] # Getting desired animals
     output <- treatments_dt[output] # merging in treatment details
     return(output)
 }

Отредактировано, чтобы использовать animal_id как уникальный идентификатор вместо лечения. h/t Uwe

Ответ 4

Большое спасибо за весь ваш канал. Вы побудили меня ответить на мой собственный вопрос, так что вот оно.

Я рассмотрел три разные структуры данных:

  • оригинал, где все метаданные наивно находятся в одной таблице. См. Мой вопрос.
  • вложенная, где таблица содержит метаданные и специальный столбец data, который содержит одну таблицу для каждого животного. См. ответ @ChiPak.
  • Две таблицы. Один для метаданных и один для данных. Эти две таблицы могут быть сопоставлены друг с другом с использованием общего идентификатора (ключа). См. ответ @UweBlock.

Подход оригинала очень удобен. Например, очень эффективно и просто записывать операции между данными и метаданными одинаково (поскольку они находятся в одной таблице). Например, создание или изменение новых метаданных или новые данные могут быть эффективно выполнены с помощью :=.

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

Я также очень серьезно рассмотрел параметр две таблицы. Это очень эффективно, если пользователи знают, как выполнять объединения (которые довольно многословны), и если они могут поддерживать связь между данными и метаданными (например, если у вас есть несколько наборов данных, вам необходимо убедиться, что у вас есть правильные метаданные для правильных данных). В идеале метаданные и данные должны быть в одной и той же структуре, так же как вложенные таблицы "внутри" их уникальной родительской таблицы.

В конце концов, я попытался взять несколько из трех подходов и пришел с новой структурой данных, которую я поместил в пакет под названием behavr. Данные внутренне хранятся в экземпляре класса, который происходит от data.table, но он также имеет метаданные в качестве атрибута. Данные и метаданные имеют один и тот же ключ. Для данных он работает как обычная таблица данных, например. dt[, y2 := y +1] Поскольку метаданные находятся внутри одной структуры, я мог бы написать функцию (xmd) для "расширения метаданных" и неявно присоединиться к ней. Например, dt[, y3 := xmd(a_meta_variable) + y] просматривает a_meta_variable в метаданных и присоединяется к нему для использования в качестве обычного - анонимного - вектора. Кроме того, я немного изменил оператор [, чтобы мы могли получить доступ к метаданным с помощью [..., meta=T]: dt[meta=T], чтобы увидеть метаданные, dt[, new_meta_var := meta_var1+ meta_var2, meta=T], чтобы сделать новые метапеременные и dt[id < 10,meta=T] для подмножества.

На данный момент это действительно пакет черновиков, поэтому я был бы очень рад, если бы у вас был какой-то запас и вклад! Подробнее на https://github.com/rethomics/behavr