Un framework generico che utilizza OpenStreetMap e DBSCAN Spatial Clustering per catturare le aree urbane più pubblicizzate
In questo articolo, mostro una metodologia rapida e facile da usare in grado di identificare i punti caldi per un dato interesse in base ai Punti di interesse (POI) raccolti da OpenStreetMap (OSM) usando il DBSCAN algoritmo di sklearn. Per prima cosa, raccoglierò i dati grezzi dei POI appartenenti a un paio di categorie che ho trovato su ChatGPT e che presumo siano caratteristici del cosiddetto stile di vita hyp (ad esempio caffè, bar, mercati, studi di yoga); dopo aver convertito i dati in un pratico GeoDataFrame, eseguo il clustering geospaziale e, infine, valuto i risultati in base al modo in cui le diverse funzionalità urbane si mescolano in ciascun cluster.
Sebbene la scelta dell’argomento che chiamo “hipster” e delle categorie di PDI ad esso collegate sia alquanto arbitraria, possono essere facilmente sostituite da altri argomenti e categorie: il metodo di rilevamento automatico degli hot spot rimane lo stesso. I vantaggi di un metodo così semplice da adottare vanno dall’identificazione del locale poli di innovazione sostenere la pianificazione dell’innovazione per individuare sottocentri urbani sostenere iniziative di pianificazione urbana, valutare diverse opportunità di mercato per le imprese, analizzare opportunità di investimento immobiliare o individuare punti caldi turistici.
Tutte le immagini sono state create dall’autore.
Innanzitutto, ottengo il poligono amministrativo della città di destinazione. Dato che Budapest è la mia città natale, lo utilizzo per facilitare la convalida (sul campo). Tuttavia, poiché utilizzo solo il database globale di OSMquesti passaggi possono essere facilmente riprodotti per qualsiasi altra parte del mondo coperta da OSM. In particolare, utilizzo il pacchetto OSMNx per ottenere i limiti di amministrazione in modo semplicissimo.
import osmnx as ox # version: 1.0.1city = 'Budapest'
admin = ox.geocode_to_gdf(city)
admin.plot()
Il risultato di questo blocco di codice:
Ora utilizza l’API OverPass per scaricare i PDI che rientrano nel riquadro di delimitazione dei confini amministrativi di Budapest. Nell’elenco amenity_mapping ho compilato un elenco di categorie di POI che associo allo stile di vita hipster. Devo anche notare che si tratta di una categorizzazione vaga e non basata su esperti e, con i metodi qui presentati, chiunque può aggiornare di conseguenza l’elenco delle categorie. Inoltre, è possibile incorporare altre fonti di dati POI che contengono una categorizzazione multilivello più dettagliata per una caratterizzazione più accurata dell’argomento specificato. In altre parole, questo elenco può essere modificato nel modo che ritieni opportuno: coprendo meglio le cose hipster o riadattando questo esercizio a qualsiasi altra categorizzazione di argomenti (ad esempio, punti ristoro, aree commerciali, hotspot turistici, ecc.).
Nota: come Cavalcavia il downloader restituisce tutti i risultati all’interno di un riquadro di delimitazione, alla fine di questo blocco di codice, filtro i PDI al di fuori dei confini dell’amministratore utilizzando la funzione di sovrapposizione di GeoPandas.
import overpy # version: 0.6
from shapely.geometry import Point # version: 1.7.1
import geopandas as gpd # version: 0.9.0# start the api
api = overpy.Overpass()
# get the enclosing bounding box
minx, miny, maxx, maxy = admin.to_crs(4326).bounds.T(0)
bbox = ','.join((str(miny), str(minx), str(maxy), str(maxx)))
# define the OSM categories of interest
amenity_mapping = (
("amenity", "cafe"),
("tourism", "gallery"),
("amenity", "pub"),
("amenity", "bar"),
("amenity", "marketplace"),
("sport", "yoga"),
("amenity", "studio"),
("shop", "music"),
("shop", "second_hand"),
("amenity", "foodtruck"),
("amenity", "music_venue"),
("shop", "books"),
)
# iterate over all categories, call the overpass api,
# and add the results to the poi_data list
poi_data = ()
for idx, (amenity_cat, amenity) in enumerate(amenity_mapping):
query = f"""node("{amenity_cat}"="{amenity}")({bbox});out;"""
result = api.query(query)
print(amenity, len(result.nodes))
for node in result.nodes:
data = {}
name = node.tags.get('name', 'N/A')
data('name') = name
data('amenity') = amenity_cat + '__' + amenity
data('geometry') = Point(node.lon, node.lat)
poi_data.append(data)
# transform the results into a geodataframe
gdf_poi = gpd.GeoDataFrame(poi_data)
print(len(gdf_poi))
gdf_poi = gpd.overlay(gdf_poi, admin(('geometry')))
gdf_poi.crs = 4326
print(len(gdf_poi))
Il risultato di questo blocco di codice è la distribuzione di frequenza di ciascuna categoria di POI scaricata:
Ora visualizza tutti i 2101 POI:
import matplotlib.pyplot as plt
f, ax = plt.subplots(1,1,figsize=(10,10))
admin.plot(ax=ax, color = 'none', edgecolor = 'k', linewidth = 2)
gdf_poi.plot(column = 'amenity', ax=ax, legend = True, alpha = 0.3)
Il risultato di questa cella di codice:
Questa trama è piuttosto difficile da interpretare, tranne per il fatto che il centro città è super affollato, quindi scegliamo uno strumento di visualizzazione interattivo, Foglia.
import folium
import branca.colormap as cm# get the centroid of the city and set up the map
x, y = admin.geometry.to_list()(0).centroid.xy
m = folium.Map(location=(y(0), x(0)), zoom_start=12, tiles='CartoDB Dark_Matter')
colors = ('blue', 'green', 'red', 'purple', 'orange', 'pink', 'gray', 'cyan', 'magenta', 'yellow', 'lightblue', 'lime')
# transform the gdf_poi
amenity_colors = {}
unique_amenities = gdf_poi('amenity').unique()
for i, amenity in enumerate(unique_amenities):
amenity_colors(amenity) = colors(i % len(colors))
# visualize the pois with a scatter plot
for idx, row in gdf_poi.iterrows():
amenity = row('amenity')
lat = row('geometry').y
lon = row('geometry').x
color = amenity_colors.get(amenity, 'gray') # default to gray if not in the colormap
folium.CircleMarker(
location=(lat, lon),
radius=3,
color=color,
fill=True,
fill_color=color,
fill_opacity=1.0, # No transparency for dot markers
popup=amenity,
).add_to(m)
# show the map
m
La visualizzazione predefinita di questa mappa (che puoi modificare facilmente modificando il parametro zoom_start=12):
Successivamente è possibile modificare il parametro di zoom e ritracciare la mappa, o semplicemente ingrandire utilizzando il mouse:
Oppure rimpicciolisci completamente:
Ora che ho tutti i POI necessari a portata di mano, scelgo l’algoritmo DBSCAN, scrivendo prima una funzione che prende i POI ed esegue il clustering. Mi limiterò solo a perfezionare il eps parametro di DBSDCANche, in sostanza, quantifica la dimensione caratteristica di un cluster, la distanza tra i POI da raggruppare. Inoltre, trasformo le geometrie in un CRS locale (EPSG:23700) per lavorare in unità SI. Maggiori informazioni sulle conversioni CRS Qui.
from sklearn.cluster import DBSCAN # version: 0.24.1
from collections import Counter# do the clusteirng
def apply_dbscan_clustering(gdf_poi, eps):
feature_matrix = gdf_poi('geometry').apply(lambda geom: (geom.x, geom.y)).tolist()
dbscan = DBSCAN(eps=eps, min_samples=1) # You can adjust min_samples as needed
cluster_labels = dbscan.fit_predict(feature_matrix)
gdf_poi('cluster_id') = cluster_labels
return gdf_poi
# transforming to local crs
gdf_poi_filt = gdf_poi.to_crs(23700)
# do the clustering
eps_value = 50
clustered_gdf_poi = apply_dbscan_clustering(gdf_poi_filt, eps_value)
# Print the GeoDataFrame with cluster IDs
print('Number of clusters found: ', len(set(clustered_gdf_poi.cluster_id)))
clustered_gdf_poi
Il risultato di questa cella:
Ci sono 1237 cluster: sembra un po’ troppo se guardiamo solo agli hotspot accoglienti e hipster. Diamo un’occhiata alla loro distribuzione delle dimensioni e quindi scegliamo una soglia di dimensione: chiamare un cluster con due hotspot POI probabilmente non è comunque corretto.
clusters = clustered_gdf_poi.cluster_id.to_list()
clusters_cnt = Counter(clusters).most_common()f, ax = plt.subplots(1,1,figsize=(8,4))
ax.hist((cnt for c, cnt in clusters_cnt), bins = 20)
ax.set_yscale('log')
ax.set_xlabel('Cluster size', fontsize = 14)
ax.set_ylabel('Number of clusters', fontsize = 14)
Il risultato di questa cella:
In base allo spazio vuoto nell’istogramma, manteniamo i cluster con almeno 10 POI! Per ora si tratta di un’ipotesi di lavoro abbastanza semplice. Tuttavia, ciò potrebbe essere realizzato anche in modi più sofisticati, ad esempio incorporando il numero di diversi tipi di POI o l’area geografica coperta.
to_keep = (c for c, cnt in Counter(clusters).most_common() if cnt>9)
clustered_gdf_poi = clustered_gdf_poi(clustered_gdf_poi.cluster_id.isin(to_keep))
clustered_gdf_poi = clustered_gdf_poi.to_crs(4326)
len(to_keep)
Questo frammento mostra che ci sono 15 cluster che soddisfano il filtraggio.
Una volta che abbiamo i 15 veri gruppi hipster, inseriscili su una mappa:
import folium
import random# get the centroid of the city and set up the map
min_longitude, min_latitude, max_longitude, max_latitude = clustered_gdf_poi.total_bounds
m = folium.Map(location=((min_latitude+max_latitude)/2, (min_longitude+max_longitude)/2), zoom_start=14, tiles='CartoDB Dark_Matter')
# get unique, random colors for each cluster
unique_clusters = clustered_gdf_poi('cluster_id').unique()
cluster_colors = {cluster: "#{:02x}{:02x}{:02x}".format(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) for cluster in unique_clusters}
# visualize the pois
for idx, row in clustered_gdf_poi.iterrows():
lat = row('geometry').y
lon = row('geometry').x
cluster_id = row('cluster_id')
color = cluster_colors(cluster_id)
# create a dot marker
folium.CircleMarker(
location=(lat, lon),
radius=3,
color=color,
fill=True,
fill_color=color,
fill_opacity=0.9,
popup=row('amenity'),
).add_to(m)
# show the map
m
Ogni gruppo conta come un gruppo elegante e hipster, tuttavia devono essere tutti unici in un modo o nell’altro, giusto? Vediamo quanto sono unici confrontando il portafoglio di categorie di POI che hanno da offrire.
Innanzitutto, cerca la diversità e misura la varietà/diversità delle categorie di POI in ciascun cluster calcolandone l’entropia.
import math
import pandas as pddef get_entropy_score(tags):
tag_counts = {}
total_tags = len(tags)
for tag in tags:
if tag in tag_counts:
tag_counts(tag) += 1
else:
tag_counts(tag) = 1
tag_probabilities = (count / total_tags for count in tag_counts.values())
shannon_entropy = -sum(p * math.log(p) for p in tag_probabilities)
return shannon_entropy
# create a dict where each cluster has its own list of amenitiy
clusters_amenities = clustered_gdf_poi.groupby(by = 'cluster_id')('amenity').apply(list).to_dict()
# compute and store the entropy scores
entropy_data = ()
for cluster, amenities in clusters_amenities.items():
E = get_entropy_score(amenities)
entropy_data.append({'cluster' : cluster, 'size' :len(amenities), 'entropy' : E})
# add the entropy scores to a dataframe
entropy_data = pd.DataFrame(entropy_data)
entropy_data
Il risultato di questa cella:
E una rapida analisi di correlazione su questa tabella:
entropy_data.corr()
Dopo aver calcolato la correlazione tra ID del cluster, dimensione del cluster ed entropia del cluster, esiste una correlazione significativa tra dimensione ed entropia; tuttavia, è lungi dallo spiegare tutta la diversità. A quanto pare, infatti, alcuni hotspot sono più diversificati di altri, mentre altri sono un po’ più specializzati. In cosa sono specializzati? Risponderò a questa domanda confrontando i profili di POI di ciascun cluster con la distribuzione complessiva di ciascun tipo di POI all’interno dei cluster e selezionando le prime tre categorie di POI più tipiche di un cluster rispetto alla media.
# packing the poi profiles into dictionaries
clusters = sorted(list(set(clustered_gdf_poi.cluster_id)))
amenity_profile_all = dict(Counter(clustered_gdf_poi.amenity).most_common())
amenity_profile_all = {k : v / sum(amenity_profile_all.values()) for k, v in amenity_profile_all.items()}# computing the relative frequency of each category
# and keeping only the above-average (>1) and top 3 candidates
clusters_top_profile = {}
for cluster in clusters:
amenity_profile_cls = dict(Counter(clustered_gdf_poi(clustered_gdf_poi.cluster_id == cluster).amenity).most_common() )
amenity_profile_cls = {k : v / sum(amenity_profile_cls.values()) for k, v in amenity_profile_cls.items()}
clusters_top_amenities = ()
for a, cnt in amenity_profile_cls.items():
ratio = cnt / amenity_profile_all(a)
if ratio>1: clusters_top_amenities.append((a, ratio))
clusters_top_amenities = sorted(clusters_top_amenities, key=lambda tup: tup(1), reverse=True)
clusters_top_amenities = clusters_top_amenities(0:min((3,len(clusters_top_amenities))))
clusters_top_profile(cluster) = (c(0) for c in clusters_top_amenities)
# print, for each cluster, its top categories:
for cluster, top_amenities in clusters_top_profile.items():
print(cluster, top_amenities)
Il risultato di questo blocco di codice:
Le descrizioni delle categorie principali mostrano già alcune tendenze. Ad esempio, il cluster 17 è chiaramente dedicato al bere, mentre il 19 mescola anche musica, possibilmente facendo festa. Il cluster 91, con librerie, gallerie e caffè, è sicuramente un luogo per il relax diurno, mentre il cluster 120, con musica e una galleria, può essere un ottimo riscaldamento per qualsiasi giro dei pub. Dalla distribuzione possiamo anche vedere che saltare in un bar è sempre appropriato (o, a seconda dei casi d’uso, dovremmo pensare a ulteriori normalizzazioni in base alle frequenze di categoria)!
Come residente locale, posso confermare che questi cluster hanno perfettamente senso e rappresentano abbastanza bene il mix di funzionalità urbane desiderate nonostante la metodologia semplice. Naturalmente, questo è un pilota veloce che può essere arricchito e ritoccato in diversi modi, come ad esempio:
- Basandosi su una categorizzazione e selezione di POI più dettagliate
- Considerare le categorie POI durante il clustering (clustering semantico)
- Arricchire le informazioni sui POI con, ad esempio, recensioni e valutazioni sui social media
Fonte: towardsdatascience.com