На днях мне позвонил друг и сказал, что хочет остановиться в Питере на пару-тройку дней и посмотреть старинные памятники архитектуры нашей культурной столицы. Спросил совета, — где бы ему остановиться поближе к центру города, чтобы успеть посмотреть Летний сад и все такое, это значит точно не у меня… фух
А поскольку буквально на днях я завершил вводную часть курса Аналитик данных в Яндекс Практикум на одной из онлайн площадок, то и решил потренироваться на друге в применении логики такого анализа. Забегая вперед, скажу, что результат меня несколько удивил, возможно где-то в моей логике ошибка. Если так, то поправьте меня. Я только учусь.
Итак поехали…
Загрузка датасета
На сайте https://data.gov.ru/ находим и скачиваем датасет Объекты культурного наследия на территории Санкт-Петербурга. Последнее обновление датировано 2016 годом, но для объектов возрастом в пару веков это не будет проблемой.
Давайте оценим количество данных, с которыми будем работать:
import pandas
data = pandas.read_csv('spb_memo.csv')
print('Количество строк:', len(data))
Количество строк: 9275
Ого, более девяти тысяч объектов культурного наследия! Недаром наш город называют культурной столицей России. Что есть внутри?
data.head()
number | name | name_object | date | author | address | district | protection_category | base | note | |
---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | NaN | Здание Консисторского управления Могилевской Р... | 1870-1873; 1878-1879; 1896-1897; 1900-1902 | арх. В.И. Собольщиков, Е.С. Воротилов; арх. Е.... | 1-я Красноармейская ул., 11, лит. А, Б | Адмиралтейский | Объект культурного наследия регионального знач... | Распоряжение КГИОП № 10-22 от 21.07.2009 | NaN |
Выборка по районам
У нас есть названия объектов, даты постройки, адреса и даже имена авторов, отлично!
Как правило, городские достопримечательности, по большей части, располагаются ближе к центру города. Но любая гипотеза требует доказательств. Посмотрим статистку по районам:
districts = list(data['district'])
districts_unique = list(set(districts))
total_per_district = []
for district in set(districts):
district_counter = 0
for index in range(len(districts)):
if districts[index] == district:
district_counter += 1
total_per_district.append(district_counter)
seaborn.barplot(x=total_per_district, y=districts_unique)
Никаких сюрпризов. Большинство памятников архитектуры располагаются в Центральном, Петродворцовом и Адмиралтейском районах города. Центральный и Адмиралтейский районы — это самый что ни на есть центр. На периферии объектов значительно меньше, за исключением Петродворцового и Пушкинского района. Но вряд ли за свои короткие выходные друг поедет так далеко, хотя и много потеряет. Как бы там ни было, вычёркиваем Пушкинский район, а также другие районы, находящиеся за КАДом.
outside_districts = ['Пушкинский', 'Кронштадтский', 'Кронштадт',
'Колпинский', 'Курортный', 'Петродворцовый', 'Санкт-Петербург']
districts_unique = [item for item in districts_unique
if item not in outside_districts ]
for district in districts_unique:
district_counter = 0
for index in range(len(districts)):
if districts[index] == district:
district_counter += 1
total_per_district.append(district_counter)
seaborn.barplot(x=total_per_district, y=districts_unique)
Проверка других столбцов
Что ж, круг сужается, двигаемся дальше. В датасете есть такая характеристика объектов, как протекционная категория. Будет ли нам полезна эта колонка?
protection_categories = list(data['protection_category'])
protection_categories_unique = list(set(protection_categories))
total_per_category = []
for category in set(protection_categories):
category_counter = 0
for index in range(len(districts)):
if protection_categories[index] == category:
category_counter += 1
total_per_category.append(category_counter)
seaborn.barplot(x=total_per_category, y=protection_categories_unique)
Возможно информация важная, на для нашей задачи тут ничего полезного. Всего три категории, по которым мы не собираемся сортировать данные. Попробуем другой путь.
Сначала я пропустил эти категории, однако благодаря ряду советов в комментариях вернулся к этим данным вновь. Если мы хотим снять самые туристические сливки, и отсечь множество больничных корпусов и сталинок, нам нужна категория объектов федерального значения. Обязательно учтем это при выборке по авторам.
Выборка по авторам
Итак, что мы пока имеем. Поиск у нас сузился до центральных районов. Там больше всего интересующих нас объектов. Но "больше" означает ли "лучше"? Посмотрим статистку по авторам всех архитектурных произведений города. Есть небольшая сложность в том, что у каждого объекта авторы перечислены просто через запятую, иногда с их должностями, иногда просто ФИО. Пришлось исправить это небольшим фильтром, чтобы остались только ФИО.
authors_all = list(data['author'])
authors = []
total_per_author = []
for author_line in authors_all:
if author_line == author_line:
if ',' in author_line:
for author in author_line.replace(';', ',').replace('арх. ', '').replace('худ. ', '').replace('гражд.инж. ', '').replace('архитекторы ', '').replace('фонтанный мастер ', '').replace('арх-ры ', '').split(','):
author = author.strip()
if author not in authors:
authors.append(author.strip())
total_per_author.append(1)
else:
index = authors.index(author.strip())
total_per_author[index] += 1
else:
if author not in authors:
authors.append(author.strip())
authors_df = pandas.DataFrame(authors, columns=['name'])
authors_df['count'] = total_per_author
seaborn.barplot(x=authors_df['count'],
y=authors_df['name'])
Этот график совсем не выглядит информативным. Сколько же там авторов:
print('Авторов в выбоке:', len(authors_df))
Авторов в выбоке: 2011
Конечно, для такого количества авторов столбчатую диаграмму не построишь. Чисто для целей визуализации предлагаю посмотреть тех авторов, которые построили более 20 объектов. Меняем последние строчки кода на:
most_frequent_authors_df = authors_df.loc[(authors_df['count'] > 20)]
seaborn.barplot(x=most_frequent_authors_df['count'],
y=most_frequent_authors_df['name'])
Чем дальше, тем интересней! И кто же этот Г.А. Симонов, который судя по статистике отстроил чуть ли не половину города, а в честь него даже ни одной улицы не назвали? Заглянем в Википедию:
Григорий Александрович Симонов (23 января 1893, Ташкент — 31 января 1974, Москва) — советский архитектор, инженер, педагог.
Г. А. Симонов родился в Ташкенте. Детство провел в Троицке, там окончил гимназию.
Окончил Петроградский Институт гражданских инженеров в 1920 году, где преподавал с 1929 года.
В 1919—1922 гг. обучался в Академии Художеств.
С 1924 года руководил Проектным Бюро Стройкома.
С 1943 года — заместитель председателя Государственного Комитета по делам архитектуры при Совете Народных Комиссаров СССР.
С 1947 по 1949 год — председатель Комитета по делам архитектуры при Совмине СССР.
С 1955 года — преподаватель Московского архитектурно-строительного института.
Теперь понятно, это послереволюционный, советский архитектор, руководивший сооружением множества объектов в тогдашнем Ленинграде. Если отставить личность товарища Симонова в покое, что еще мы здесь видим? Имеет место выброс статистических данных — Симонов упоминается так часто, что затмевает других. К тому же друг попросил старинные памятники архитектуры. Так что, ни в коем случае не умаляя творцов последнего века, давайте исключим из выборки все сооружения старше 1900 года. Правда тут есть небольшая проблема...
В нашем датасете столбец с датами — текстовый, произвольной длинны и содержания. Где-то стоит одна дата, где-то период, где-то просто двухзначная цифра века постройки. В данном случае мы не можем сделать числовую выборку, а можем работать только со строками. Если бы точность была важна, можно было бы преобразовать этот столбец, как мы сделали с авторами, можно было бы отбирать строки регулярными выражениями. Но это всего лишь поездка друга, так что решаем не усложнять, и волевым решением отсекаем все даты, где встречается число 19. Мы упустим несколько построек, где в поле дата указано "конец 19 в.", "1819" и тому подобных. Примем это как допустимые потери. Снова, мы не сможем визуализировать всех авторов выборки, но отобразим хотя бы наиболее часто встречающихся.
districts_authors_df = pandas.DataFrame(districts_unique, columns=['district'])
for col in most_frequent_authors_df['name']:
districts_authors_df[col] = 0
districts_authors_df.set_index('district', inplace=True)
top_poi = []
for district in districts_unique:
district_df = data.loc[(data['district'] == district) &
(data['protection_category'] == 'Объект культурного наследия федерального значения') &
(~data["date"].str.contains('19', na=True))]
for index, row in district_df.iterrows():
for author in most_frequent_authors_df['name']:
if row['author'] == row['author'] and author in row['author']:
districts_authors_df[author][district] += 1
for author in authors_df['name']:
if row['author'] == row['author'] and author in row['author']:
top_poi.append(row['number'])
seaborn.heatmap(districts_authors_df, xticklabels=True, yticklabels=True) #, annot=True
Ситуация проясняется. У нас теперь есть список наиболее интересных для нашей задачи районов и авторов. Переменная top_poi
(Top Points of Interests) содержит номера наших призеров. Но где именно находятся объекты? Пришло время для геокодирования...
Геокодирование
У нас есть колонка с адресами объектов, но если мы хотим определить, как далеко или близко что-то находится, нам нужны координаты. Процесс конвертации адресов в координаты называется геокодирование или геокодинг. У Яндекса есть отличный сервис, который все сделает за нас.
Для начала сделаем копию нашего датасета и добавим туда два новых поля: долгота и широта:
df = pandas.read_csv('spb_memo.csv')
df['lat'] = float('nan')
df['lon'] = float('nan')
df.to_csv("spb_memo_geo.csv", index=False)
У геокодера Яндекса бесплатно только 1000 запросов в сутки. В нашей переменной top_poi
содержится чуть меньше 500 объектов, так что на это исследование у нас сегодня хватает:
df = pandas.read_csv('spb_memo_geo.csv')
from decimal import Decimal
import os
from dotenv import load_dotenv
from yandex_geocoder import Client
load_dotenv('.env')
yandex_geo_api_key = os.environ.get("yaGeoApi")
client = Client(yandex_geo_api_key)
coordinates = 0
api_limit_per_day = 1000
for poi in top_poi:
if api_limit_per_day > 0:
poi_row = df.loc[(df['number'] == poi)]
if poi_row.empty:
lat = float('nan')
else:
lat = list(poi_row['lat'])[0]
addr = list(poi_row['address'])
if lat != lat and len(addr) > 0 and addr[0] == addr[0] and len(addr[0]) > 5:
coords = client.coordinates("Санкт-Петербург, " + addr[0])
df.loc[poi_row.index, 'lon'], df.loc[poi_row.index, 'lat'] = coords
api_limit_per_day -= 1
df.to_csv("spb_memo_geo.csv", index=False)
Последний отсев
Итак, наш усовершенствованный датасет теперь содержит долготу и широту для интересующих нас объектов. Визуализируем его в виде красных точек:
df = pandas.read_csv('spb_memo_geo.csv')
lat_lon = df[df['number'].isin(top_poi)]
x = list(lat_lon['lat'])
y = list(lat_lon['lon'])
seaborn.scatterplot(x=x, y=y, c=['red'])
Вы будете правы, если скажите, что такое расположение точек не соответствует сторонам света — по X должна быть долгота, а по Y — широта. Но на данном этапе нам это и не нужно. Мы ищем зависимости, отклонения, совпадения и т.д.
И что это за одинокая точка в левом верхнем углу, из-за которой у нас верхняя половина графика пустая?
print(df.loc[(df['lat'] < 59.8) & (df['lon'] > 31)])
7922 Летний сад
Name: name, dtype: object
Ах, это Летний сад, который друг упоминал в своей просьбе. Туда он пойдет независимо от расстояния. Вычеркиваем его из выборки, к тому же для нас это небольшой выброс данных, делающий картину менее понятной.
lat_lon = df.loc[df['number'].isin(top_poi) & (df['lat'] > 59.6) &
(df['lat'] < 62) & (df['lon'] > 29) & (df['lon'] < 30.8)]
x = list(lat_lon['lat'])
y = list(lat_lon['lon'])
seaborn.scatterplot(x=x, y=y, c=['red'])
Итог
Итак, пришло время ответить на самый главный вопрос нашего исследования. Раз друг хочет поселиться где-то в центре, чтобы независимо от того какой объект он выберет, это было относительно недалеко, просто возьмем среднее значение всех координат.
xy_center = (sum(x) / len(x), sum(y) / len(x))
seaborn.scatterplot(x=y, y=x, c=['red'])
seaborn.scatterplot(x=[xy_center[1]], y=[xy_center[0]], c=['green'])
Зеленая точка и есть наше заветное место. Интересно, а где это вообще?
print(xy_center[1], ',', xy_center[0])
59.926768, 30.294057
Снова прибегнем к геокодеру Яндекса, но на этот раз в обратном направлении — для преобразования координат обратно в адрес:
address = client.address(Decimal("30.294057"), Decimal("59.926768"))
print(address)
Россия, Санкт-Петербург, набережная Крюкова канала
Вот оно! Когда все только началось, лично мне казалось, что центральная точка окажется где-то на Дворцовой площади или в Петропавловской крепости, но нет, другие архитектурные объекты оттянули ее на себя, и мы получили набережную Крюкова канала.
Что до друга, он взял этот адрес за точку отсчета, нашел где можно остановиться поблизости и провел в нашем городе незабываемые выходные.
Финальная визуализация
Это было чисто статистическое исследование, — максимум, что удалось выжать из этого датасета. Исследование не имело отношение к истории. Мы не узнали, какие архитектурные стили преобладают в Санкт-Петербурге, и как они менялись со временем; не увидели в выборках архитекторов-основателей. Надеюсь обо всем этом друг узнал из посещенных им экскурсий.
Напоследок, давайте сделаем все красиво, и действительно сопоставим наши данные с реальной картой, используя библиотеку OSMnx, основанную на данных OpenStreetMap (спасибо Carlos Lannister за подсказку):
import osmnx as ox
# Center of map
latitude = 59.939099
longitude = 30.315877
point = (latitude, longitude)
G = ox.graph_from_point(point, dist=10000, retain_all=True, simplify = True,
network_type='all')
u = []
v = []
key = []
data = []
for uu, vv, kkey, ddata in G.edges(keys=True, data=True):
u.append(uu)
v.append(vv)
key.append(kkey)
data.append(ddata)
# List to store colors
roadColors = []
roadWidths = []
for item in data:
if "length" in item.keys():
if item["length"] <= 100:
linewidth = 0.10
color = "#a6a6a6"
elif item["length"] > 100 and item["length"] <= 200:
linewidth = 0.15
color = "#676767"
elif item["length"] > 200 and item["length"] <= 400:
linewidth = 0.25
color = "#454545"
elif item["length"] > 400 and item["length"] <= 800:
color = "#d5d5d5"
linewidth = 0.35
else:
color = "#ededed"
linewidth = 0.45
else:
color = "#a6a6a6"
linewidth = 0.10
roadColors.append(color)
roadWidths.append(linewidth)
bgcolor = "#061529"
fig, ax = ox.plot_graph(G, node_size=0,figsize=(27, 40),
dpi = 300,bgcolor = bgcolor,
save = False, edge_color=roadColors,
edge_linewidth=roadWidths, edge_alpha=1,
show=False, close=False)
for i in range(len(x)): #
ax.scatter(y[i], x[i], s = 100, c='red')
ax.scatter(xy_center[1], xy_center[0], s = 100, c='green')
Как видно из кода, красные точки — это объекты культурного наследия Санк-Петербурга (в данном случае порядка 1 300 объектов, без ограничения по годам), а зеленая точка — рекомендованное место старта.
Где же мистика?
Собрав все правки, пожелания и рекомендации из комментариев я еще раз переделал расчёты:
Убрал из выборки Петродворцовый район
Добавил Приморский район
Ограничил выборку только объектами имеющими федеральное значение
Убедился, что значимые архитекторы города, такие как Бенуа, Растрелли, Трезини и др. включены в выборку
В результате всех этих коррекций точка сместилась на адрес Россия, Санкт-Петербург, Инженерная улица, 2-4А, тот самый мистический Инженерный замок, с его призраком, легендами и загадочной надписью на фасаде. Ну чем не мистика?
Ниже обновленная финальная визуализация со всеми коррекциями и новой центральной точкой:
Если захотите проверить мои расчёты, можете найти все файлы в моём репозитории Github.