Эффективное индексирование/объединение в data.table по нескольким зависимым условиям для алгоритма обнаружения остановки

Изменить: реальный набор данных доступен здесь

С благодарностью

Ван, Руи, Фанглин Чен, Чжэньюй Чен, Тяньсин Ли, Габриэлла Харари, Стефани Тигнор, Ся Чжоу, Дрор Бен-Зеев и Эндрю Т. Кэмпбелл. StudentLife: оценка психического здоровья, успеваемости и поведенческих тенденций студентов колледжей, использующих смартфоны. В материалах конференции ACM по вездесущим вычислениям. 2014.


Объяснение

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

Место (A) является остановкой, если существует другое место (B) с отметкой времени не менее 180 секунд после A, и если все места между A и B включительно имеют расстояние от A меньше или равное 80 метрам.

Я попытался уменьшить данные, чтобы они все еще работали, но не требовали фактических координат.

data <- data.table(id = c(1, 2, 3, 4, 5, 6, 7, 8, 9, 10),
                   latlon = c(0, 50, 80, 90, 90, 100, 190, 110, 110, 110),
                   time = c(0, 60, 120, 180, 240, 300, 360, 420, 480, 520))

id 1 не является остановкой, потому что первое местоположение с разницей во времени › 180 (id 5) имеет расстояние в латлоне 90.

id 2 является остановкой, так как все локации между ней и первой локацией с разницей во времени › 180 (id 6) имеют расстояние меньше 80 (0, 30, 40, 40, 50).

id 6 не является остановкой, потому что, хотя id 10 составляет › 180 разницу во времени, id 7, которая находится между ними, имеет расстояние больше 80.

id 8 не является остановкой, потому что по прошествии как минимум 180 секунд после этого нет места.

В конечном счете, мне нужно иметь возможность жадно назначать идентификатор остановки, например, если я обнаружу, что id 2 имеет точки, которые удовлетворяют требованию расстояния до id 7, места с идентификаторами 2: 7 имеют идентификатор остановки 2.

Матрица и цикл for

Если я запускаю это:

nrows <- nrow(data)

latlon_dist <- outer(data$latlon, data$latlon, `-`)
latlon_dist[upper.tri(latlon_dist)] <- NA
time_window <- outer(data$time, data$time, `-`)
time_window[upper.tri(time_window)] <- NA

foo <- function(x){
  mindist <- min(which(x[, 1] > 80), nrows)
  if (mindist >= min(which(x[, 2] > 180), nrows + 1)) mindist else NA
}

bar <- array(c(latlon_dist, time_window),
  dim = c(nrows, nrows, 2))


apply(bar, 2, foo)

Он возвращает мне пороговые значения > NA 7 7 NA NA NA NA NA NA NA, которые я могу использовать в цикле for, чтобы установить соответствующий идентификатор остановки.

threshholds <- apply(bar, 2, foo) - 1

previous_threshhold <- 0
for (i in seq_along(threshholds)) {
  current_threshhold <- threshholds[i]
  
  if (!is.na(current_threshhold) && current_threshhold > previous_threshhold) {
    data[i:current_threshhold, stop_id := i]
    previous_threshhold <- current_threshhold
  }
}

На данный момент это единственный способ гарантировать точность. Все остальное, что я пробовал, я считал правильным, только чтобы обнаружить, что это не идентично этой ситуации. Но это, как вы можете себе представить, ужасно неэффективно, и в моем исследовании моделирования оно запускалось 116 000 раз.

Мое предположение состоит в том, что лучший способ справиться с этим — неэквивалентное соединение в data.table.

Другая реализация, которую я сейчас использую, работает лучше, когда количество строк в наборе данных делает массив слишком тяжелым для памяти. Я не буду переводить это для работы с данными, но оно здесь на случай, если оно даст кому-то идеи. Я застрял в цикле while, чтобы он мог пропускать некоторые итерации, когда он уже присвоил stop_id ряду точек. Если все точки 1:7 принадлежат stop_id 1, они сами не считаются остановками-кандидатами, мы просто снова переходим к тестированию в точке 8. Технически это возвращает другое решение, но остановки, которые достаточно близки, объединяются позже в этом процессе. , так что конечный результат вряд ли будет сильно отличаться.

Для цикла, без матрицы

stopFinder <- function(dt){
  
  nrows <- nrow(dt)
  
  if (nrows < 20000){
    return(quickStopFinder(dt))
  }
  i <- 1
  remove_indices <- 0
  while (i < nrows) {
    this_ends  <- dt[!remove_indices,
                     Position(
                       geodist_vec(rep(longitude[1], .N),
                                   rep(latitude[1], .N),
                                   longitude,
                                   latitude,
                                   paired = TRUE),
                       f = function(x) x > 80,
                       nomatch = .N + 1) ] + i - 1
    # A) Do some number of points occur within the distance?
    # B) If so, is it at least three minutes out?
    if (this_ends > (i + 1) && dt[c(i, (this_ends - 1)), timestamp[.N] > time_window[1]]) {
      # Last index is the one before the distance is broken
      last_index_of_stop <- this_ends - 1
      
      # Next run, we will remove all prior considerations
      remove_indices <- c(1:last_index_of_stop)
      
      # Set the point itself
      dt[i,
         `:=`(candidate_stop = TRUE,
              stop_id = id,
              within_stop = TRUE)]
      # Set the attached points
      dt[(i + 1):last_index_of_stop,
         `:=`(within_stop = TRUE,
              stop_id = i)]
      
      # Start iterating again on the point that broke the distance
      i <- this_ends
    } else {
      # If no stop, move on and leave out this point
      remove_indices <- c(1:i)
      i <- i + 1
    }
  }
  dt[]
}

quickStopFinder более или менее является реализацией, о которой я рассказал в начале, которая интенсивно использует память и работает медленно, но немного менее медленно, чем stopFinder.

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

  res <- dt[dt,
            on = .(timestamp >= timestamp_dup,
                   timestamp <= time_window)]
  res[, dist := geodist_vec(x1 = longitude,
                            y1 = latitude,
                            x2 = i.longitude,
                            y2 = i.latitude,
                            paired = TRUE,
                            measure = "haversine")]
  res[, candidate_stop := all(dist <= 80), i.id]
  

Новое с реальными данными

Изменить с примером из реальных данных:

Это обрабатывает ситуацию с соединениями, но становится слишком большим слишком быстро. Это быстро, когда данных мало.

sm2 <- read.csv(file = "http://daniellemc.cool/sm.csv", row.names = NULL)
sm <- copy(sm2)
setDT(sm)
sm <- sm[, .(timestamp, longitude, latitude, id)]
sm[, timestamp := as.POSIXct(timestamp)]
sm[, id2 := id]

# This is problematic on my data because of how quickly it grows.
test <- sm[sm, on = .(id >= id)]
test[, i.id2 := NULL]
setnames(test, c("time.2", "longitude.2", "latitude.2", "id.1",
                 "id.2", "time.1", "longitude.1", "latitude.1"))


# Time and distance differences calculated between each pair
test[, distdiff := geodist_vec(longitude.1, latitude.1,
                               longitude.2, latitude.2,
                               paired = TRUE)]
test[, timediff := time.2 - time.1]

# Include the next distance to make sure there's at least one within distance and 
# over 180 timediff.
test[, nextdistdiff := shift(distdiff, -1), id.1]

# Are all distances within 180 sec within 80, and is the next following also < 80
test[, dist_met := FALSE]
test[timediff < 180, dist_met := all(distdiff < 80 & nextdistdiff < 80), id.1]
test[, dist_met := any(dist_met), id.1]

# Test how many occur consecutively 
# This keeps us from having > 80 dist but then coming back within 80
test[, consecutive := FALSE]
test[distdiff < 80, consecutive :=  c(TRUE, cummin(diff(id.2) == 1) == 1), id.1]
test[consecutive == TRUE & dist_met == TRUE, stop_id := min(id.1), id.2]
test[test[consecutive == TRUE & dist_met == TRUE], stop_id := i.stop_id, on = .(id.1 = id.2)]
test <- unique(test[, .(stop_id, id.1)])

# Join it back to the data.
sm[test, stop_id := stop_id, on = .(id = id.1)]


person Danielle McCool    schedule 21.04.2021    source источник


Ответы (1)


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

Поскольку data.table допускает только >, < или =, соединения сначала выполняются в прямоугольных областях, а затем отфильтровываются соответствующие расстояния. На приведенных вами реальных данных получается в 7 раз меньше вычислений.

library(data.table)
library(geosphere)

data <- copy(sm)

minduration <- 180
maxdistance <- 80

data[,latmin := destPoint(cbind(longitude,latitude),b = 180, d=maxdistance)[,2]]
data[,latmax := destPoint(cbind(longitude,latitude),b = 0 , d=maxdistance)[,2]]

data[,lonmin := destPoint(cbind(longitude,latitude),b = 270, d=maxdistance)[,1]]
data[,lonmax := destPoint(cbind(longitude,latitude),b = 90, d=maxdistance)[,1]]

data[,timestampmin := timestamp+minduration]

# Cross product with space and time windows
cross <- data[data,.(i.id,x.id,i.latitude,i.longitude,x.latitude,x.longitude,dist = distGeo(cbind(x.longitude,x.latitude),cbind(i.longitude,i.latitude)) ,i.timestamp,x.timestamp) 
              ,on=.(timestamp>timestampmin,
                    longitude >= lonmin,
                    longitude<=lonmax,
                    latitude >= latmin,
                    latitude <= latmax)][
                    dist<maxdistance]

# Summarizing the results
cross[,.(keep=cumsum(fifelse(diff(x.id-i.id)==1,1,NA_integer_))),by=i.id][
      !is.na(keep),.(startid = i.id,nextid = i.id+keep)][
      !(startid %in% nextid)][
      ,.(maxid=max(nextid)),by=startid][
      ,.(stopid = min(startid)),by=maxid]

     maxid stopid
  1:     6      1
  2:    18     10
  3:    26     22
  4:    33     28
  5:    48     40
 ---             
162:  4273   4269
163:  4276   4274
164:  4295   4294
165:  4303   4301
166:  4306   4305
person Waldi    schedule 26.04.2021
comment
Да, я думаю, что это время, когда упрощение latlon до одной переменной для простоты проблематично. Конечно, когда у вас есть пара координат широты и долготы, вы не можете отображать радиус, как со временем. Я мог бы строить окна по широте и долготе независимо друг от друга, но это дало бы мне квадрат. Я обновлю свой вопрос реальными данными. - person Danielle McCool; 26.04.2021
comment
с нетерпением жду реальных данных: работа с окнами также должна быть возможна для данных x/y - person Waldi; 26.04.2021
comment
Я обновил с реальными данными. К сожалению, это делает его менее предсказуемым, но мне кажется, что я не могу найти хороший баланс из-за природы этого типа данных. - person Danielle McCool; 26.04.2021
comment
дайте мне знать, если мое редактирование идет в правильном направлении. Количество обнаружений кажется важным, но я думаю, что они соблюдают правила, которые вы дали. - person Waldi; 26.04.2021
comment
широта/долгота min/max являются квадратными (imgur.com/JRNsxFI), но теоретически, поскольку квадрат содержит круг, это может уменьшить число, которое я проверяю. Вы проверяете истинное расстояние независимо. Я не могу точно сказать, что означает стоп-выход. Если это B.min, стопы не жадные. Если id1 формирует потенциальную остановку с id2 и id3, а id2 формирует потенциальную остановку с id3, id1 захватывает 2, а id2 захватывает 3, что предотвращает достаточное агрегирование стопов. Кроме того, B.all страдает от той же проблемы, что он может стать чрезмерно большим, потому что он присоединяется до проверки условий, чего мне нужно избегать. - person Danielle McCool; 26.04.2021
comment
Разрешение остановки с параметрами 80 метров и 180 секунд в данных возвращает 353 остановки с помощью stopFinder и 356 остановок с более явным алгоритмом, вставленным выше. - person Danielle McCool; 26.04.2021
comment
Смотрите мое обновление. Вы правы, это еще не жадный, я проверю это. - person Waldi; 26.04.2021
comment
Давайте продолжим обсуждение в чате. - person Danielle McCool; 26.04.2021
comment
@DanielleMcCool, онлайн в чате - person Waldi; 26.04.2021