Географическое расстояние по группам - применение функции к каждой паре строк - PullRequest
3 голосов
/ 11 апреля 2019

Я хочу рассчитать среднее географическое расстояние между количеством домов в провинции.

Предположим, у меня есть следующие данные.

df1 <- data.frame(province = c(1, 1, 1, 2, 2, 2),
              house = c(1, 2, 3, 4, 5, 6),
              lat = c(-76.6, -76.5, -76.4, -75.4, -80.9, -85.7), 
              lon = c(39.2, 39.1, 39.3, 60.8, 53.3, 40.2))

Используя библиотеку geosphere, я могу найтиРасстояние между двумя домами.Например:

library(geosphere)
distm(c(df1$lon[1], df1$lat[1]), c(df1$lon[2], df1$lat[2]), fun = distHaversine)

#11429.1

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

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

Ответы [ 6 ]

5 голосов
/ 14 апреля 2019

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

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

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

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

sample.size=1e6    
lapply(split(df1[3:4], df1$province), 
  function(x) {
    s1 = x[sample(nrow(x), sample.size, T), ]
    s2 = x[sample(nrow(x), sample.size, T), ]
    mean(distHaversine(s1, s2))
  })

Некоторые большие данные для проверки:

N=1e6
df1 <- data.frame(
  province = c(rep(1,N),rep(2,N)),
  house = 1:(2*N),
  lat = c(rnorm(N,-76), rnorm(N,-85)), 
  lon = c(rnorm(N,39), rnorm(N,-55,2)))

Чтобы получить представление о точности этого метода, мы можем использовать начальную загрузку.Для следующей демонстрации я использую только 100 000 строк данных, чтобы мы могли выполнить 1000 итераций начальной загрузки за короткое время:

N=1e5
df1 <- data.frame(lat = rnorm(N,-76,0.1), lon = rnorm(N,39,0.1))

dist.f = function(i) {
    s1 = df1[sample(N, replace = T), ]
    s2 = df1[sample(N, replace = T), ]
    mean(distHaversine(s1, s2))
    }

boot.dist = sapply(1:1000, dist.f)
mean(boot.dist)
# [1] 17580.63
sd(boot.dist)
# [1] 29.39302

hist(boot.dist, 20) 

Т.е. для этих тестовых данных среднее расстояние составляет 17 580 +/- 29 м.,Это коэффициент вариации 0,1%, который достаточно точен для большинства целей.Как я уже сказал, вы можете получить больше точности, увеличив размер выборки, если вам это действительно нужно.

enter image description here

5 голосов
/ 13 апреля 2019

Моей первоначальной идеей было посмотреть на исходный код distHaversine и скопировать его в функцию, которую я бы использовал с proxy.Это будет работать так (обратите внимание, что lon, как ожидается, будет первым столбцом):

library(geosphere)
library(dplyr)
library(proxy)

df1 <- data.frame(province = as.integer(c(1, 1, 1, 2, 2, 2)),
                  house = as.integer(c(1, 2, 3, 4, 5, 6)),
                  lat = c(-76.6, -76.5, -76.4, -75.4, -80.9, -85.7), 
                  lon = c(39.2, 39.1, 39.3, 60.8, 53.3, 40.2))

custom_haversine <- function(x, y) {
  toRad <- pi / 180

  diff <- (y - x) * toRad
  dLon <- diff[1L]
  dLat <- diff[2L]

  a <- sin(dLat / 2) ^ 2 + cos(x[2L] * toRad) * cos(y[2L] * toRad) * sin(dLon / 2) ^ 2
  a <- min(a, 1)
  # return
  2 * atan2(sqrt(a), sqrt(1 - a)) * 6378137
}

pr_DB$set_entry(FUN=custom_haversine, names="haversine", loop=TRUE, distance=TRUE)

average_dist <- df1 %>%
  select(-house) %>%
  group_by(province) %>%
  group_map(~ data.frame(avg=mean(proxy::dist(.x[ , c("lon", "lat")], method="haversine"))))

Однако, если вы ожидаете миллионы строк на провинцию, proxy, вероятно, не будетвозможность выделить промежуточные (нижние треугольники) матрицы.Поэтому я перенес код на C ++ и добавил многопоточность в качестве бонуса:

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

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

// [[Rcpp::plugins(cpp11)]]
// [[Rcpp::depends(RcppParallel,RcppThread)]]

#include <cstddef> // size_t
#include <math.h> // sin, cos, sqrt, atan2, pow
#include <vector>

#include <RcppThread.h>
#include <Rcpp.h>
#include <RcppParallel.h>

using namespace std;
using namespace Rcpp;
using namespace RcppParallel;

// single to double indices for lower triangular of matrices without diagonal
void s2d(const size_t id, const size_t nrow, size_t& i, size_t& j) {
  j = nrow - 2 - static_cast<size_t>(sqrt(-8 * id + 4 * nrow * (nrow - 1) - 7) / 2 - 0.5);
  i = id + j + 1 - nrow * (nrow - 1) / 2 + (nrow - j) * ((nrow - j) - 1) / 2;
}

class HaversineCalculator : public Worker
{
public:
  HaversineCalculator(const NumericVector& lon,
                      const NumericVector& lat,
                      double& avg,
                      const int n)
    : lon_(lon)
    , lat_(lat)
    , avg_(avg)
    , n_(n)
    , cos_lat_(lon.length())
  {
    // terms for distance calculation
    for (size_t i = 0; i < cos_lat_.size(); i++) {
      cos_lat_[i] = cos(lat_[i] * 3.1415926535897 / 180);
    }
  }

  void operator()(size_t begin, size_t end) {
    // for Kahan summation
    double sum = 0;
    double c = 0;

    double to_rad = 3.1415926535897 / 180;

    size_t i, j;
    for (size_t ind = begin; ind < end; ind++) {
      if (RcppThread::isInterrupted(ind % static_cast<int>(1e5) == 0)) return;

      s2d(ind, lon_.length(), i, j);

      // haversine distance
      double d_lon = (lon_[j] - lon_[i]) * to_rad;
      double d_lat = (lat_[j] - lat_[i]) * to_rad;
      double d_hav = pow(sin(d_lat / 2), 2) + cos_lat_[i] * cos_lat_[j] * pow(sin(d_lon / 2), 2);
      if (d_hav > 1) d_hav = 1;
      d_hav = 2 * atan2(sqrt(d_hav), sqrt(1 - d_hav)) * 6378137;

      // the average part
      d_hav /= n_;

      // Kahan sum step
      double y = d_hav - c;
      double t = sum + y;
      c = (t - sum) - y;
      sum = t;
    }

    mutex_.lock();
    avg_ += sum;
    mutex_.unlock();
  }

private:
  const RVector<double> lon_;
  const RVector<double> lat_;
  double& avg_;
  const int n_;
  tthread::mutex mutex_;
  vector<double> cos_lat_;
};

// [[Rcpp::export]]
double avg_haversine(const DataFrame& input, const int nthreads) {
  NumericVector lon = input["lon"];
  NumericVector lat = input["lat"];

  double avg = 0;
  int size = lon.length() * (lon.length() - 1) / 2;
  HaversineCalculator hc(lon, lat, avg, size);

  int grain = size / nthreads / 10;
  RcppParallel::parallelFor(0, size, hc, grain);
  RcppThread::checkUserInterrupt();

  return avg;
}

Этот код не будет выделять какую-либо промежуточную матрицу, он просто вычислит расстояние для каждой пары того, что должно быть нижней треугольной, и накапливает значения для среднего значения в конце.См. здесь для части суммирования Кахана.

Если вы сохраните этот код, скажем, haversine.cpp, то вы можете сделать следующее:

library(dplyr)
library(Rcpp)
library(RcppParallel)
library(RcppThread)

sourceCpp("haversine.cpp")

df1 %>%
  group_by(province) %>%
  group_map(~ data.frame(avg=avg_haversine(.x, parallel::detectCores())))
# A tibble: 2 x 2
# Groups:   province [2]
  province     avg
     <int>   <dbl>
1        1  15379.
2        2 793612.

Вотпроверка работоспособности тоже:

pr_DB$set_entry(FUN=geosphere::distHaversine, names="distHaversine", loop=TRUE, distance=TRUE)

df1 %>%
  select(-house) %>%
  group_by(province) %>%
  group_map(~ data.frame(avg=mean(proxy::dist(.x[ , c("lon", "lat")], method="distHaversine"))))

Слово предостережения:

df <- data.frame(lon=runif(1e3, -90, 90), lat=runif(1e3, -90, 90))

system.time(proxy::dist(df, method="distHaversine"))
   user  system elapsed 
 34.353   0.005  34.394

system.time(proxy::dist(df, method="haversine"))
   user  system elapsed 
  0.789   0.020   0.809

system.time(avg_haversine(df, 4L))
   user  system elapsed 
  0.054   0.000   0.014

df <- data.frame(lon=runif(1e5, -90, 90), lat=runif(1e5, -90, 90))

system.time(avg_haversine(df, 4L))
   user  system elapsed 
 73.861   0.238  19.670

Вам, вероятно, придется подождать некоторое время, если у вас есть миллионы строк ...

Я должен также упомянуть, что невозможно обнаружить пользовательское прерывание в потоках, созданных с помощью RcppParallel, поэтому, если вы начнете вычисление, вам следует либо дождаться его завершения, либо полностью перезапустить R / RStudio. См. EDIT2 выше.


Относительно сложности

В зависимости от ваших фактических данных и количества ядер, имеющихся на вашем компьютере, вы вполне можете закончить в ожидании завершения расчетов.Эта проблема имеет квадратичную сложность (так сказать, на провинцию).Эта строка:

int size = lon.length() * (lon.length() - 1) / 2;

обозначает количество вычислений расстояния (haversine), которое необходимо выполнить.Таким образом, если количество строк увеличивается в n, то количество вычислений увеличивается, в общем, в n^2 / 2.

Нет способа оптимизировать это;Вы не можете вычислить среднее число N чисел без фактического вычисления каждого числа в первую очередь, и вам будет трудно найти что-то более быстрое, чем многопоточный код C ++, поэтому вам придется либо подождать, либо выброситьбольше ядер в проблеме, либо с одной машиной, либо с множеством машин, работающих вместе.В противном случае вы не сможете решить эту проблему.

4 голосов
/ 13 апреля 2019

Решение:

lapply(split(df1, df1$province), function(df){
  df <- Expand.Grid(df[, c("lat", "lon")], df[, c("lat", "lon")])
  mean(distHaversine(df[, 1:2], df[, 3:4]))
})

, где Expand.Grid() взято из https://stackoverflow.com/a/30085602/3502164.

Пояснение:

1. Производительность

Я бы не стал использовать distm(), поскольку он преобразует векторизованную функцию distHaversine() в невекторизованную distm(). Если вы посмотрите на исходный код, вы увидите:

function (x, y, fun = distHaversine) 
{
   [...]
   for (i in 1:n) {
        dm[i, ] = fun(x[i, ], y)
    }
    return(dm)
}

В то время как distHaversine() отправляет «весь объект» в C, distm() отправляет данные «по строкам» в distHaversine() и поэтому заставляет distHaversine() делать то же самое при выполнении кода в C. Следовательно, distm() не следует использовать. С точки зрения производительности, я вижу больше вреда при использовании функции оболочки distm(), так как вижу преимущества.

2. Объяснение кода в «решении»:

а) Разделение на группы:

Вы хотите проанализировать данные по группе: провинция. Разбить на группы можно: split(df1, df1$province).

б) Группировка "глыбы колонн"

Вы хотите найти все уникальные комбинации широты / долготы. Первое предположение может быть expand.grid(), но это не работает для нескольких столбцов. К счастью, мистер Флик позаботился об этой функции expand.grid для data.frames в R .

Тогда у вас есть data.frame() всех возможных комбинаций и вам просто нужно использовать mean(distHaversine(...)).

1 голос
/ 12 апреля 2019

В отношении этой нити векторизованное решение вашей проблемы будет выглядеть следующим образом:

toCheck <- sapply(split(df1, df1$province), function(x){
                                            combn(rownames(x), 2, simplify = FALSE)})

names(toCheck) <- sapply(toCheck, paste, collapse = " - ")


sapply(toCheck, function(x){
               distm(df1[x[1],c("lon","lat")], df1[x[2],c("lon","lat")], 
                     fun = distHaversine)
                           })


  #    1 - 2      1 - 3      2 - 3      4 - 5      4 - 6      5 - 6 
  # 11429.10   22415.04   12293.48  634549.20 1188925.65  557361.28 

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


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

df2 <- df2[order(df2$province),] #sorting may even improve performance
names(toCheck) <- paste("province", unique(df2$province))

toCheck <- sapply(split(df2, df2$province), function(x){
                                            combn(rownames(x), 2, simplify = FALSE)})

sapply(toCheck, function(x){ sapply(x, function(y){
  distm(df2[y[1],c("lon","lat")], df2[y[2],c("lon","lat")], fun = distHaversine)
})})

# $`province 1`
# [1]   11429.10   22415.04 1001964.84   12293.48 1013117.36 1024209.46
# 
# $`province 2`
# [1]  634549.2 1188925.7  557361.3
# 
# $`province 3`
# [1] 590083.2
# 
# $`province 4`
# [1] 557361.28 547589.19  11163.92

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

df2 <- data.frame(province = c(1, 1, 1, 2, 2, 2, 1, 3, 3, 4,4,4),
                  house = c(1, 2, 3, 4, 5, 6, 7, 10, 9, 8, 11, 12),
                  lat = c(-76.6, -76.5, -76.4, -75.4, -80.9, -85.7, -85.6, -76.4, -75.4, -80.9, -85.7, -85.6), 
                  lon = c(39.2, 39.1, 39.3, 60.8, 53.3, 40.2, 40.1, 39.3, 60.8, 53.3, 40.2, 40.1))
0 голосов
/ 14 апреля 2019

Вы можете использовать векторизованную версию расстояния haversine, такую ​​как:

dist_haversine_for_dfs <- function (df_x, df_y, lat, r = 6378137) 
{
  if(!all(c("lat", "lon") %in% names(df_x))) {
    stop("parameter df_x does not have column 'lat' and 'lon'")
  }
  if(!all(c("lat", "lon") %in% names(df_y))) {
    stop("parameter df_x does not have column 'lat' and 'lon'")
  }
  toRad <- pi/180
  df_x <- df_x * toRad
  df_y <- df_y * toRad
  dLat <- df_y[["lat"]] - df_x[["lat"]]
  dLon <- df_y[["lon"]] - df_x[["lon"]]
  a <- sin(dLat/2) * sin(dLat/2) + cos(df_x[["lat"]]) * cos(df_y[["lat"]]) * 
    sin(dLon/2) * sin(dLon/2)
  a <- pmin(a, 1)
  dist <- 2 * atan2(sqrt(a), sqrt(1 - a)) * r
  return(dist)
}

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

library(data.table)
dt <- data.table(df1)
ids <- dt[, {
  comb_mat <- arrangements::combinations(x = house, k = 2)
  list(house_x = comb_mat[, 1],
       house_y = comb_mat[, 2])}, by = province]

jdt <- cbind(ids, 
             dt[ids$house_x, .(lon_x=lon, lat_x=lat)], 
             dt[ids$house_y, .(lon_y=lon, lat_y=lat)])

jdt[, dist := dist_haversine_for_dfs(df_x = jdt[, .(lon = lon.x, lat = lat.x)],
                                     df_y = jdt[, .(lon = lon.y, lat = lat.y)])]

jdt[, .(mean_dist = mean(dist)), by = province]

который выводит

   province mean_dist
1:        1  15379.21
2:        2 793612.04
0 голосов
/ 11 апреля 2019

Мои 10 центов.Вы можете:

# subset the province
df1 <- df1[which(df1$province==1),]

# get all combinations
all <- combn(df1$house, 2, FUN = NULL, simplify = TRUE)

# run your function and get distances for all combinations
distances <- c()
for(col in 1:ncol(all)) {
  a <- all[1, col]
  b <- all[2, col]
  dist <- distm(c(df1$lon[a], df1$lat[a]), c(df1$lon[b], df1$lat[b]), fun = distHaversine)
  distances <- c(distances, dist)
  }

# calculate mean:
mean(distances)
# [1] 15379.21

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

df1 <- df1[which(df1$province==1),]
mean(sapply(split(df1, df1$province), dist))
# [1] 1.349036

Как видите, он дает разные результаты, потому что функция dist может рассчитывать расстояния различного типа (например, евклидова) и не может делатьhaversine или другие "геодезические" расстояния.Пакет geodist, кажется, имеет опции, которые могут приблизить вас к sapply:

library(geodist)
library(magrittr)

# defining the data
df1 <- data.frame(province = c(1, 1, 1, 2, 2, 2),
                  house = c(1, 2, 3, 4, 5, 6),
                  lat = c(-76.6, -76.5, -76.4, -75.4, -80.9, -85.7), 
                  lon = c(39.2, 39.1, 39.3, 60.8, 53.3, 40.2))

# defining the function 
give_distance <- function(resultofsplit){
  distances <- c()
  for (i in 1:length(resultofsplit)){
    sdf <- resultofsplit
    sdf <- sdf[[i]]
    sdf <- sdf[c("lon", "lat", "province", "house")]

    sdf2 <- as.matrix(sdf)
    sdf3 <- geodist(x=sdf2, measure="haversine")
    sdf4 <- unique(as.vector(sdf3))
    sdf4 <- sdf4[sdf4 != 0]        # this is to remove the 0-distances 
    mean_dist <- mean(sdf4)
    distances <- c(distances, mean_dist)
    }  
    return(distances)
}

split(df1, df1$province) %>% give_distance()
#[1]  15379.21 793612.04

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

...