Быстрый расчет объединения CDF/прокатки на нескольких столбцах

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

library(data.table)  # v 1.9.7

set.seed(2016)
dt <- data.table(x=rnorm(1000), y=rnorm(1000), z=rnorm(1000))
dt
             x        y       z
   1: -0.91474  2.07025 -1.7499
   2:  1.00125 -1.80941 -1.3856
   3: -0.05642  1.58499  0.8110
   4:  0.29665 -1.16660  0.3757
   5: -2.79147 -1.75526  1.2851
  ---                          
 996:  0.63423  0.13597 -2.3710
 997:  0.21415  1.03161 -1.5440
 998:  1.15357 -1.63713  0.4191
 999:  0.79205 -0.56119  0.6670
1000:  0.19502 -0.05297 -0.3288

Я хочу подсчитать количество выборок, чтобы (х <= X, y <= Y, z <= Z) для некоторой сетки верхних границ (X, Y, Z), таких как

bounds <- CJ(X=seq(-2, 2, by=.1), Y=seq(-2, 2, by=.1), Z=seq(-2, 2, by=.1))
bounds
        X  Y    Z
    1: -2 -2 -2.0
    2: -2 -2 -1.9
    3: -2 -2 -1.8
    4: -2 -2 -1.7
    5: -2 -2 -1.6
   ---           
68917:  2  2  1.6
68918:  2  2  1.7
68919:  2  2  1.8
68920:  2  2  1.9
68921:  2  2  2.0

Теперь, я понял, что я могу это сделать элегантно (используя не equi-соединения)

dt[, Count := 1]
result <- dt[bounds, on=c("x<=X", "y<=Y", "z<=Z"), allow.cartesian=TRUE][, list(N.cum = sum(!is.na(Count))), keyby=list(X=x, Y=y, Z=z)]
result[, CDF := N.cum/nrow(dt)]
result
        X  Y    Z N.cum   CDF
    1: -2 -2 -2.0     0 0.000
    2: -2 -2 -1.9     0 0.000
    3: -2 -2 -1.8     0 0.000
    4: -2 -2 -1.7     0 0.000
    5: -2 -2 -1.6     0 0.000
   ---                       
68917:  2  2  1.6   899 0.899
68918:  2  2  1.7   909 0.909
68919:  2  2  1.8   917 0.917
68920:  2  2  1.9   924 0.924
68921:  2  2  2.0   929 0.929

Но этот метод действительно неэффективен и работает очень медленно, так как я начинаю увеличивать счетчик bin. Я думаю, что многовариантная версия функции data.table roll join будет делать трюк, но это мне не представляется возможным. Любые предложения по ускорению этого?

Ответ 1

Выяснил это.

# Step1 - map each sample to the nearest X, Y, and Z above it. (In other words, bin the data.)

X <- data.table(X=seq(-2, 2, by=.1)); X[, x := X]
Y <- data.table(Y=seq(-2, 2, by=.1)); Y[, y := Y]
Z <- data.table(Z=seq(-2, 2, by=.1)); Z[, z := Z]

dt <- X[dt, on="x", roll=-Inf, nomatch=0]
dt <- Y[dt, on="y", roll=-Inf, nomatch=0]
dt <- Z[dt, on="z", roll=-Inf, nomatch=0]

# Step2 - aggregate by unique (X, Y, Z) triplets and count the samples directly below each of these bounds.
bg <- dt[, .N, keyby=list(X, Y, Z)]

# Step4 - Get the count of samples directly below EVERY (X, Y, Z) bound
bounds <- CJ(X=X$X, Y=Y$Y, Z=Z$Z)
kl <- bg[bounds, on=c("X", "Y", "Z")]
kl[is.na(N), N := 0]

# Step5 (the tricky part) - Consider a single (Y, Z) pair. X will be in ascending order. So we can do a cumsum on X for each (Y, Z) to count x <= X | Y,Z. Now if you hold X and Z fixed, you can do a cumsum on Y (which is also in ascending order) to count x <= X, y <= Y | Z. And then just continue this process.
kl[, CountUntil.XgivenYZ := cumsum(N), by=list(Y, Z)]
kl[, CountUntil.XYgivenZ := cumsum(CountUntil.XgivenYZ), by=list(X, Z)]
kl[, CountUntil.XYZ := cumsum(CountUntil.XYgivenZ), by=list(X, Y)]

# Cleanup
setnames(kl, "CountUntil.XYZ", "N.cum")
kl[, CDF := N.cum/nrow(dt)]

Обобщение

Для тех, кто этого хочет, я обобщил это для работы с любым количеством переменных и сбрасывал функцию в мой пакет R, mltools.

Например, чтобы решить эту проблему, вы можете сделать

library(mltools)

bounds <- list(x=seq(-2, 2, by=.1), y=seq(-2, 2, by=.1), z=seq(-2, 2, by=.1))
empirical_cdf(x=dt, ubounds=bounds)
        x  y    z N.cum   CDF
    1: -2 -2 -2.0     0 0.000
    2: -2 -2 -1.9     0 0.000
    3: -2 -2 -1.8     0 0.000
    4: -2 -2 -1.7     0 0.000
    5: -2 -2 -1.6     0 0.000
   ---                       
68917:  2  2  1.6   899 0.899
68918:  2  2  1.7   909 0.909
68919:  2  2  1.8   917 0.917
68920:  2  2  1.9   924 0.924
68921:  2  2  2.0   929 0.929

Ответ 2

Обновление

Ниже я предоставил общее решение base R (оно будет работать на неравномерных сетках). Он был быстрее, чем самое быстрое опубликованное решение, предоставляемое OP (подробнее об этом позже). Поскольку ОП указывает, генерация столбца N.cum является настоящим узким местом, поэтому я сосредоточил свои усилия только на этой задаче (т.е. Создание CDF является тривиальной задачей после получения N.cum).

JoeBase <- function(dtt, s) {
    m <- matrix(c(dtt$x, dtt$y, dtt$z), ncol = 3)
    N.Cum <- array(vector(mode = "integer"), dim = rev(sapply(s, length)))
    for (i in seq_along(s[[1]])) {
        t1 <- m[,1] <= s[[1]][i]
        for (j in seq_along(s[[2]])) {
            t2 <- t1 & (m[,2] <= s[[2]][j])
            for (k in seq_along(s[[3]])) {
                N.Cum[k,j,i] <- sum(t2 & (m[,3] <= s[[3]][k]))
            }
        }
    }
    as.vector(N.Cum)
}

В приведенном выше алгоритме используются векторизованные операции, в частности создание и использование логических векторов t1 и t2. Этот вектор используется для получения числа строк, удовлетворяющих критериям для всех 3 столбцов в исходной таблице данных. Мы просто полагаемся на внутреннее принуждение через R от логического вектора к интегральному вектору действием sum.

Выяснение того, как заполнить трехмерный массив целых чисел N.cum, было немного сложной задачей, так как позже он будет преобразован в вектор через as.vector. Для изучения поведения as.vector потребовалось немного проб и ошибок. К моему удивлению, "последнее" и "первое" измерение должно быть перестроено для того, чтобы принуждение к вектору происходило верно (первые несколько раз, я использовал N.Cum [i, j, k] вместо N.Cum [K, J, I]).

Сначала давайте проверим равенство:

library(data.table)
## Here is the function I used to test against. I included the generation
## of "bounds" and "bg" as "result" depends on both of these (N.B. "JoeBase" does not)
BenDT <- function(dt, s) {
    X <- data.table(X=s[[1]]); X[, x := X]
    Y <- data.table(Y=s[[2]]); Y[, y := Y]
    Z <- data.table(Z=s[[3]]); Z[, z := Z]

    dt <- X[dt, on="x", roll=-Inf, nomatch=0]
    dt <- Y[dt, on="y", roll=-Inf, nomatch=0]
    dt <- Z[dt, on="z", roll=-Inf, nomatch=0]
    bg <- dt[, .N, keyby=list(X, Y, Z)]

    bounds <- CJ(X=X$X, Y=Y$Y, Z=Z$Z)

    kl <- bg[bounds, on=c("X", "Y", "Z")]
    kl[is.na(N), N := 0]

    # Counting
    kl[, CountUntil.XgivenYZ := cumsum(N), by=list(Y, Z)]
    kl[, CountUntil.XYgivenZ := cumsum(CountUntil.XgivenYZ), by=list(X, Z)]
    kl[, CountUntil.XYZ := cumsum(CountUntil.XYgivenZ), by=list(X, Y)]

    # Cleanup
    setnames(kl, "CountUntil.XYZ", "N.cum")
    kl[, CDF := N.cum/nrow(dt)]
    kl
}

t1 <- BenDT(dt, seq(-2,2,0.1))
t2 <- JoeBase(dt, seq(-2,2,0.1))

all.equal(t1$N.cum, t2)
[1] TRUE

Теперь мы проверяем скорость. Сначала мы скомпилируем обе функции с помощью cmpfun из пакета compiler. Первый контрольный показатель отражает эффективность на более мелких примерах.

library(compiler)
c.JoeBase <- cmpfun(JoeBase)
c.BenDT <- cmpfun(BenDT)
c.OldBenDT <- cmpfun(OldBenDT)  ## The previous best solution that Ben contributed

st <- list(seq(-2, 2, 0.1), seq(-2, 2, 0.1), seq(-2, 2, 0.1))
microbenchmark(c.BenDT(dt, st), c.OldBenDT(dt, st), c.JoeBase(dt, st), times = 10)
Unit: milliseconds
              expr        min         lq       mean    median         uq        max neval cld
   c.BenDT(dt, st)   34.24872   34.78908   38.87775   37.4924   43.37179   46.12859    10 a  
c.OldBenDT(dt, st) 1485.68178 1532.35878 1607.96669 1593.9813 1619.58908 1845.75876    10  b 
 c.JoeBase(dt, st) 1880.71648 1962.38160 2049.43985 2007.4880 2169.93078 2281.02118    10   c

Ниже приведен старый тест.
Однако, когда количество ящиков увеличивается, c.JoeBase действительно начинает доминировать (более чем в 5 раз быстрее).

st <- list(seq(-5, 5, 0.1), seq(-5, 5, 0.1), seq(-5, 5, 0.1))
microbenchmark(c.JoeBase(dt, st), c.OldBenDT(dt, st), times = 5)
Unit: seconds
              expr       min        lq      mean    median        uq       max neval cld
 c.JoeBase(dt, st)  23.50927  23.53809  29.61145  24.52748  30.81485  45.66759     5  a 
c.OldBenDT(dt, st) 110.60209 123.95285 133.74601 124.97929 125.96186 183.23394     5   b

После проведения дальнейших тестов у меня есть некоторые опасения относительно результатов (@Ben указал на подобное мнение в комментариях). Я уверен, что c.JoeBase работает быстрее только из-за ограничений моего старого компьютера. Как отметил в своем ответе @stephematician, исходное решение интенсивно запоминается, и если вы просто выполните system.time на c.OldBenDT, вы увидите, что большую часть времени тратится в категории system и категория user сравнима с категорией user c.JoeBase. Мой 6-летний Mac имеет только 4 ГБ оперативной памяти, и я предполагаю, что с этими операциями происходит большое количество свопов памяти. Обратите внимание:

## test with very tiny buckets (i.e. 0.025 instead of 0.1 above)
st <- list(seq(-1.5, 1.5, 0.025), seq(-1.5, 1.5, 0.025), seq(-1.5, 1.5, 0.025))  
system.time(c.JoeBase(dt, st))
  user  system elapsed 
36.407   4.748  41.170

system.time(c.OldBenDT(dt, st))
   user  system elapsed 
49.653  77.954 475.304

system.time(c.BenDT(dt, st))  ## Ben new solution is lightning fast
  user  system elapsed 
 0.603   0.063   0.668

Несмотря на это, последнее решение @Ben намного превосходит. Ознакомьтесь с этими новыми критериями:

st <- list(seq(-5, 5, 0.1), seq(-5, 5, 0.1), seq(-5, 5, 0.1))
microbenchmark(c.JoeBase(dt, st), BenDT(dt, st), times = 5)
Unit: milliseconds
             expr        min         lq       mean     median        uq        max neval cld
c.JoeBase(dt, st) 26517.0944 26855.7819 28341.5356 28403.7871 29926.213 30004.8018     5   b
    BenDT(dt, st)   342.4433   359.8048   400.3914   379.5319   423.336   496.8411     5  a

Еще одна победа data.table.

Ответ 3

Просто примечание об альтернативе, и все же очевидное решение:

set.seed(2016)
dt <- data.table(x=rnorm(20000), y=rnorm(20000), z=rnorm(20000))

system.time({
    dt <- t(as.matrix(dt))

    bounds <- as.matrix(expand.grid(z=seq(-2,2,0.1),
                                    y=seq(-2,2,0.1),
                                    x=seq(-2,2,0.1)))

    bounds <- bounds[,ncol(bounds):1]

    n_d <- ncol(bounds)

    x <- apply(bounds,
               1,
               function(x) sum(colSums(dt < x) == n_d))
})

Это решение на моей машине занимает примерно в два раза больше времени, чтобы рассчитать решения JoeBase и OldBenDT. Основное различие? Использование памяти. Он больше связан с процессором.

Я не знаю точного способа сравнения использования памяти в R, но функция memory.size(max=T) сообщила, используя 5 ГБ памяти для этих предыдущих подходов (а не для подхода без привязки), в то время как только с использованием 40 Мб памяти для подхода apply (примечание: я использовал 20000 точек в распределении выборки).

Я думаю, что это имеет важные последствия для масштаба вычислений, которые вы можете выполнить.

Ответ 4

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

set.seed(2016)
dt <- data.table(x=rnorm(1000), y=rnorm(1000), z=rnorm(1000))
setkey(dt)

bounds <- CJ(x=seq(-2, 2, by=.1), y=seq(-2, 2, by=.1), z=seq(-2, 2, by=.1))

a <- dt[bounds,.N / nrow(dt),on=c("x<x","y<y","z<z"),
        by=.EACHI,
        allow.cartesian=T]