Определение актуальных мест в городских районах

Актуальные места в городских районах как их определить?

Места гипстеров в Будапеште.

Общая методология, использующая OpenStreetMap и DBSCAN Spatial Clustering для выявления самых популярных мест в городе

В этой статье я покажу быструю и простую методологию, способную выявлять популярные места по заданным интересам на основе точек интереса (POI), собранных с помощью OpenStreetMap (OSM) с использованием алгоритма DBSCAN из библиотеки sklearn. Сначала я соберу исходные данные POI, относящиеся к нескольким категориям, которые я нашел на ChatGPT, и предположу, что они характерны для так называемого гип-образа жизни (например, кафе, бары, рынки, йога-студии); после преобразования этих данных в удобный GeoDataFrame, я выполню пространственную кластеризацию и, наконец, оценю результаты на основе того, насколько хорошо разные урбанистические функции смешиваются в каждом кластере.

Хотя выбор темы, которую я называю “гипстер”, и категорий POI, связанных с этой темой, является относительно произвольным, их легко можно заменить другими темами и категориями – автоматический метод определения горячих точек остается прежним. Преимущества такого простого метода состоят в том, что он помогает выявить локальные центры инноваций, поддерживающие планирование инноваций, определение урбанистических субцентров, поддерживающих инициативы по градостроительству, оценку различных рыночных возможностей для предприятий, анализ возможностей инвестиций в недвижимость и выявление туристических горячих точек.

Все изображения были созданы автором.

1. Получение данных из OSM

Сначала я получаю административный полигон целевого города. Поскольку Будапешт – мой родной город, для простой (полевой) проверки я использую его. Однако, так как я использую только глобальную базу данных OSM, эти шаги легко могут быть воспроизведены для любой другой части мира, охваченной OSM. В частности, я использую пакет OSMNx для получения административных границ в очень простой форме.

import osmnx as ox # версия: 1.0.1city = 'Budapest'admin = ox.geocode_to_gdf(city)admin.plot()

Результат выполнения этого кодового блока:

Административные границы Будапешта.

Теперь используйте API OverPass для загрузки POI, которые попадают в ограничивающий прямоугольник административных границ Будапешта. В списке amenity_mapping я собрал список категорий POI, которые я связываю с гипстерским образом жизни. Я также хочу отметить, что это субъективная и неоснованная на профессиональной экспертизе категоризация, и с помощью представленных здесь методов каждый может обновить список категорий соответствующим образом. Кроме того, можно включить другие источники данных POI, содержащие более точную категоризацию на нескольких уровнях для более точной характеристики заданной темы. Другими словами, этот список можно изменять так, как вам удобно – от лучшего охвата гипстерских вещей до перенастройки этого упражнения на любую другую тематическую категоризацию (например, фуд-корты, торговые зоны, туристические достопримечательности и т.д.).

Примечание: поскольку загрузчик OverPass возвращает все результаты внутри ограничивающего прямоугольника, в конце этого кодового блока я фильтрую те POI, которые находятся за пределами административных границ, используя функцию перекрытия GeoPandas.

import overpy # версия: 0.6from shapely.geometry import Point # версия: 1.7.1import geopandas as gpd # версия: 0.9.0# запуск APIapi = overpy.Overpass()# получение ограничивающего прямоугольникаминх, мину, максх, максу = admin.to_crs(4326).bounds.T[0]bbox = ','.join([str(мину), str(минх), str(максу), str(максх)])# определение интересующих категорий OSMamenity_mapping = [    ("amenity", "кафе"),    ("tourism", "галерея"),    ("amenity", "паб"),    ("amenity", "бар"),    ("amenity", "рынок"),    ("sport", "йога"),    ("amenity", "студия"),    ("shop", "музыка"),    ("shop", "вторичный рынок"),    ("amenity", "фудтрак"),    ("amenity", "музыкальное заведение"),    ("shop", "книги")]# перебираем все категории, вызываем пересечение api, # и добавляем результаты в список poi_datapoi_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)         # преобразуем результаты в геодатафреймgdf_poi = gpd.GeoDataFrame(poi_data)print(len(gdf_poi))gdf_poi = gpd.overlay(gdf_poi, admin[['geometry']])gdf_poi.crs = 4326print(len(gdf_poi))

Результатом этого кода является частотное распределение каждой скачанной категории POI:

Частотное распределение каждой скачанной категории POI.

2. Визуализация данных POI

Теперь визуализируем все 2101 POI:

import matplotlib.pyplot as pltf, 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)

Результат этой ячейки кода:

Будапешт со всеми скачанными POI, помеченными их категориями.

Этот график довольно трудно интерпретировать — за исключением того, что центр города переполнен народом, поэтому давайте воспользуемся интерактивным инструментом визуализации, Folium.

import foliumimport branca.colormap as cm# получить центроид города и настроить картуx, y = admin.geometry.to_list()[0].centroid.xym = 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']# преобразовать гдф поиamenity_colors = {}unique_amenities = gdf_poi['amenity'].unique()for i, amenity in enumerate(unique_amenities):    amenity_colors[amenity] = colors[i % len(colors)]# визуализировать метки с помощью рассеянного графикаfor idx, row in gdf_poi.iterrows():    amenity = row['amenity']    lat = row['geometry'].y    lon = row['geometry'].x    color = amenity_colors.get(amenity, 'gray')  # по умолчанию серый, если нет в цветовой карте        folium.CircleMarker(        location=[lat, lon],        radius=3,          color=color,        fill=True,        fill_color=color,        fill_opacity=1.0,  # Нет прозрачности для точечных маркеров        popup=amenity,    ).add_to(m)# показать картуm

Основной вид этой карты (который можно легко изменить, отрегулировав параметр zoom_start=12):

Будапешт со всеми скачанными POI, помеченными их категориями — интерактивная версия, первая настройка масштабирования.

Затем можно изменить параметр масштабирования и заново построить карту или просто приблизить с помощью мыши:

Будапешт со всеми скачанными POI, помеченными их категориями — интерактивная версия, вторая настройка масштабирования.

Или полностью уменьшить масштаб:

Будапешт со всеми скачанными POI, помеченными их категориями — интерактивная версия, третья настройка масштабирования.

3. Пространственная кластеризация

Теперь, когда у меня есть все необходимые места интереса (POI) под рукой, я приступаю к алгоритму DBSCAN, сначала написав функцию, которая принимает POI и выполняет кластеризацию. Я только настраиваю параметр eps DBSDCAN, который, по сути, оценивает характерный размер кластера, расстояние между POI, которые должны быть группированы вместе. Кроме того, я преобразую геометрии в местную CRS (EPSG:23700) для работы в единицах СИ. Подробнее о преобразованиях CRS можно узнать здесь.

from sklearn.cluster import DBSCAN # версия: 0.24.1from collections import Counter# выполните кластеризацию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)  # Вы можете настроить min_samples по необходимости    cluster_labels = dbscan.fit_predict(feature_matrix)    gdf_poi['cluster_id'] = cluster_labels    return gdf_poi# преобразование в местную CRSgdf_poi_filt = gdf_poi.to_crs(23700)    # выполните кластеризациюeps_value = 50  clustered_gdf_poi = apply_dbscan_clustering(gdf_poi_filt, eps_value)# Вывести GeoDataFrame с идентификаторами кластеровprint('Количество найденных кластеров: ', len(set(clustered_gdf_poi.cluster_id)))clustered_gdf_poi

Результат этой ячейки:

Предварительный просмотр GeoDataFrame POI, где каждое место обозначено своим идентификатором кластера.

Таким образом, имеется 1237 кластеров – это, кажется, слишком много, если мы рассматриваем только уютные, модные места. Давайте посмотрим на распределение их размеров, а затем выберем пороговый размер – называть кластер с двумя POI горячими местами, вероятно, не очень правильно.

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('Размер кластера', fontsize = 14)ax.set_ylabel('Количество кластеров', fontsize = 14)

Результат этой ячейки:

Распределение размеров кластеров.

Основываясь на пробеле в гистограмме, давайте оставим кластеры с не менее чем 10 POI! В настоящее время это достаточно простая рабочая гипотеза. Однако это можно сделать и более сложными способами, например, учитывая количество различных типов POI или площадь географического покрытия.

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)

Этот фрагмент показывает, что есть 15 кластеров, удовлетворяющих фильтрации.

Как только у нас есть 15 истинных модных кластеров, разместите их на карте:

import foliumimport random# получите центроид города и настройте картумин_longitude, min_latitude, max_longitude, max_latitude = clustered_gdf_poi.total_boundsm = folium.Map(location=[(min_latitude+max_latitude)/2, (min_longitude+max_longitude)/2], zoom_start=14, tiles='CartoDB Dark_Matter')# получите уникальные случайные цвета для каждого кластера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}# визуализируйте места интереса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]        # создайте точечный маркер     folium.CircleMarker(        location=[lat, lon],        radius=3,         color=color,        fill=True,        fill_color=color,        fill_opacity=0.9,          popup=row['amenity'],     ).add_to(m)# показать картуm
Кластеры HIPSTER POI - первый уровень масштабирования.
Кластеры HIPSTER POI - второй уровень масштабирования.
Кластеры HIPSTER POI - третий уровень масштабирования.

4. Сравнение кластеров

Каждый кластер считается модным и стильным – однако они все должны быть уникальными в том или ином аспекте, не так ли? Давайте посмотрим, насколько они уникальны, сравнивая наборы категорий точек интереса (POI), которые они предлагают.

Сначала стремимся к разнообразию и измеряем разнообразие/вариативность категорий POI в каждом кластере, вычисляя их энтропию.

import mathimport 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# создание словаря, где каждому кластеру соответствует список объектовclusters_amenities = clustered_gdf_poi.groupby(by = 'cluster_id')['amenity'].apply(list).to_dict()# вычисление и сохранение оценок энтропииentropy_data = []for cluster, amenities in clusters_amenities.items():    E = get_entropy_score(amenities)    entropy_data.append({'cluster' : cluster, 'size' :len(amenities), 'entropy' : E})    # добавление оценок энтропии в датафреймentropy_data = pd.DataFrame(entropy_data)entropy_data

Результат выполнения данной ячейки:

Разнообразие (энтропия) каждого кластера на основе его профиля POI.

И быстрый анализ корреляции в этой таблице:

entropy_data.corr()
Корреляция между характеристиками кластеров.

После вычисления корреляции между идентификатором кластера, размером и энтропией кластера, существует значительная корреляция между размером и энтропией; однако она далека от объяснения всего разнообразия. Кажется, действительно, некоторые места более разнообразны, чем другие, в то время как другие более специализированы. В чем они специализированы? Я отвечу на этот вопрос, сравнивая профили POI каждого кластера с общим распределением каждого типа POI внутри кластеров и выбирая три наиболее типичные категории POI для кластера по сравнению с средним значением.

# упаковка профилей POI в словари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()}# вычисление относительной частоты каждой категории# и оставляем только выше-средние (>1) и топ-3 кандидатов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]    # вывод, для каждого кластера, его лучших категорий:for cluster, top_amenities in clusters_top_profile.items():    print(cluster, top_amenities)

Результат этого кодового блока:

Уникальный отпечаток удобств каждого кластера.

Описания лидирующих категорий уже показывают некоторые тенденции. Например, кластер 17 явно предназначен для питья, в то время как 19 также объединяет музыку, возможно, с вечеринками. Кластер 91 с книжными магазинами, галереями и кафе является местом для дневного отдыха, в то время как кластер 120 с музыкой и галереей может быть отличным разминкой перед паб-кроулом. Из распределения мы также видим, что посещение бара всегда уместно (или, в зависимости от случая использования, мы должны думать о дальнейших нормализациях на основе частоты категорий)!

Заключительные замечания

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

  • Основываться на более детальной категоризации и выборе POI (точка интереса)
  • Учитывать категории POI при выполнении кластеризации (семантическая кластеризация)
  • Обогащать информацию о POI, например, отзывами и рейтингами из социальных сетей