web-dev-qa-db-fra.com

Approximation rapide de Haversine (Python/Pandas)

Chaque ligne d’un cadre de données Pandas contient les coordonnées lat/lng de 2 points. En utilisant le code Python ci-dessous, le calcul des distances entre ces 2 points pour plusieurs (millions) de lignes prend beaucoup de temps!

Étant donné que les 2 points sont séparés par moins de 50 milles et que la précision n’est pas très importante, est-il possible d’accélérer le calcul?

from math import radians, cos, sin, asin, sqrt
def haversine(lon1, lat1, lon2, lat2):
    """
    Calculate the great circle distance between two points 
    on the earth (specified in decimal degrees)
    """
    # convert decimal degrees to radians 
    lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2])
    # haversine formula 
    dlon = lon2 - lon1 
    dlat = lat2 - lat1 
    a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
    c = 2 * asin(sqrt(a)) 
    km = 6367 * c
    return km


for index, row in df.iterrows():
    df.loc[index, 'distance'] = haversine(row['a_longitude'], row['a_latitude'], row['b_longitude'], row['b_latitude'])
23
Nyxynyx

Voici une version numpy vectorisée de la même fonction:

import numpy as np

def haversine_np(lon1, lat1, lon2, lat2):
    """
    Calculate the great circle distance between two points
    on the earth (specified in decimal degrees)

    All args must be of equal length.    

    """
    lon1, lat1, lon2, lat2 = map(np.radians, [lon1, lat1, lon2, lat2])

    dlon = lon2 - lon1
    dlat = lat2 - lat1

    a = np.sin(dlat/2.0)**2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon/2.0)**2

    c = 2 * np.arcsin(np.sqrt(a))
    km = 6367 * c
    return km

Les entrées sont toutes des tableaux de valeurs, et il devrait pouvoir faire des millions de points instantanément. L’exigence est que les entrées soient ndarrays mais les colonnes de votre table de pandas fonctionneront.

Par exemple, avec des valeurs générées aléatoirement:

>>> import numpy as np
>>> import pandas
>>> lon1, lon2, lat1, lat2 = np.random.randn(4, 1000000)
>>> df = pandas.DataFrame(data={'lon1':lon1,'lon2':lon2,'lat1':lat1,'lat2':lat2})
>>> km = haversine_np(df['lon1'],df['lat1'],df['lon2'],df['lat2'])

La lecture en boucle dans des tableaux de données est très lente en python. Numpy fournit des fonctions qui fonctionnent sur des tableaux de données entiers, ce qui vous permet d'éviter les boucles et d'améliorer considérablement les performances.

Ceci est un exemple de vectorisation .

53
derricw

Pour illustrer mon propos, j'ai pris la version de numpy dans la réponse de @ballsdotballs et j'ai également créé une implémentation C complémentaire qui doit être appelée via ctypes. Puisque numpy est un outil hautement optimisé, mon code C a peu de chances d'être aussi efficace, mais il devrait être un peu proche. Le gros avantage ici est que, en parcourant un exemple avec des types C, vous pouvez voir comment vous pouvez connecter vos propres fonctions C personnelles à Python sans trop de frais supplémentaires. C’est particulièrement agréable lorsque vous souhaitez simplement optimiser un petit morceau d’un calcul plus volumineux en écrivant ce petit morceau dans une source C plutôt que dans Python. Le simple fait d'utiliser numpy résoudra le problème la plupart du temps, mais dans les cas où vous n'avez pas vraiment besoin de tous les éléments de numpy et que vous ne souhaitez pas ajouter le couplage pour nécessiter l'utilisation de numpy types de données dans certains codes, il est très pratique de savoir comment accéder à la bibliothèque intégrée ctypes et le faire vous-même.

Commençons par créer notre fichier source C, appelé haversine.c:

#include <stdlib.h>
#include <stdio.h>
#include <math.h>

int haversine(size_t n, 
              double *lon1, 
              double *lat1, 
              double *lon2, 
              double *lat2,
              double *kms){

    if (   lon1 == NULL 
        || lon2 == NULL 
        || lat1 == NULL 
        || lat2 == NULL
        || kms == NULL){
        return -1;
    }

    double km, dlon, dlat;
    double iter_lon1, iter_lon2, iter_lat1, iter_lat2;

    double km_conversion = 2.0 * 6367.0; 
    double degrees2radians = 3.14159/180.0;

    int i;
    for(i=0; i < n; i++){
        iter_lon1 = lon1[i] * degrees2radians;
        iter_lat1 = lat1[i] * degrees2radians;
        iter_lon2 = lon2[i] * degrees2radians;
        iter_lat2 = lat2[i] * degrees2radians;

        dlon = iter_lon2 - iter_lon1;
        dlat = iter_lat2 - iter_lat1;

        km = pow(sin(dlat/2.0), 2.0) 
           + cos(iter_lat1) * cos(iter_lat2) * pow(sin(dlon/2.0), 2.0);

        kms[i] = km_conversion * asin(sqrt(km));
    }

    return 0;
}

// main function for testing
int main(void) {
    double lat1[2] = {16.8, 27.4};
    double lon1[2] = {8.44, 1.23};
    double lat2[2] = {33.5, 20.07};
    double lon2[2] = {14.88, 3.05};
    double kms[2]  = {0.0, 0.0};
    size_t arr_size = 2;

    int res;
    res = haversine(arr_size, lon1, lat1, lon2, lat2, kms);
    printf("%d\n", res);

    int i;
    for (i=0; i < arr_size; i++){
        printf("%3.3f, ", kms[i]);
    }
    printf("\n");
}

Notez que nous essayons de rester avec les conventions C. Passage explicite d'arguments de données par référence, en utilisant size_t pour une variable de taille et en espérant que notre fonction haversine fonctionnera en mutant l'une des entrées passées de sorte qu'elle contienne les données attendues à la sortie. La fonction renvoie en fait un entier, qui est un indicateur de réussite/échec pouvant être utilisé par d'autres consommateurs de niveau C de la fonction. 

Nous allons devoir trouver un moyen de gérer tous ces petits problèmes spécifiques à C dans Python.

Ensuite, mettons notre version numpy de la fonction avec quelques importations et quelques données de test dans un fichier appelé haversine.py:

import time
import ctypes
import numpy as np
from math import radians, cos, sin, asin, sqrt

def haversine(lon1, lat1, lon2, lat2):
    """
    Calculate the great circle distance between two points 
    on the earth (specified in decimal degrees)
    """
    # convert decimal degrees to radians 
    lon1, lat1, lon2, lat2 = map(np.radians, [lon1, lat1, lon2, lat2])
    # haversine formula 
    dlon = lon2 - lon1 
    dlat = lat2 - lat1 
    a = (np.sin(dlat/2)**2 
         + np.cos(lat1) * np.cos(lat2) * np.sin(dlon/2)**2)
    c = 2 * np.arcsin(np.sqrt(a)) 
    km = 6367 * c
    return km

if __== "__main__":
    lat1 = 50.0 * np.random.Rand(1000000)
    lon1 = 50.0 * np.random.Rand(1000000)
    lat2 = 50.0 * np.random.Rand(1000000)
    lon2 = 50.0 * np.random.Rand(1000000)

    t0 = time.time()
    r1 = haversine(lon1, lat1, lon2, lat2)
    t1 = time.time()
    print t1-t0, r1

J'ai choisi de faire des lats et des lons (en degrés) choisis aléatoirement entre 0 et 50, mais peu importe cette explication.

La prochaine chose que nous devons faire est de compiler notre module C de manière à ce qu’il puisse être chargé dynamiquement par Python. J'utilise un système Linux (vous pouvez trouver très facilement des exemples pour d'autres systèmes sur Google). Mon objectif est donc de compiler haversine.c dans un objet partagé, comme suit:

gcc -shared -o haversine.so -fPIC haversine.c -lm

Nous pouvons également compiler un fichier exécutable et l'exécuter pour voir ce que la fonction main du programme C affiche:

> gcc haversine.c -o haversine -lm
> ./haversine
0
1964.322, 835.278, 

Maintenant que nous avons compilé l'objet partagé haversine.so, nous pouvons utiliser ctypes pour le charger en Python et nous devons fournir le chemin d'accès au fichier pour le faire:

lib_path = "/path/to/haversine.so" # Obviously use your real path here.
haversine_lib = ctypes.CDLL(lib_path)

Désormais, haversine_lib.haversine se comporte plutôt comme une fonction Python, sauf que nous aurons peut-être besoin d'effectuer un marshaling de type manuel pour nous assurer que les entrées et les sorties sont interprétées correctement.

numpy fournit en fait des outils utiles pour cela et celui que je vais utiliser ici est numpy.ctypeslib. Nous allons construire un type de type pointeur qui nous permettra de transmettre numpy.ndarrays à ces fonctions ctypes- chargées comme si elles étaient des pointeurs. Voici le code:

arr_1d_double = np.ctypeslib.ndpointer(dtype=np.double, 
                                       ndim=1, 
                                       flags='CONTIGUOUS')

haversine_lib.haversine.restype = ctypes.c_int
haversine_lib.haversine.argtypes = [ctypes.c_size_t,
                                    arr_1d_double, 
                                    arr_1d_double,
                                    arr_1d_double,
                                    arr_1d_double,
                                    arr_1d_double] 

Notez que nous demandons au proxy de fonction haversine_lib.haversine d'interpréter ses arguments en fonction des types souhaités.

Maintenant, pour le tester depuis Python, il ne reste plus qu’à créer une variable de taille et un tableau qui sera muté (comme dans le code C) pour contenir les données de résultat, nous pouvons l’appeler:

size = len(lat1)
output = np.empty(size, dtype=np.double)
print "====="
print output
t2 = time.time()
res = haversine_lib.haversine(size, lon1, lat1, lon2, lat2, output)
t3 = time.time()
print t3 - t2, res
print type(output), output

En regroupant le tout dans le bloc __main__ de haversine.py, l'ensemble du fichier se présente désormais comme suit:

import time
import ctypes
import numpy as np
from math import radians, cos, sin, asin, sqrt

def haversine(lon1, lat1, lon2, lat2):
    """
    Calculate the great circle distance between two points 
    on the earth (specified in decimal degrees)
    """
    # convert decimal degrees to radians 
    lon1, lat1, lon2, lat2 = map(np.radians, [lon1, lat1, lon2, lat2])
    # haversine formula 
    dlon = lon2 - lon1 
    dlat = lat2 - lat1 
    a = (np.sin(dlat/2)**2 
         + np.cos(lat1) * np.cos(lat2) * np.sin(dlon/2)**2)
    c = 2 * np.arcsin(np.sqrt(a)) 
    km = 6367 * c
    return km

if __== "__main__":
    lat1 = 50.0 * np.random.Rand(1000000)
    lon1 = 50.0 * np.random.Rand(1000000)
    lat2 = 50.0 * np.random.Rand(1000000)
    lon2 = 50.0 * np.random.Rand(1000000)

    t0 = time.time()
    r1 = haversine(lon1, lat1, lon2, lat2)
    t1 = time.time()
    print t1-t0, r1

    lib_path = "/home/ely/programming/python/numpy_ctypes/haversine.so"
    haversine_lib = ctypes.CDLL(lib_path)
    arr_1d_double = np.ctypeslib.ndpointer(dtype=np.double, 
                                           ndim=1, 
                                           flags='CONTIGUOUS')

    haversine_lib.haversine.restype = ctypes.c_int
    haversine_lib.haversine.argtypes = [ctypes.c_size_t,
                                        arr_1d_double, 
                                        arr_1d_double,
                                        arr_1d_double,
                                        arr_1d_double,
                                        arr_1d_double]

    size = len(lat1)
    output = np.empty(size, dtype=np.double)
    print "====="
    print output
    t2 = time.time()
    res = haversine_lib.haversine(size, lon1, lat1, lon2, lat2, output)
    t3 = time.time()
    print t3 - t2, res
    print type(output), output

Pour l'exécuter, qui exécutera et chronométrera séparément les versions Python et ctypes et imprimera quelques résultats, nous pouvons simplement faire

python haversine.py

qui affiche:

0.111340045929 [  231.53695005  3042.84915093   169.5158946  ...,  1359.2656769
  2686.87895954  3728.54788207]
=====
[  6.92017600e-310   2.97780954e-316   2.97780954e-316 ...,
   3.20676686e-001   1.31978329e-001   5.15819721e-001]
0.148446083069 0
<type 'numpy.ndarray'> [  231.53675618  3042.84723579   169.51575588 ...,  1359.26453029
  2686.87709456  3728.54493339]

Comme prévu, la version numpy est légèrement plus rapide (0,11 seconde pour les vecteurs d'une longueur de 1 million) mais notre version rapide et sale ctypes n'est pas en reste: un respectable de 0,148 seconde sur les mêmes données.

Comparons ceci à une solution naïve de boucles for en Python:

from math import radians, cos, sin, asin, sqrt

def slow_haversine(lon1, lat1, lon2, lat2):
    n = len(lon1)
    kms = np.empty(n, dtype=np.double)
    for i in range(n):
       lon1_v, lat1_v, lon2_v, lat2_v = map(
           radians, 
           [lon1[i], lat1[i], lon2[i], lat2[i]]
       )

       dlon = lon2_v - lon1_v 
       dlat = lat2_v - lat1_v 
       a = (sin(dlat/2)**2 
            + cos(lat1_v) * cos(lat2_v) * sin(dlon/2)**2)
       c = 2 * asin(sqrt(a)) 
       kms[i] = 6367 * c
    return kms

Lorsque je mets cela dans le même fichier Python que les autres et que je le fais sur le même million de données, je vois constamment un temps d'environ 2,65 secondes sur ma machine.

Ainsi, en passant rapidement à ctypes, nous améliorons la vitesse d'un facteur d'environ 18. Pour de nombreux calculs qui peuvent bénéficier d'un accès à des données nues et contiguës, vous constaterez souvent des gains beaucoup plus élevés que cela.

Pour être tout à fait clair, je n’approuve pas du tout que cela soit une meilleure option que d’utiliser numpy. C’est précisément le problème que numpy a été conçu pour résoudre le problème. Il est donc logique de créer votre propre code ctypes chaque fois qu’il est logique (a) d’incorporer les types de données numpy dans votre application et ) il existe un moyen simple de mapper votre code dans un équivalent numpy, n’est pas très efficace.

Mais il est toujours très utile de savoir comment faire cela pour les occasions où vous préférez écrire quelque chose en C et l'appelez-le en Python, ou dans les situations où une dépendance à numpy n'est pas pratique (dans un système embarqué où numpy ne peut pas être installé, par exemple).

11
ely

Dans le cas où l'utilisation de scikit-learn est autorisée, je donnerais la chance suivante:

from sklearn.neighbors import DistanceMetric
dist = DistanceMetric.get_metric('haversine')

# example data
lat1, lon1 = 36.4256345, -5.1510261
lat2, lon2 = 40.4165, -3.7026
lon1, lat1, lon2, lat2 = map(np.radians, [lon1, lat1, lon2, lat2])

X = [[lat1, lon1],
     [lat2, lon2]]
kms = 6367
print(kms * dist.pairwise(X))
8
Kraviz

Une extension triviale de la solution vectorisée de @ derricw , vous pouvez utiliser numba pour améliorer les performances de ~ 2x sans pratiquement modifier votre code. Pour les calculs numériques purs, cela devrait probablement être utilisé pour des tests comparatifs/des tests par rapport à des solutions éventuellement plus efficaces.

from numba import njit

@njit
def haversine_nb(lon1, lat1, lon2, lat2):
    lon1, lat1, lon2, lat2 = np.radians(lon1), np.radians(lat1), np.radians(lon2), np.radians(lat2)
    dlon = lon2 - lon1
    dlat = lat2 - lat1
    a = np.sin(dlat/2.0)**2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon/2.0)**2
    return 6367 * 2 * np.arcsin(np.sqrt(a))

Analyse comparative par rapport à la fonction pandas:

%timeit haversine_pd(df['lon1'], df['lat1'], df['lon2'], df['lat2'])
# 1 loop, best of 3: 1.81 s per loop

%timeit haversine_nb(df['lon1'].values, df['lat1'].values, df['lon2'].values, df['lat2'].values)
# 1 loop, best of 3: 921 ms per loop

Code de benchmarking complet:

import pandas as pd, numpy as np
from numba import njit

def haversine_pd(lon1, lat1, lon2, lat2):
    lon1, lat1, lon2, lat2 = map(np.radians, [lon1, lat1, lon2, lat2])
    dlon = lon2 - lon1
    dlat = lat2 - lat1
    a = np.sin(dlat/2.0)**2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon/2.0)**2
    return 6367 * 2 * np.arcsin(np.sqrt(a))

@njit
def haversine_nb(lon1, lat1, lon2, lat2):
    lon1, lat1, lon2, lat2 = np.radians(lon1), np.radians(lat1), np.radians(lon2), np.radians(lat2)
    dlon = lon2 - lon1
    dlat = lat2 - lat1
    a = np.sin(dlat/2.0)**2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon/2.0)**2
    return 6367 * 2 * np.arcsin(np.sqrt(a))

np.random.seed(0)
lon1, lon2, lat1, lat2 = np.random.randn(4, 10**7)
df = pd.DataFrame(data={'lon1':lon1,'lon2':lon2,'lat1':lat1,'lat2':lat2})
km = haversine_pd(df['lon1'], df['lat1'], df['lon2'], df['lat2'])
km_nb = haversine_nb(df['lon1'].values, df['lat1'].values, df['lon2'].values, df['lat2'].values)

assert np.isclose(km.values, km_nb).all()

%timeit haversine_pd(df['lon1'], df['lat1'], df['lon2'], df['lat2'])
# 1 loop, best of 3: 1.81 s per loop

%timeit haversine_nb(df['lon1'].values, df['lat1'].values, df['lon2'].values, df['lat2'].values)
# 1 loop, best of 3: 921 ms per loop
1
jpp

Certaines de ces réponses "arrondissent" le rayon de la terre. Si vous les comparez à d'autres calculateurs de distance (tels que geopy ), ces fonctions seront désactivées.

Vous pouvez désactiver R=3959.87433 pour la constante de conversion ci-dessous si vous souhaitez obtenir la réponse en miles. 

Si vous voulez des kilomètres, utilisez R= 6372.8.

lon1 = -103.548851
lat1 = 32.0004311
lon2 = -103.6041946
lat2 = 33.374939


def haversine(lat1, lon1, lat2, lon2):

      R = 3959.87433 # this is in miles.  For Earth radius in kilometers use 6372.8 km

      dLat = radians(lat2 - lat1)
      dLon = radians(lon2 - lon1)
      lat1 = radians(lat1)
      lat2 = radians(lat2)

      a = sin(dLat/2)**2 + cos(lat1)*cos(lat2)*sin(dLon/2)**2
      c = 2*asin(sqrt(a))

      return R * c

print(haversine(lat1, lon1, lat2, lon2))
0
Clay