#!/usr/bin/env python # coding: utf-8 # #
Térképes, geolokációs adatok felhasználása
# ### Háttér # # A térképes, geolokációs adatok felhasználása ma már az adatelemzési repertoár magától értetődő része. # # Minden jelentősebb vizualizációs szoftver képes térképes adatok megjelenítésére, e tekintetben tehát remek a helyzet. Előfordulhat ugyan, hogy nem vagyunk kibékülve az alapbeállításként használt Google Maps, OpenStreetMap, vagy Bing Maps egyik-másik tulajdonságával, komoly fejfájást azonban nem fognak okozni. # ### A sült galamb nem repül a szánkba # # A megjelenítéshez azonban **elő kell készíteni** a térképes adatokat, s a rendelkezésre álló adatszetten túl **gyakran kell (kéne) külső forrásból kiegészítő információkat beszereznünk**. Erre mutatok három példát, megvalósításukkal együtt: # 1. valós címek földrajzi koordinátákká alakítása # 2. földrajzi pontok közötti távolság légvonalban # 3. földrajzi pontok közötti távolság közúton # ### Az illusztráció által használt technológia # # Python 3.6 szoftverkörnyezetben, az Anaconda csomag, és +1 letölthető csomag használatával mutatok be egy egyszerűbb megoldást. A Google térképszolgáltatásának, a Google Maps-nek a programozási interface-ét (API-ját) is használni fogom. # # A teljes kód letölthető Jupyter Notebook (korábbi nevén: IPython Notebook) formátumban. # ### Adatkör # # Tegyük föl, hogy különböző magyar városok polgármesteri hivatalainak címe áll rendelkezésre, szépen, rendezett formában, egy Excel-fájlban. # In[1]: import haversine # légvonalban mért földrajzi távolság import numpy as np import pandas as pd import requests # HTTP lekérdezések, ezt használjuk a Google Maps API-k eléréséhez import time # késleltetés import xlsxwriter # mentés Excel fájlba # In[2]: pd.set_option("display.max_rows", 12) # Beolvasom Excelből a címeket, és a Python Pandas csomagjának DataFrame objektumában (egyfajta speciális táblázatban) tárolom. # In[3]: df = pd.read_excel("Városháza.xlsx") df.head() # áttekintés; csak az első néhány értéket jelenítjük meg # ### 1) Címek átalakítása földrajzi koordinátákká # # Első feladatként alakítsuk a címeket pontos földrajzi koordinátákká! A _**Google Maps Geocoding API**_ felhasználásával egyszerűen meg tudjuk tenni, legalábbis ha nem túl sok címről van szó. # # A felhasználási kvóta (most) **napi 2500 lekérdezés** – fejlesztési fázisban érdemes tehát a teljes adatkörnek csupán egy szeletén próbálkozni, s majd csak a kész verzióban lekérdezni az összes szükséges címet. API „kulcsot” igényelni [ezen a címen lehet](https://developers.google.com/maps/documentation/geocoding/get-api-key), ezt minden lekérdezésbe bele kell építeni (lásd a forráskódban). # # A Google védekezik szervereinek túlterhelése ellen, ezért **kisebb várakozást is érdemes beiktatni** az egyes lekérdezések közé. 3 másodperc elég lehet, így persze lassulnak a lekérdezések, lehet tehát próbálkozni, és feszegetni a határokat :) # In[4]: # konstansok, amit használni fogunk (Python nyelvben valódi konstansok híján: később szándékosan meg nem változtatott változók) # Google Maps API kulcsok, például itt lehet igényelni: https://developers.google.com/maps/documentation/geocoding/get-api-key CONST_GEOCODE_API_KEY = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" # ez most __használhatatlan__, szándékosan hamis kulcs ;) CONST_DISTANCE_API_KEY = CONST_GEOCODE_API_KEY # pontosító jellegű kiegészítések CONST_ORSZAG = "Magyarország" # minden adatpont ebben az országban van CONST_REGIO = "hu" # pontosító régió; még véletlenül sem az USA-ban található Budapest településre vagyunk kíváncsiak, hanem az itthonira :) # várakozási idők két lekérdezés között (másodpercben) CONST_SLEEP_GEOCODE_KOZOTT = 3 CONST_SLEEP_DISTANCEMATRIX_KOZOTT = 11 CONST_SLEEP_ERROR_KEZDETI = 15 CONST_SLEEP_ERROR_MAXIMUM = 30 # Létrehozok új oszlopokat, ahol a földrajzi koordinátákat írom majd. Az ilyen típusú adatoknak hagyományosan kétféle leképezése ismert: # - historikus, fok-perc-másodperc formátum, ahol Budapest: 47°30’ északi szélesség, 19°03’ keleti hosszúság # - tizedes törtszám, ahol Budapest: 47,5 északi szélesség, 19,05 keleti hosszúság # # Most a **tizedes törtszám leképezést** fogom használni. # In[5]: # két új oszlop a koordináták komponenseinek for oszlop in ["geo_szelesseg", "geo_hosszusag"]: df[oszlop] = None df[oszlop] = df[oszlop].astype(float) # tizedes tört adattípus # In[6]: df.info() # áttekintés; a DataFrame oszlopai és adattípusai # In[7]: df.head() # áttekintés; csak az első néhány értéket jelenítjük meg # A Google Maps Geocoding API **lekérdezését ketté bontom**: # - egyrészt össze kell állítani az API felé elküldött lekérdezést # - másrészt föl kell dolgozni a Google válaszát. # # Lássuk a két kódot, mégpedig fordított sorrendben. # A Google válaszát annak szabványos struktúrája szerint dolgozom föl. JSON formátumban kérjem le a Google-től, amit Python dictionary típusként egyszerű tovább alakítani. # # Több okból **előfrodulhat, hogy a Google szerverétől nem érkezik rendes válasz**: # - megszakadt az internet-kapcsolat # - túlléptem a Google-nél a lekérdezések napi limitjét # - érvénytelen formátumban indítottam a lekérést # - stb. # # Bizonyos esetekben érdemes változatlan formátumban újra (és újra, és újra...) lekérdezni; például ismeretlen hiba, nosza, várjunk egy keveset és próbáljuk újra! Más esetekben fölösleges várnunk (érvénytelen formátumban indított lekérést fölösleges megismételni). # In[8]: def geocode_query(webcim, varakozas=CONST_SLEEP_ERROR_KEZDETI, max_varakozas=CONST_SLEEP_ERROR_MAXIMUM): """A Google Maps Geocoding API felé indított lekérdezések végrehajtása. Az eredmény Python dictionary (kulcs-érték szótár) típusú.""" rj = requests.get(webcim).json() # lekérdezést indítunk, és az eredményt JSON formátummá alakítjuk if rj.get("status") == "OK" and len(rj.get("results", [])) > 0: # a Google Maps Geocoding API mondjuk elvileg akkor is visszaad egy üres "results" tömböt, ha nincs találat, de azért így a biztos... # Itt persze lenne tér némi barkácsolásra, például hogy ne fixen a Google által fölkínált # legelső lehetőséget használjuk (Pythonban: 0 indexű találat, rj["results"][0]), # hanem egy másikat, esetleg többet is. Hiányos, zavaros, irányítószám nélküli címeknél # lehet több eredményünk... Az egyszerűség kedvéért most csak a legelső szerepel. return {"lat": rj["results"][0]["geometry"]["location"]["lat"], "lng": rj["results"][0]["geometry"]["location"]["lng"], "status_ok": 1} # remek, valódi eredményünk van elif rj.get("status") == "UNKNOWN_ERROR" or rj.get("status") is None: if varakozas > max_varakozas: return {"lat": 0, "lng": 0, "status_ok": 0} # majd később kiszűrjük őket else: time.sleep(varakozas) # várakozunk return geocode_query(webcim, varakozas*2) # növeljük a várakozási időt, s újra nekifutunk else: return {"lat": 0, "lng": 0, "status_ok": 0} # majd később kiszűrjük őket, és "INVALID_REQUEST", "MAX_ELEMENTS_EXCEEDED", "OVER_QUERY_LIMIT", "REQUEST_DENIED" esetén egyáltalán nem futunk neki ismét # A Google Maps Geocoding API felé indított lekérdezésnél maradhatnak pl. a magyar ékezetes karakterek az utcanevekben, semmi szükség őket átalakítani, a szóközöket azonban „+” jelre cserélem. Régiót, esetleg konkrét országot is megadok szűkítésként (van „Buda” az USA Texas államában is, például). # # Paraméter, hogy **az adott DataFrame hányadik oszlopában szerepel az irányítószám, a város, a cím**, továbbá hogy a DataFrame **hányadik oszlopában kell visszaadnom a földrajzi szélesség és hosszúság koordinátáit**. # In[9]: def geocode_dataframe(p, cim_oszlopok=(0, 1, 2), geo_oszlopok=(3, 4)): """A Google Maps Geocoding API felé indított lekérdezés összeállítása, és az eredmények tárolása.""" """cim_oszlopok: irányítószám, város, városon belüli cím sorrendben kell a paraméter tuple-t érteni geo_oszlopok: előbb a földrajzi szélesség, majd a földrajzi hosszúság oszlopa szerepel a tuple-ben""" i = 0 while i < len(p): lnk = "https://maps.googleapis.com/maps/api/geocode/json?address={varos}+{cim}+{irsz}+{orszag}®ion={region}&key={api_key}".format( \ cim = p.iloc[i, cim_oszlopok[2]].replace(" ", "+").strip(), \ varos = p.iloc[i, cim_oszlopok[1]].replace(" ", "+").strip(), \ irsz = str(p.iloc[i, cim_oszlopok[0]]), \ orszag = CONST_ORSZAG, \ region = CONST_REGIO, \ api_key = CONST_GEOCODE_API_KEY) # összeállítottuk a Google Maps Geocoding API lekérdezés webcímét koordinatak = geocode_query(lnk) # megtörténik a tényleges lekérdezés if koordinatak.get("status_ok") == 1: for j in zip(geo_oszlopok, ["lat", "lng"]): p.iloc[i, j[0]] = koordinatak.get(j[1]) # előbb a földrajzi szélesség, majd a földrajzi hosszúság if i < len(p) - 1: # Ajánlatos lehet pár másodperc szünetet tartani lekérdezések között, hogy a Google ne tekintse # visszaélésszerű felhasználásnak. Az utolsó elem után persze már ne várakozzunk :) time.sleep(CONST_SLEEP_GEOCODE_KOZOTT) else: for j in geo_oszlopok: p.iloc[i, j] = None # explicit törlés, hogy már létező DataFrame-re újrafuttatva nehogy téves érték maradjon benne i += 1 return p # A függvények definíciója után jöhet a tulajdonképpeni futtatás! # In[10]: df = geocode_dataframe(df, cim_oszlopok=(1, 2, 3), geo_oszlopok=(4, 5)) # a Megye oszloppal nem kezdek semmit, de a többi oszlop pozíciója fontos paraméter df.head() # áttekintés; csak az első néhány értéket jelenítjük meg # Amennyiben **hiányos (üres) koordinátát találunk**, tegyünk egy próbát, írjuk be az adott címet a Google Maps webböngészős felületére! Talán nem az API lekérdezéssel van ugyanis gond, hanem a Google Maps egyáltalán nem találja az adott címet! # - az „u.” és az „utca” átváltása remekül megy a Google-nek # - például az már megzavarhatja, és nemlétező címnek tekintheti, ha a Széchenyi István u. a címek forrásául szolgáló Excelben „Széchenyi I. u.”-ként szerepel, a Nagy Lajos király útja csonkolva, „Nagy L. kir.” formában, vagy éppen a 96-98-as házszám összecsúszva „9698”-ként van benne # # Az eredményt Excel-fájlba mentem. # In[11]: # írjuk ki az eredményt egy másik Excel-fájlba writer = pd.ExcelWriter("Városháza (földrajzi koordinátákkal).xlsx", engine="xlsxwriter") df.to_excel(writer, sheet_name="Munka1", index=False) writer.save() # Az első feladattal tehát készen volnánk: előálltak a városházák geokoordinátái. # ### 2) Légvonalban mért távolság # # Második feladatként számoljuk ki a koordináták összes többi koordinátától való, légvonalban mért távolságát! # # Mint azt földrajzórán megtanultuk, Földünk „Föld-alakú”, azaz „geoid” formájú, nem pedig tökéletes gömb. Mégis annak szokás tekinteni, és a légvonalban mért távolságot egy gömbfelszín két pontja közötti, a felszínen mért legrövidebb távolságként értelmezni. # # Letöltök egy kis Python modult (`pip install haversine`), és felhasználom. Két, Python tuple formájában beadott földrajzi koordináta távolságát adja vissza kilométerben (igény esetén mérföldben). # In[12]: # illusztráció: földrajzi koordináták távolsága légvonalban, tökéletes gömbnek tekintett Föld felszínén mérve # haversine Python-modul, és https://en.wikipedia.org/wiki/Haversine_formula koordinatak_budapest = (47.5, 19.03) # Python tuple, szélesség és hosszúság koordinatak_parizs = (48.8530511, 2.3496311) # Python tuple, szélesség és hosszúság for i, mertekegyseg in enumerate(["kilométer", "mérföld"]): tav = haversine.haversine(koordinatak_budapest, koordinatak_parizs, miles=bool(i==1)) # alapból kilométer, s miles=True esetén mérfőld s = "{:{szeles}.{tizedes}f} {egyseg}".format(tav, szeles=10, tizedes=3, egyseg=mertekegyseg) print(s) # A koordinátás DataFrame-ből (táblázatból) párosításokat készítek: „egyirányú” módon, tehát pl. egy Budapest-Debrecen távot nem érdemes kétszer kiszámoltatni, oda-vissza. Egy pontnak az önmagától vett távolságára sem vagyunk kíváncsiak, értelemszerűen. # In[13]: # Minden koordinátának minden másikkal vett párosítása, de csak egyszer (nem "oda-vissza") df2 = pd.merge(df.reset_index().assign(descartes_segedvaltozo=1), df.reset_index().assign(descartes_segedvaltozo=1), on="descartes_segedvaltozo", suffixes=("_1", "_2")) \ .drop("descartes_segedvaltozo", axis=1) \ .query("index_1 < index_2") \ .sort_values(["index_1", "index_2"]) \ .reset_index(drop=True) \ .drop(["index_1", "index_2"], axis=1) df2.head(10) # áttekintés; csak az első néhány értéket jelenítjük meg # Alkalmazzuk a föntebb bemutatott modulban található függvényt, s az így előálló távolságokat új oszlopként adjuk hozzá a DataFrame-hez. # In[14]: tav = df2.apply(lambda x: haversine.haversine((x["geo_szelesseg_1"], x["geo_hosszusag_1"]), (x["geo_szelesseg_2"], x["geo_hosszusag_2"])), axis=1) df2["tavolsag_legvonalban"] = tav df2.head(10) # áttekintés; csak az első néhány értéket jelenítjük meg # Íme, a második feladat is elkészült: megtudtuk a koordináták légvonalban mért távolságát. A Python Pandas DataFrame jobb oldali szélső oszlopában immár ez látható. # ### 3) Közúton mért távolság # # Harmadik feladatként számoljuk ki a koordináták összes többi koordinátától való, közúton mért távolságát! # # Ezt a _**Google Maps Distance Matrix API**_-val tudjuk lekérdezni. A Google felé földrajzi koordinátákat kell küldenünk, és **igazán változatos beállítási lehetőségeink vannak**: # - autó helyett esetleg bicajjal kívánjuk megtenni a távot (utóbbi esetben városon belül a kijelölt bicikliutakon igyekszik tervezni a Google) # - vagy távol kívánunk maradni a kompoktól (ahol benzint tudunk ugyan spórolni, de a várakozás miatt mégis tovább tarthat az út) # In[15]: def distancematrix_query(webcim, varakozas=CONST_SLEEP_ERROR_KEZDETI, max_varakozas=CONST_SLEEP_ERROR_MAXIMUM): """A Google Maps Distance Matrix API felé indított lekérdezések végrehajtása. Az eredmény Python dictionary (kulcs-érték szótár) típusú.""" tav = None status_ok = 0 rj = requests.get(webcim).json() if rj.get("status") == "OK": try: # Itt persze lenne tér némi barkácsolásra, például hogy ne fixen a Google által fölkínált # legelső lehetőséget használjuk (Pythonban: 0 indexű találat, rj["rows"][0]), # hanem egy másikat, esetleg többet is. Az egyszerűség kedvéért most csak a legelső szerepel. # Tudtommal a Google API válaszában a "distance" amúgy mindig méterben áll rendelkezésre, # és a mérföld/kilométer beállítás csak a "text" elemet befolyásolja. tav = int(rj["rows"][0]["elements"][0]["distance"]["value"]) / 10**3 # kilométer-átváltás status_ok = 1 except: pass return {"status_ok": status_ok, "tav": tav} elif rj.get("status") == "UNKNOWN_ERROR" or rj.get("status") is None: if varakozas > max_varakozas: return {"status_ok": status_ok, "tav": tav} else: time.sleep(varakozas) return distancematrix_query(webcim, varakozas*2) else: return {"status_ok": status_ok, "tav": tav} # "INVALID_REQUEST", "MAX_ELEMENTS_EXCEEDED", "OVER_QUERY_LIMIT", "REQUEST_DENIED" esetén nem futunk neki ismét # Ha a kezdő- vagy végpont koordinátája nem elérhető, akkor be sem küldöm az API-nak útvonaltervezésre, így gyorsítva a futást. **A koordináták oszlopait paraméterként adom meg**, és ezt mindig földrajzi szélesség, majd földrajzi hosszúság sorrendben teszem. # In[16]: def distancematrix_dataframe(p, geo_oszlopok=((0, 1), (2, 3)), tav_oszlop=4): """A Google Maps Distance Matrix API felé indított lekérdezés összeállítása, és az eredmények tárolása.""" i = 0 while i < len(p): if any([np.isnan(p.iloc[i, geo_oszlopok[j][k]]) for j in range(2) for k in range(2)]): # hiányzó koordináta van, ezért nem számolunk távolságot p.iloc[i, tav_oszlop] = None # explicit törlés, hogy már létező DataFrame-re újrafuttatva nehogy téves érték maradjon benne else: # nincs hiányzó koordináta, tehát számolunk távolságot # legyártjuk a Google Maps Distance Matrix API által várt, vesszővel elválasztott, tizedes ponttal működő koordinátás input string-eket, pl. "46.254568,20.148387" geo1 = "{},{}".format(*(p.iloc[i, geo_oszlopok[0][j]] for j in range(2))) geo2 = "{},{}".format(*(p.iloc[i, geo_oszlopok[1][j]] for j in range(2))) s = "https://maps.googleapis.com/maps/api/distancematrix/json?origins={}&destinations={}&mode=driving&units=metric&avoid=ferries&key={api_key}".format(geo1, geo2, api_key=CONST_DISTANCE_API_KEY) # a lekérdezés webcíme; autózás (driving) helyett akár gyaloglás (walking) vagy biciklizés (bicycling) is megadható! Utóbbi esetben városon belül előnyben részesíti a kiépített bicikliutakat. valasz = distancematrix_query(s) # lekérjük a távolságot az API-tól if valasz.get("status_ok") == 1: p.iloc[i, tav_oszlop] = valasz.get("tav") if i < len(p) - 1: # Ajánlatos lehet pár másodperc szünetet tartani lekérdezések között, hogy a Google ne tekintse # visszaélésszerű felhasználásnak. Az utolsó elem után persze már ne várakozzunk :) time.sleep(CONST_SLEEP_DISTANCEMATRIX_KOZOTT) i += 1 return p # Új oszlopot hozok létre, oda írom majd a lekérdezés eredményét. # In[17]: df2["tavolsag_kozuton"] = None df2["tavolsag_kozuton"] = df2["tavolsag_kozuton"].astype(float) # tizedes tört adattípus df2.info() # Az adott Python Pandas DataFrame újabb oszloppal bővült, s a legutolsó (jobb szélső) oszlopban lesz látható a friss eredményünk. # # Jöhet a tulajdonképpeni futtatás! # In[18]: df2 = distancematrix_dataframe(df2, geo_oszlopok=((4, 5), (10, 11)), tav_oszlop=13) # paraméterként az oszlop-indexek df2.head(10) # áttekintés; csak az első néhány értéket jelenítjük meg # Készen vagyunk, a harmadik feladat eredménye a közúton mért távolság. # In[19]: df2["kozut_legvonal_arany"] = df2["tavolsag_kozuton"] / df2["tavolsag_legvonalban"] # csak kiváncsiságból df2.head(10) # In[20]: # írjuk ki az eredményt egy Excel-fájlba writer = pd.ExcelWriter("Városháza (távolságok).xlsx", engine="xlsxwriter") df2.to_excel(writer, sheet_name="Munka1", index=False) writer.save() # Ennyi hát, ízelítőül. # ###
Köszönöm a figyelmet, s jó munkát kívánok mindenkinek!