Получение статистических данных с помощью Python, Pandas и Bokeh

Германия — это не только страна с крупнейшей экономикой в ​​Европе, но и страна с красивыми пейзажами и интересной культурой. Неудивительно, что Германия является популярным местом для туристов и эмигрантов со всего мира. Исследовательский анализ данных рынка аренды жилья в Германии может быть интересен не только аналитикам данных, но и людям, которые собираются жить и работать в этой стране. Я покажу некоторые интересные тенденции, которые можно найти в Python, Pandas и Bokeh.

Давайте углубимся в это.

Сбор данных

Для поиска данных я решил использовать ImmoScout24, который является не только одним из крупнейших (на момент написания статьи там числится около 72 тысяч квартир и домов), но и старейшим сайтом такого типа. Согласно https://web.archive.org, первая версия была сделана в 1999 году, более 20 лет назад. У ImmoScout24 также есть API и страница для разработчиков. Я связался с отделом по связям с общественностью, и они разрешили мне использовать данные сайта для этой публикации, но не смогли дать мне API-ключ. Вероятно, этот API предназначен только для партнеров, для добавления или редактирования данных о жилье, а не для пакетного чтения. Что ж, это не проблема; данные могут быть получены с веб-страниц с помощью Python, что делает задачу еще более сложной.

Прежде чем собирать данные аналогичным образом, сначала спросите разрешения у владельца, а также будьте «хорошим гостем дома»: не используйте слишком много потоков, чтобы предотвратить перегрузку сервера, используйте локально сохраненные HTML-файлы для отладки вашего кода, и в в случае использования веб-браузера по возможности отключите загрузку изображений.

Сначала я попытался получить данные страницы с помощью requests:

import requests


url_berlin = "https://www...."
print(requests.get(url_berlin))

Увы, не получилось — на странице есть защита от роботов, и перед получением результатов поиска человек должен подтвердить, что он не робот. Простые способы вроде смены юзер-агента не помогли. Что ж, мы действительно не роботы, и это не проблема. Библиотека Python Selenium позволяет использовать настоящий браузер Chrome для извлечения данных и автоматизации чтения страниц:

from selenium import webdriver
import time


def page_has_loaded(driver: webdriver.Chrome):
    """ Check if the page is ready """
    page_state = driver.execute_script('return document.readyState;')
    return page_state == 'complete'


def page_get(url: str, driver: webdriver.Chrome, delay_sec: int):
    """ Get the page content """
    driver.get(url)
    time.sleep(delay_sec)
    while not page_has_loaded(driver):
        time.sleep(1)
    return driver.page_source


options = webdriver.ChromeOptions()
driver = webdriver.Chrome(executable_path="./chromedriver", chrome_options=options)

# Get the first page
url_page1 = "https://www...."
html1 = page_get(url_page1, driver, delay_sec=30)

# Get next pages
url_page2 = "https://www..."
html2 = page_get(url_page1, driver, delay_sec=1)
...

Когда мы запустим код, откроется окно браузера. И как мы видим в коде, перед обработкой первой страницы я добавил 30-секундную задержку, которой достаточно, чтобы подтвердить, что я не робот. В течение этого интервала также хорошо открыть «настройки» браузера, нажав 3 точки справа, и отключить загрузку изображений; это значительно ускоряет обработку. Браузер остается открытым во время запросов следующих страниц, и дальнейшие данные могут обрабатываться без этой проверки «роботом».

После того, как мы получим тело HTML, извлечение данных станет более или менее простым. Во-первых, мы должны найти свойства элементов HTML, используя кнопку «Проверить» в веб-браузере:

Затем мы можем получить эти элементы на Python с помощью библиотеки Beautiful Soup. Этот код извлекает все URL-адреса квартир со страницы:

from bs4 import BeautifulSoup


soup = bs.BeautifulSoup(html1, "lxml")
li = soup.find(id="resultListItems")
links_all = []
children = li.find_all("li", {"class": "result-list__listing"})
for child in children:
    for link in child.find_all("a"):
        if 'data-go-to-expose-id' in link.attrs:
            links_all.append(base_url + link['href'])
            break
links_all.append(base_url + link['href'])

Давайте теперь выясним, какие данные мы можем получить.

Поля данных

Для каждого объекта недвижимости мы можем получить вот такую ​​страницу (из соображений конфиденциальности все значения и названия компаний размыты):

Давайте посмотрим, какие данные мы можем получить:

  • Заголовок. На этой картине мы видим (на немецком языке, конечно) «Красивая однокомнатная квартира в красивом (месте) Хермсдорфе». Я не думаю, что этот текст полезен для анализа, а просто для развлечения, позже мы построим из него облако слов.
  • Typ (тип). В данном примере это тип «Etagenwohnung» (квартира, расположенная на этаже).
  • Kaltmiete – это так называемая "холодная цена". Это арендная плата без учета коммунальных расходов, таких как отопление или электричество.
  • Warmiete, или «теплая цена». Название может ввести в заблуждение, как мы видим на картинке, «теплая цена» включает в себя не только расходы на отопление («heizkosten»), но и другие дополнительные расходы («nebenkosten»).
  • Этаж (этаж). На этой странице мы видим текст «0 из 3» — потребуется небольшой разбор. В Германии 1-й этаж — это первый надземный этаж, поэтому я полагаю, что 0 означает «цокольный этаж» или «Erdgeschoss» по-немецки. А из текста «от 0 до 3» мы также можем извлечь общее количество этажей в здании.
  • Осторожность (депозит). Стоимость, которая может покрыть возможный ущерб, и будет возвращена арендатору в конце арендной платы. Здесь мы видим значение «3-Kaltmieten». Сразу же учтем, что потребуется некоторый разбор.
  • Флаше (область). Как следует из названия, это площадь дома или квартиры.
  • Циммер (комната). В данном примере это 1.

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

Процесс парсинга HTML в целом такой же, как описано выше. Например, чтобы получить название свойства, можно использовать этот код:

soup = bs.BeautifulSoup(s_html, "lxml")

title = soup.find_all("h1", id="expose-title")
if len(title) > 0:
    str_title = title[0].get_text().strip()

Таким же образом можно найти и другие поля. После запуска кода для всех страниц я получил такой набор данных, который я сохранил в формате CVS:

property_id;logging_date;property_area;num_rooms;floor;floors_in_building;price_cold_eur;price_warm_eur;deposit_eur;property_type;publisher;city;title;address;region;
13507XXX1;2023-03-20;7.0;1;None;None;110;110;None;Sonstige;Private;Berlin;Lagerraum / Kellerraum / Abstellraum zu vermieten;None;Moabit, 10551 Berlin;
13613XXX2;2023-03-20;29.0;1;None;None;189;320;None;None;XXXXXXXX Sverige AB;Berlin;Wohnungstausch: Luise-Zietz-Straße 119;Luise-Zietz-Straße 119;Marzahn, 12000 Berlin;
...
14010XXXn;2023-03-20;68.0;1;None;None;28000;28000;1000;None;HousingXXXXXXXXX B.V;Berlin;Wilhelminenhofstraße, Berlin;Wilhelminenhofstraße 0;Oberschöneweide, 12459 Berlin;

Давайте теперь посмотрим, какую информацию мы можем получить.

Преобразование и загрузка данных

Как мы видели в последнем абзаце, данные о жилье обязательно потребуют некоторой очистки и преобразования.

Я собрал данные из 6 городов, расположенных в разных частях Германии: Берлина, Дрездена, Франкфурта, Гамбурга, Кёльна и Мюнхена. В качестве примера давайте проверим Берлин; для других городов подход такой же. Во-первых, давайте загрузим CSV в кадр данных Pandas:

import pandas as pd

df_berlin = pd.read_csv("Berlin.csv", sep=';', 
                        na_values=["None"], parse_dates=['logging_date'], 
                        dtype={"price_cold_eur": pd.Int32Dtype(), 
                               "price_warm_eur": pd.Int32Dtype(), 
                               "floor": pd.Int32Dtype(), 
                               "floors_in_building": pd.Int32Dtype()})
display(df_berlin)

Процесс прост, но есть несколько полезных трюков. Сначала парсинг был сделан на Python, а для пропущенных значений в CSV было написано «Нет». Я не хочу, чтобы «None» была текстовой строкой, поэтому я указал ее как параметр «na_values». Я также указал «;» в качестве разделителя и установите тип «pd.Int32Dtype» для целочисленных полей, таких как цена или номер этажа. Кстати, моя первая попытка была использовать UInt32, потому что цена все равно не может быть отрицательной, но оказалось, что при вычислении разностей иногда могут встречаться отрицательные значения, и это приводит к тому, что некоторые ячейки получают значения типа 4 294 967 295. На практике было просто проще сохранить Int32; к счастью для нас, цены на жилье не превышают максимальное значение Int32 ;)

Если все было сделано правильно, на выходе мы должны получить что-то вроде этого:

Проверим размерность и количество значений NULL:

display(df_berlin.shape)
display(df_berlin.isna().sum())

Вывод выглядит следующим образом:

Мы видим, что в Берлине доступно для аренды 3556 объектов, каждый объект имеет «холодные» и «теплые» цены, площадь и количество комнат; эти поля, вероятно, являются обязательными. Поле «тип» отсутствует для 2467 свойств, 2200 свойств не имеют значения «этаж» и так далее. Как было описано ранее, некоторые поля, такие как значения «депозита», потребуют преобразования, например, нам понадобится метод для преобразования текстовых строк, таких как «3 Nettokaltmieten», в числовые значения.

Базовый анализ

Во-первых, давайте посмотрим, что мы можем сделать с помощью Pandas, не прилагая серьезных усилий для написания кода. В качестве разминки давайте получим описательную статистику набора данных, используя метод Pandas «describe»:

display(df_berlin.drop(columns=['property_id']).describe().style.format(precision=0, thousands=","))

Здесь я лишь немного настроил вывод: убрал из результатов «property_id» и подкорректировал стиль вывода, добавив разделитель «тысячи». Результат для Берлина выглядит так:

Мы видим, что в Берлине зарегистрировано 3556 объектов недвижимости. Мы уже получили это значение на предыдущем шаге, и хорошо иметь какую-то проверку правильности наших результатов. Средняя (50-й процентиль) площадь этих 3556 объектов составляет 60 м², а средняя цена — 1645 евро. 75-й процентиль составляет 2271 евро, что означает, что 75% цен на аренду ниже этого значения. И что интересно, среднее количество комнат — 2, что выглядит интуитивно правильно, но есть даже 11-комнатные квартиры (максимальная цена €28 000 может подсказать, что эти 11-комнатные места недешевы).

В качестве следующего шага давайте составим матрицу рассеяния для некоторых полей: площадь объекта, количество комнат и цены. Это можно сделать в Pandas с помощью одного вызова метода:

pd.plotting.scatter_matrix(df_berlin[["property_area", "num_rooms", 
                                      "price_warm_eur", "price_cold_eur"]][(df_berlin['price_cold_eur'] > 0) & (df_berlin['price_cold_eur'] < 5000)], 
                           hist_kwds={'bins': 50, 'color': '#0C0786'}, 
                           figsize=(16, 16))

Здесь я также настроил параметры визуализации — отрегулировал количество бинов гистограммы, ограничил цены диапазоном 0-5000€ (иначе график слишком мелкий из-за каких-то выбросов), а также отрегулировал цвет. Результат не так уж и плох для 4 строк кода:

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

from bokeh.io import show, output_notebook, export_png
from bokeh.plotting import figure, output_file
from bokeh.models import ColumnDataSource, LabelSet, Label, Whisker, FactorRange
from bokeh.transform import factor_cmap, factor_mark, cumsum
from bokeh.palettes import *
from bokeh.layouts import row, column
output_notebook()

Мы готовы идти; Давайте начнем.

Типы недвижимости

Первый вопрос, который меня интересовал, — какие типы недвижимости доступны в Германии. Как я уже писал ранее, я собрал данные из 6 разных городов Германии. Давайте загрузим файлы CSV и объединим их в один фрейм данных:

df_berlin = pd.read_csv("Berlin.csv", sep=';', na_values=["None"], parse_dates=['logging_date'], dtype={"price_cold_eur": pd.Int32Dtype(), "price_warm_eur": pd.Int32Dtype(), "floor": pd.Int32Dtype(), "floors_in_building": pd.Int32Dtype()})
df_munchen = pd.read_csv("Munchen.csv", sep=';', na_values=["None"], parse_dates=['logging_date'], dtype={"price_cold_eur": pd.Int32Dtype(), "price_warm_eur": pd.Int32Dtype(), "floor": pd.Int32Dtype(), "floors_in_building": pd.Int32Dtype()})
df_hamburg = pd.read_csv("Hamburg.csv", sep=';', na_values=["None"], parse_dates=['logging_date'], dtype={"price_cold_eur": pd.Int32Dtype(), "price_warm_eur": pd.Int32Dtype(), "floor": pd.Int32Dtype(), "floors_in_building": pd.Int32Dtype()})
df_cologne = pd.read_csv("Cologne.csv", sep=';', na_values=["None"], parse_dates=['logging_date'], dtype={"price_cold_eur": pd.Int32Dtype(), "price_warm_eur": pd.Int32Dtype(), "floor": pd.Int32Dtype(), "floors_in_building": pd.Int32Dtype()})
df_frankfurt = pd.read_csv("Frankfurt.csv", sep=';', na_values=["None"], parse_dates=['logging_date'], dtype={"price_cold_eur": pd.Int32Dtype(), "price_warm_eur": pd.Int32Dtype(), "floor": pd.Int32Dtype(), "floors_in_building": pd.Int32Dtype()})
df_dresden = pd.read_csv("Dresden.csv", sep=';', na_values=["None"], parse_dates=['logging_date'], dtype={"price_cold_eur": pd.Int32Dtype(), "price_warm_eur": pd.Int32Dtype(), "floor": pd.Int32Dtype(), "floors_in_building": pd.Int32Dtype()}) 

df = pd.concat([df_berlin, df_munchen, df_hamburg, 
                df_cologne, df_frankfurt, df_dresden])

Этот код, очевидно, можно оптимизировать; например, я могу получить список всех CSV-файлов в папке с помощью glob.glob(‘*.csv’), но только для 6 файлов мне было лень это делать.

Теперь давайте найдем распределение типов свойств:

data_pr = df[['property_type']].fillna('Unbekannt').groupby(['property_type'], as_index=False).size().sort_values(by=["size"], ascending=True)

types = data_pr['property_type']
amount = data_pr['size']

palette = Viridis10 + Plasma10
p = figure(y_range=FactorRange(factors=types), width=1200, height=500, title="Apartment Types")
p.hbar(y=types, right=amount, height=0.8, color=palette[:len(types)])
p.xaxis.axis_label = 'Amount'
p.x_range.start = 0
show(p)

Я использовал некоторые настройки для улучшения результатов. Я заменил значения «NA» на «Unbekannt», немецкое слово, означающее «неизвестно» (все остальные типы на немецком языке, поэтому имена должны быть на одном языке). Затем я сгруппировал значения по типу собственности и отсортировал результат по сумме. И в качестве окончательной настройки я указал цветовую палитру, чтобы избежать скучных синих полос в стиле Matplotlib. Окончательный вывод выглядит следующим образом:

Результаты интересные. Многие свойства в листинге не имеют указанного типа. Среди других типов «Etagenwohnung» (квартира, расположенная на этаже) является самой популярной. Третий и четвертый типы — это «dachgeshoss» (место под крышей) и «erdgeschosswohnung» (квартира на первом этаже). Другие типы, такие как «maisonette» (небольшой дом) или «hochparterre» (приподнятый цокольный этаж), встречаются реже; читатели могут найти более подробное описание самостоятельно.

Давайте проверим цены на разные типы недвижимости. Я мог предположить, что «пентхаус» должен быть дороже «стандартной» квартиры, а квартира на первом этаже должна быть дешевле «стандартной». Давайте проверим это. Мы можем найти распределение цен, сгруппировав по типам и агрегировав результат в Pandas:

def q0(x):
    return x.quantile(0.01)

def q1(x):
    return x.quantile(0.25)

def q3(x):
    return x.quantile(0.75)

def q4(x):
    return x.quantile(0.99)


agg_data = {'price_cold_eur': ['size', 'min', q0, q1, 'median', q3, q4, 'max']}

prices = df[df['price_cold_eur'].notna()][['property_type', 'price_cold_eur']].fillna('Unbekannt').groupby('property_type', as_index=False).agg(agg_data)
prices = prices.sort_values(by=('price_cold_eur', 'size'), ascending=True)
display(prices.style.hide(axis="index"))

Результат мы можем увидеть в виде таблицы:

Используя график коробка с усами, мы можем представить результаты в наглядной форме:

prices = prices.sort_values(by=('price_cold_eur', 'size'), ascending=True)

p_types = prices["property_type"]
q1 = prices["price_cold_eur"]["q1"].astype('int32')
q3 = prices["price_cold_eur"]["q3"].astype('int32')
v_min = prices["price_cold_eur"]["q0"].astype('int32')
v_max = prices["price_cold_eur"]["q4"].astype('int32')
median = prices["price_cold_eur"]["median"].astype('int32')

palette = Viridis10 + Plasma10
source = ColumnDataSource(data=dict(p_types=p_types, 
                                    lower=v_min, 
                                    bottom=q1, 
                                    median=median, 
                                    top=q3,                                     
                                    upper=v_max,
                                    color=palette[:p_types.shape[0]]))

p = figure(x_range=p_types, width=1400, height=500, title="Property types distribution") 
whisker = Whisker(base="p_types", upper="upper", lower="lower", source=source)
p.add_layout(whisker)
p.vbar(x='p_types', top='top', bottom='median', width=0.9, color='color', 
       line_color="black", source=source)
p.vbar(x='p_types', top='median', bottom='bottom', width=0.9, color='color', 
       line_color="black", source=source)

p.left[0].formatter.use_scientific = False
p.y_range.start = 0
p.y_range.end = 4000
p.yaxis.axis_label = 'Rent Price, EUR'
show(p)

Я использовал одинаковую сортировку по размеру для каждой группы, поэтому цветовая палитра ящичной диаграммы такая же, как и на предыдущих диаграммах:

Мое предположение было частично правильным. Пентхаусы действительно самые дорогие, но между стандартными квартирами («etagenwohnung»), квартирами под крышей («dachgeshoss») и квартирами на первом этаже («erdgeschosswohnung») существенной разницы нет.

Цены на недвижимость

Получение распределения из всего набора данных полезно, но давайте углубимся и сравним данные из разных мест. Как было написано ранее, я собрал значения из 6 городов Германии:

Давайте посмотрим, насколько велика разница в данных, собранных из противоположных частей Германии.

Цена за площадь

Какой размер недвижимости можно арендовать за определенную сумму денег? Результаты легко получить с помощью диаграммы рассеяния; этот метод обычно требует только двух массивов для X и Y. Но мы можем сделать его лучше.

Во-первых, давайте создадим список возможных типов недвижимости, отсортированных по сумме, как мы это уже делали ранее:

pr_types = df[['property_type']].fillna('Unbekannt').groupby(['property_type'], as_index=False).size().sort_values(by=["size"], ascending=True)['property_type']

Затем мы можем создать 3 массива для конкретного города, данные будут включать площадь в квадратных метрах, цену и тип:

price_limit, area_limit = 3000, 200  

df_city = df_berlin  
data_pr = df_city[df_city['price_cold_eur'].notna()][['property_area', 'price_cold_eur', 'property_type']].fillna('Unbekannt')
data_pr = data_pr[(data_pr['property_area'] > 0) & (data_pr['property_area'] < area_limit) & (data_pr['price_cold_eur'] > 0) & (data_pr['price_cold_eur'] < price_limit)] 
values_x = data_pr["property_area"].astype('float').values
values_y = data_pr["price_cold_eur"].astype('float').values
types = data_pr["property_type"]

Здесь я заменил типы свойств NULL на «Unbekannt», которые не нужны для самой точечной диаграммы, но полезны для легенды графика. Я также использовал только значения, отличные от NaN, для цен. Были выбраны лимиты в 3000 евро и 200 м²; Я полагаю, что это разумный диапазон, который заинтересует большинство читателей.

В качестве дополнительного шага я создал модель линейной регрессии и обучил ее, используя точки данных; это позволит провести линейное приближение:

from sklearn.linear_model import LinearRegression

linear_model = LinearRegression().fit(values_x.reshape(-1, 1), values_y)

Теперь мы готовы рисовать результаты:

palette = (Viridis10 + Plasma10)[:len(pr_types)]

name = "Berlin"
df_city = df_berlin

source = ColumnDataSource(dict(property_area=values_x, 
                               price_cold_eur=values_y, 
                               property_type=types))

title = f"Property prices: {name} ({df_city.shape[0]} items, {data_pr.shape[0]} displayed)"
p = figure(width=1200, height=550, title=title)

# Draw scatter
p.scatter("property_area", "price_cold_eur",
              source=source, fill_alpha=0.8, size=4,
              legend_group='property_type',
              color=factor_cmap('property_type', palette, pr_types)
             )
# Draw approximation with a linear model
linear_model = LinearRegression().fit(values_x.reshape(-1, 1), values_y)
p.line([0, 9999], linear_model.predict([[0], [9999]]), line_width=4, line_dash="2 2", alpha=0.5)

p.xaxis.axis_label = 'Area, m^2'
p.yaxis.axis_label = 'Rent Price, EUR'
p.x_range.start = 0
p.x_range.end = area_limit
p.y_range.start = 0
p.y_range.end = price_limit
p.toolbar_location = None
p.legend.visible = True
p.legend.background_fill_alpha = 0.9

Я решил отобразить разные города на одном графике, поэтому весь этот код вынес в отдельный метод «get_figure_price_per_area». Затем я могу нарисовать несколько фигур Боке, объединив их в ряды и столбцы:

p1 = get_figure_price_per_area("Berlin", df, df_berlin)
p2 = get_figure_price_per_area("München", df, df_munchen)
p3 = get_figure_price_per_area("Hamburg", df, df_hamburg)
p4 = get_figure_price_per_area("Köln", df, df_koeln)
p5 = get_figure_price_per_area("Frankfurt", df, df_frankfurt)
p6 = get_figure_price_per_area("Dresden", df, df_dresden)
show(column(row(p1, p2), 
            row(p3, p4), 
            row(p5, p6)))

Результаты довольно интересные:

Во-первых, мы можем визуально сравнить количество объектов, доступных на рынке. Берлин (вверху слева на графике) — большой город, а также желанное место; рынок там самый большой, и разница в цене тоже самая высокая. Например, квартиры площадью 50 м² здесь можно найти в разных ценовых категориях: от бюджетных €400 до элитных €3000+. Для сравнения, недвижимость в Дрездене (справа внизу на графике) гораздо дешевле, а дорогих почти нет. Может быть, спрос на аренду в Дрездене намного ниже, и, вероятно, это связано с зарплатами и количеством доступных рабочих мест. Во-вторых, в данных Берлина отчетливо просматриваются два отдельных класса. Объяснения не знаю, возможно, это произошло из-за исторического разделения города на западную (БРД) и восточную (ГДР) части.

Гистограммы цены и площади

Мы также можем видеть цены в более компактной форме, используя гистограмму. Сделать гистограмму несложно; метод «гистограммы» NumPy может выполнять все вычисления:

def get_figure_histogram_price(name: str, df_city: pd.DataFrame):
    price_limit = 10000
    prices = df_city['price_cold_eur'].dropna().values
    hist_e, edges_e = np.histogram(prices, density=False, bins=50, range=(0, price_limit))

    # Create figure
    palette = Viridis256[::3][0:len(hist_e)]  # Take every 3rd item from array
    p = figure(width=1400, height=500, 
               title=f"Property prices: {name} ({df_city.shape[0]} total)")
    p.quad(top=hist_e, bottom=0, left=edges_e[:-1], right=edges_e[1:], fill_color=palette)
    p.x_range.start = 0
    p.x_range.end = price_limit
    p.y_range.start = 0
    p.y_range.end = 360
    p.xaxis[0].ticker.desired_num_ticks = 20
    p.xaxis.axis_label = "Rent Price, EUR"
    p.yaxis.axis_label = "Amount"
    p.toolbar_location = None
    return p

Я использовал тот же подход, чтобы нарисовать график, поместив несколько городов вместе:

p1 = get_figure_histogram_price("Berlin", df_berlin)
p2 = get_figure_histogram_price("München", df_munchen)
p3 = get_figure_histogram_price("Hamburg", df_hamburg)
p4 = get_figure_histogram_price("Köln", df_koeln)
p5 = get_figure_histogram_price("Frankfurt", df_frankfurt)
p6 = get_figure_histogram_price("Dresden", df_dresden)
show(column(row(p1, p2), 
            row(p3, p4), 
            row(p5, p6)))

Результаты явно коррелируют с графиком рассеяния, который мы видели ранее:

Мюнхен кажется самым дорогим местом, где пик раздачи составляет около 1500 евро, и, как уже упоминалось ранее, иметь 2 пика в Берлине выглядит интересно. Распределение смещено вправо, и, по крайней мере, для крупных городов, таких как Берлин или Мюнхен, мы можем видеть длинный хвост, где цены на некоторые объекты даже превышают 10 000 евро за м.

Что касается площади в квадратных метрах, я покажу результаты только для Берлина; остальные города выглядят в целом так же:

Большинство домов и квартир имеют площадь от 30 до 70 м², что выглядит интуитивно правильно, но, как мы видим, некоторые объекты даже меньше 10 м², а некоторые больше 250 м².

Коммунальные расходы

Следующее, что интересно знать, это сколько стоят коммунальные услуги. Как было описано ранее, все перечисленные квартиры имеют 2 цены: так называемую «теплую» (с коммунальными услугами, такими как отопление и электричество) и «холодную» стоимость. Подсчитаем разницу и построим точечный график:

name = "Berlin"
df_city = df_berlin

df_area = df_city[['property_area', 'price_warm_eur', 'price_cold_eur', 'property_type']].copy()
df_area['property_type'] = df_area['property_type'].fillna('Unbekannt')

pr_types = df_area[['property_type']].groupby(['property_type'], as_index=False).size().sort_values(by=["size"], ascending=True)['property_type']

df_area['price_diff'] = df_area['price_warm_eur'] - df_area['price_cold_eur']
df_area = df_area[df_area['price_diff'] > 0] 
values_x = df_area['property_area'].astype('float').values
values_y = df_area['price_diff'].astype('float').values

p = figure(width=1200, height=500, title=f"Utilities cost: {name}")

source = ColumnDataSource(dict(property_area=values_x, 
                               price_diff=values_y, 
                               property_type=df_area["property_type"]))

palette = (Viridis10 + Plasma10)[:len(pr_types)]

# Draw scatter
p.scatter("property_area", "price_diff", source=source,
          fill_alpha=0.8, size=4,
          legend_group='property_type',
          color=factor_cmap('property_type', palette, pr_types))
# Draw approximation with a linear model
model = LinearRegression().fit(values_x.reshape(-1, 1), values_y)
p.line([0, 9999], model.predict([[0], [9999]]), line_width=2, line_dash="2 2", alpha=0.5, color='red')

p.xaxis.axis_label = 'Area, m^2'
p.yaxis.axis_label = '"Warm" - "Cold" price, EUR'
p.x_range.start = 0
p.x_range.end = 200
p.y_range.start = 0
p.y_range.end = 800
p.toolbar_location = None
p.legend.visible = True
p.legend.background_fill_alpha = 0.9
show(p)

Результат интересный:

Очевидно, что результаты сильно различаются: в разных домах могут быть разные типы отопления, утепления и так далее. Но в целом недвижимость площадью 50 м² может иметь около €200 коммунальных расходов в месяц, а удвоение площади также пропорционально удваивает расходы, в нашем примере до €400 в месяц за дом или квартиру площадью 100 м². Что касается типов недвижимости, то точки на графике распределены более-менее поровну, и визуальной корреляции между стоимостью и разными типами я не увидел.

Депозит

Депозит является важной частью договора аренды, поскольку его стоимость может быть довольно большой. Законный максимум для депозита в Германии — 3 «холодные» цены; посмотрим как оно в реальности.

Во-первых, давайте посмотрим, какие данные у нас есть:

display(df_berlin[["title", "price_cold_eur", "deposit_eur"]])

Результат выглядит следующим образом:

Мы видим, что значения различаются — некоторые владельцы помещают сумму в виде цифры, например «585 €», а некоторые используют текстовые описания, такие как «3 Nettokaltmieten» или «3 MM». Отобразить уникальные значения легко:

print(df['deposit_eur'].unique().tolist())

В выводе мы видим текстовые описания, такие как «Drei Nettokaltmieten», «Zwei Monatsmiete» и так далее. Чтобы проанализировать эти значения, я создал 2 метода, которые преобразуют текстовую строку в числовое значение. Может быть, эта проверка не покрывает всех возможностей, но в большинстве случаев она работает:

def value_from_str(price_str: str) -> Optional[float]:
    """ Convert string price like "7.935,60 EUR" to 7936 """
    try:
        # '4.800,00 EUR' => '4.800,00'
        if price_str.find(' ') > 0:
            price_str = price_str.split(" ")[0]
        s_filtered = ''.join(c for c in price_str if c in "0123456789,.")
        # "1000.0" => 1000.0
        if s_filtered.find(".") != -1 and s_filtered.find(",") == -1:
            return float(price_str)
        # 7.935,60 => 7935.60
        return float(s_filtered.replace(".", "").replace(",", "."))
    except ValueError as _:
        return None



def convert_price(s_deposit: str, cold_price: int) -> float:
    """ Convert text string and a price to a new value """
    try:
        if s_deposit is None:
            return None
        if isinstance(s_deposit, int) or isinstance(s_deposit, float):
            return s_deposit
        if '€' in s_deposit:
            # 585 € => 585
            return value_from_str(s_deposit)
        if 'zwei' in s_deposit.lower():
            return 2*cold_price
        if 'drei' in s_deposit.lower():
            return 3*cold_price
        if 'kalt' in s_deposit.lower() or 'km' in s_deposit.lower() or 'monat' in s_deposit.lower():
            # 2x Monatsnettokaltmieten => 2
            return cold_price*value_from_str(s_deposit)
        return value_from_str(s_deposit)
    except:
        return None

Используя эти методы, я могу сделать преобразование следующим образом:

convert_price("3 Nettokaltmieten", 659)
convert_price("1.274,00 EUR", 659)

Наконец, мы можем создать столбец в наборе данных с соотношением депозита к цене:

def get_deposit_ratio(s_deposit: str, cold_price: int) -> float:
    """ Calculate the ratio between deposit and price """
    try:
        deposit_price = convert_price(s_deposit, cold_price)
        if deposit_price is not None and cold_price != 0:
            return deposit_price/cold_price
    except:
        pass
    return None


df_berlin["deposit_price_ratio"] =  df_berlin.apply(lambda x: get_deposit_ratio(s_deposit=x['deposit_eur'], 
                                                                                cold_price=x['price_cold_eur']), 
                                                    axis=1)

С этим новым столбцом легко создавать гистограммы так же, как мы делали это раньше:

def get_deposit_histogram(name: str, df_city: pd.DataFrame):
    """ Get Bokeh figure from a dataframe """
    prices = df_city['deposit_price_ratio'].dropna().values
    hist_e, edges_e = np.histogram(prices, density=False, bins=60, range=(0, 5))
    
    # Draw
    palette = Viridis256[::2][0:len(hist_e)]  # Take every 2nd item from array
    p = figure(width=1400, height=400, 
               title=f"Property area: {name} ({df_city.shape[0]} total)")
    p.quad(top=hist_e, bottom=0, left=edges_e[:-1], right=edges_e[1:], fill_color=palette)
    p.x_range.start = 0
    p.x_range.end = 5
    p.y_range.start = 0
    p.y_range.end = 400
    p.xaxis[0].ticker.desired_num_ticks = 20
    p.xaxis.axis_label = 'Deposit amount, relative to the "cold" price'
    p.yaxis.axis_label = 'Number of objects available'
    p.toolbar_location = None
    return p

    
p1 = get_deposit_histogram("Berlin", df_berlin)
p2 = get_deposit_histogram("München", df_munchen)
p3 = get_deposit_histogram("Hamburg", df_hamburg)
p4 = get_deposit_histogram("Köln", df_koeln)
p5 = get_deposit_histogram("Frankfurt", df_frankfurt)
p6 = get_deposit_histogram("Dresden", df_dresden)
show(column(row(p1, p2), row(p3, p4), row(p5, p6)))

Результаты интересны:

Многие арендодатели просят максимально возможный залог, который в Германии ограничен законом до 3 холодных цен. Хотя можно найти место с 2-кратным, 1-кратным или даже вообще без обязательного залога (kautionsfrei на немецком языке). И что удивительно, есть некоторые владельцы, которые просят депозиты выше, чем в 3 раза. Иногда, когда это значение равно 3,05, это можно объяснить ошибкой в ​​расчетах, но если значение депозита около 5х, то это точно не так.

Издатели недвижимости

Некоторые владельцы предпочитают сами сдавать свою недвижимость в аренду; другие сотрудничают с агентством. Насколько велики эти суммы? Нарисуем распределение в виде круговой диаграммы.

def get_figure_publisher(name: str, df_city: pd.DataFrame) -> figure:
    publishers = df_city[['publisher']].groupby(['publisher'], as_index=False).size().sort_values(by=["size"], ascending=False)
    publishers = publishers[publishers['size'] > 5]
    
    # Put private first
    data_private = publishers[(publishers['publisher'] == "Private")]
    data_non_private = publishers[(publishers['publisher'] != "Private")]
    data = pd.concat([data_private, data_non_private])
    
    palette = RdGy3[:1] + Viridis11 + BrBG11 + Plasma11 + Cividis11 + RdYlBu11 + RdGy11 + PiYG11
    
    data['angle'] = data['size']/data['size'].sum()*2*np.pi
    data['percentage'] = data['size'] / data['size'].sum() * 100
    data['color'] = palette[:data.shape[0]]
    
    p = figure(width=1100, height=750, title=f"Property publishers: {name}", toolbar_location=None, x_range=(-0.5, 1.0))
    pie_chart = p.wedge(x=0, y=1, radius=0.4,
            start_angle=cumsum('angle', include_zero=True), end_angle=cumsum('angle'),
            line_color="white", 
            fill_color='color', 
            legend_field='publisher', 
            source=data)
        
    p.axis.axis_label = None
    p.axis.visible = False
    p.grid.grid_line_color = None
    return p

В этом коде я сгруппировал кадр данных по издателю и отсортировал результат по размеру. Единственная хитрость заключалась в том, чтобы сначала поставить группу «private», и для ясности я также пометил эту группу другим цветом.

Результаты для 2 городов, Берлина и Мюнхена, выглядят так:

Интересно, что только 8,5% недвижимости в Берлине выставлено на продажу частными лицами; в Мюнхене эта сумма составляет 27%. Также интересно видеть, что более 50% свойств публикуются всего несколькими агентствами.

Номера этажей

Как мы видели ранее, расположение квартиры, будь то цокольный этаж или этаж под крышей, в основном не влияет на стоимость аренды. Но все же интересно узнать, сколько этажей у большинства зданий в Германии.

Изначально я не ожидал каких-то сложностей в этой задаче, но сложность заключалась в том, чтобы сделать кастомную сортировку. Во многих квартирах или домах не указан номер этажа, и я хотел поставить значение «неизвестно» слева. Это можно сделать, внедрив собственный ключ сортировки в Pandas. Сложность здесь заключается в том, что при выполнении сортировки DataFrame Pandas применяет custom_key не к одному значению, а к объекту «pd.Series». Итак, нам нужен второй метод для обновления значений в самой серии:

def to_digit(v):
    """ Convertion for string or digit """
    if v.isdigit():
        return f"{int(v):02d}" # "1" => "001"     
    return ' ' + str(v)


def custom_key(v):
    """ Custom key for the dataframe sort """
    # v: pd.Series, convert items for proper sort
    # ["1", "10", "2", "U"] => [" U", "001", "002", "010"]
    return v.apply(to_digit)


def get_figure_floors(city_name: str, df_city: pd.DataFrame) -> figure:
    """ Get Bokeh figure """
    floors = df_city[['floor']].astype(str).mask(df_city.isnull(), None).fillna("?").groupby(['floor'], as_index=False).size()
    floors = floors.sort_values(by=["floor"], key=lambda x: custom_key(x), ascending=True)

    # Draw
    palette = Viridis11 + Plasma11 + Cividis11
    values = floors['floor']
    amount = floors['size']
    p = figure(x_range=FactorRange(factors=values), width=1200, height=400, title=f"{city_name}: apartments floor")
    p.vbar(x=values, top=amount, width=0.8, color=palette[:len(values)])
    p.xaxis.axis_label = 'Floor №'
    p.yaxis.axis_label = "Amount"
    p.toolbar_location = None
    return p

p1 = get_figure_floors("Berlin", df_berlin)
p2 = get_figure_floors("München", df_munchen)
show(row(p1, p2))

Результат для первых двух городов, Берлина и Мюнхена, выглядит так:

Как мы видим, большая часть квартир в обоих городах расположена с 1 по 5 этажи. Но есть несколько квартир на 10–20 этажах, а есть квартира в Берлине на 87-м этаже (правда, я не проверял, опечатка ли это и действительно ли это здание существует).

Гео визуализация

Построить гистограмму более или менее просто; переходим к самому интересному: отображению объектов недвижимости на географической карте. И здесь у нас есть 2 задачи. Во-первых, нам нужно получить координаты, а во-вторых, нам нужно нарисовать карту.

Геокодирование

Давайте еще раз проверим наши данные: в фрейме данных есть поля «регион» и «адрес», которые мы можем использовать для запросов геокодирования:

Для получения координат я буду использовать библиотеку GeoPy; это бесплатно и не требует никакого ключа API (как, например, API Google Maps):

from geopy.geocoders import Nominatim
from functools import lru_cache


geolocator = Nominatim(user_agent="Python3.9")

@lru_cache(maxsize=None)
def get_coord_lat_lon(full_addr: str):
    """ Get coordinates for address """
    # Remove brackets: "Mitte (Ortsteil), 10117" => "Mitte, 10117"
    p1, p2 = full_addr.find('('), full_addr.find(')')
    if p1 != -1 and p2 != -1 and p2 > p1:
        full_addr = full_addr[:p1].strip() + full_addr[p2 + 1:]  
    # Make request
    pt = geolocator.geocode(full_addr)
    return (pt.latitude, pt.longitude) if pt else (None, None)

Код прост; единственная проблема заключалась в удалении скобок «(» и «)» из адресов; оказалось, что библиотека не вернула никаких данных в случае адресов вида «Nauen, Havelland (Kreis)». Также я использовал «lru_cache», чтобы не делать запросы по одному и тому же адресу несколько раз (некоторые агентства сдают несколько квартир в одном доме).

Имея этот метод, я могу легко запросить местоположения:

df_city = df_berli
df_addrs = df_city[["address", "region", "price_cold_eur"]].dropna().copy()
# Combine the address from 2 fields
df_addrs["address_full"] = df_addrs[["address", "region"]].apply(lambda x: x[0] + ", " + x[1], axis=1)

points = []
for index, row in df_addrs.iterrows():
    addr, price = row['address_full'], row['price_cold_eur']
    lat, lon = get_coord_lat_lon(addr)
    if (index % 50) == 0:
        print(f"{index} of {df_addrs.shape[0]}: {addr}, {price}, {lat}, {lon}")
    if lat and lon:
        points.append((addr, price, lat, lon)) 

print(f"Points added: {len(points)}")
return points

карта

Для рисования карты я буду использовать бесплатную библиотеку Folium. В качестве простого примера использования этой библиотеки карту с пометкой можно отобразить с помощью нескольких строк кода:

import folium
from folium.plugins import HeatMap
from branca.element import Figure

fig = Figure(width=1200, height=1000)
m = folium.Map(location=(50.59, 10.38), tiles="openstreetmap", zoom_start=7)

folium.Marker(location=(52.59, 13.37), popup="Berlin").add_to(m)

fig.add_child(m)
display(fig)

Этот код создаст красивую интерактивную карту без ключа API:

Но наша визуализация будет немного сложнее. Я буду использовать объекты Folium «Circle» для каждого свойства, а также сгруппирую разные цены с помощью «FeatureGroup»:

import folium
from folium.plugins import HeatMap
from branca.element import Figure
import matplotlib
import matplotlib.cm as colormap


def value_to_color(value: int) -> str:
    """ Convert price value to the HTML color """
    if value >= 5000:
        # Mark high values in special colors
        return "#FF00FF"
    
    norm = matplotlib.colors.Normalize(vmin=0, vmax=3000, clip=True)
    mapper = colormap.ScalarMappable(norm=norm, cmap=colormap.inferno)
    r, g, b, _ = mapper.to_rgba(value, alpha=None, bytes=True)
    return "#" + f"{(r << 16) + (g << 8) + b:#08x}"[2:]


def add_to_map(fmap, lat, lon, price):
    """ Add point to map """
    color_str = value_to_color(price)
    folium.Circle(
        location=[lat, lon],
        radius=100,
        popup=addr + ": " + str(price),
        color=color_str,
        fill=True,
        fill_color=color_str
    ).add_to(fmap)
    
    
def get_html_text_label(text: str, value1: int, value2: int=999999):
    """ Prepare HTML label with a gradient text """
    color1 = value_to_color(value1)
    color2 = value_to_color(value2-1)
    return f'<span style="background: linear-gradient(to right, {color1}, {color2}); padding-left: 2%; padding-top: 3%; padding-bottom: 1%;">&nbsp;&nbsp;&nbsp;&nbsp;</span><span>&nbsp;{text}</span>'

def generate_map(points: list, location: Tuple, name: str):
    """ Draw a city map """
    fig = Figure(width=1200, height=600)
    m = folium.Map(location=location, zoom_start=12)

    heat_map = folium.FeatureGroup(name='Heat Map')
    price_less_500 = folium.FeatureGroup(name=get_html_text_label('< 500', 0, 500))
    price_less_1000 = folium.FeatureGroup(name=get_html_text_label('500..1000', 500, 1000))
    price_less_2000 = folium.FeatureGroup(name=get_html_text_label('1000..2000', 1000, 2000))
    price_less_5000 = folium.FeatureGroup(name=get_html_text_label('2000..5000', 2000, 5000))
    price_more_5000 = folium.FeatureGroup(name=get_html_text_label('> 5000', 5000))

    heat_data = []
    for addr, price, lat, lon in points:
        if price < 500:
            add_to_map(price_less_500, lat, lon, price)
        elif price <= 1000:
            add_to_map(price_less_1000, lat, lon, price)
        elif price <= 2000:
            add_to_map(price_less_2000, lat, lon, price)
        elif price <= 5000:
            add_to_map(price_less_5000, lat, lon, price)
        else:
            add_to_map(price_more_5000, lat, lon, price)
        heat_data.append((lat, lon))

    heat_map.add_child(HeatMap(heat_data, min_opacity=0.3, blur=50))
    m.add_child(heat_map)
    m.add_child(price_less_500)
    m.add_child(price_less_1000)
    m.add_child(price_less_2000)
    m.add_child(price_less_5000)
    m.add_child(price_more_5000)

    folium.map.LayerControl('topright', collapsed=False, style=("background-color: grey; color: white;")).add_to(m)

    fig.add_child(m)
    return fig

Я также использовал тепловую карту в качестве фона, чтобы результаты выглядели лучше. Визуализация также потребовала некоторой настройки цветов и стилей CSS для отображения градиентов; окончательный результат выглядит так:

Результат более-менее интуитивен. В Берлине районы вокруг центра дороже, но особых «элитных» мест нет. Например, объекты недвижимости с ценой выше €5000/м распределены более или менее поровну:

Динамика аренды

Этот вопрос немного сложнее. Насколько быстро происходит процесс аренды, и как долго объекты недвижимости доступны для аренды? Вопрос сложный, потому что дата публикации собственности недоступна на веб-сайте. Но мы можем оценить эти данные косвенно, сравнив результаты, снятые в разные дни.

Идея проста. Каждое свойство имеет уникальный идентификатор. Данные по одному и тому же городу я сохранял дважды, с интервалом в 7 дней. Затем я отобразил две ценовые гистограммы: одну со всеми свойствами, а вторую со свойствами, которые были удалены в течение 7 дней (свойства, которые существуют в первом фрейме данных, но отсутствуют во втором):

def get_two_histograms(name: str, df_all: pd.DataFrame, df_closed: pd.DataFrame):
    """ Draw two dataframe histograms on the same graph """
    price_limit = 10000
    prices = df_all['price_cold_eur'].values
    hist_e1, edges_e1 = np.histogram(prices, density=False, bins=40, range=(0, price_limit))
    prices = df_closed[(df_closed['price_cold_eur'] < price_limit)]['price_cold_eur'].values
    hist_e2, _ = np.histogram(prices, density=False, bins=edges_e1)

    # Draw
    palette1 = Viridis256[::3][0:len(hist_e1)]  # Take every 3rd item from array
    p = figure(width=1400, height=500, 
               title=f"Property prices: {name} ({df_city.shape[0]} total)")
    p.quad(top=hist_e1, bottom=0, left=edges_e1[:-1], right=edges_e1[1:], fill_color=palette1, fill_alpha=0.8, legend_label='All properties')
    p.quad(top=hist_e2, bottom=0, left=edges_e1[:-1]+20, right=edges_e1[1:]-20, fill_color=palette1, legend_label='Properties removed from listing within 7 days')
    # Add percentage labels
    for i in range(hist_e1.shape[0]):
        pos_x = (edges_e1[i] + edges_e1[i + 1])/2
        pos_y = hist_e1[i]
        value = 100*hist_e2[i]/hist_e1[i] if hist_e1[i] != 0 else 0        
        value_str = f'{value:.1f}%' if value > 0.5 else "0%"
        p.add_layout(Label(x=pos_x, y=pos_y + 2, text_align="center",
                 text=value_str, text_font_size='7pt',
                 background_fill_color='white', background_fill_alpha=0.0))

    p.x_range.start = 0
    p.x_range.end = price_limit
    p.y_range.start = 0
    p.y_range.end = 480
    p.xaxis[0].ticker.desired_num_ticks = 20
    p.left[0].formatter.use_scientific = False
    p.below[0].formatter.use_scientific = False
    p.xaxis.axis_label = "Rent Price, EUR"
    p.yaxis.axis_label = "Amount"
    return p


df_berlin0 = pd.read_csv("Berlin_00.csv", sep=';', na_values=["None"]).drop_duplicates(subset='property_id', keep="first")
ids0 = df_berlin0["property_id"].unique().tolist()
df_berlin6 = pd.read_csv("Berlin_06.csv", sep=';', na_values=["None"]).drop_duplicates(subset='property_id', keep="first")
ids6 = df_berlin6["property_id"].unique().tolist()

df_f = df_berlin0.copy()
df_f["deal_closed"] = df_f['property_id'].apply(lambda pr_id: pr_id in ids0 and pr_id not in ids6)
df_f_closed = df_f[df_f['deal_closed'] == True]

p1 = get_two_histograms("Berlin", df_berlin0, df_f_closed)
show(p1)

Я также добавил процентные метки к столбцам, чтобы сделать столбцы более читабельными. Результат выглядит следующим образом:

Очевидно, что этот результат не является статистически значимым, поскольку эксперимент проводился только один раз. По крайней мере, я могу сказать, что на момент проведения этого теста около 20% объектов недвижимости в Берлине в диапазоне €800–1200 были сняты с листинга в течение недели. Очевидно, что более дорогие объекты остаются дольше; за тот же период было снесено только около 9% объектов недвижимости в ценовом диапазоне 3000 евро.

Обнаружение аномалий

В качестве следующего шага давайте немного развлечемся и попробуем найти аномалии, что-то необычное и нестандартное. И для этого я буду использовать алгоритм Isolation Forest, реализованный в библиотеке Python Scikit-Learn. Для поиска аномалий я буду использовать 3 признака: площадь, цена и количество комнат:

from sklearn.ensemble import IsolationForest


df_city = df_berlin[["region", "address", "price_cold_eur", "num_rooms", "property_area"]].dropna().copy().reset_index(drop=True)
anomaly_inputs = ["price_cold_eur", "num_rooms", "property_area"]
for field in anomaly_inputs:
    df_city[field] = df_city[field].astype(float)

display(df_city[anomaly_inputs])

model_if = IsolationForest(contamination=0.01)
model_if.fit(df_city[anomaly_inputs])

# anomaly_scores: generated by calling model_IF.predict() and is used to identify if a point is an outlier (-1) or an inlier (1)
df_city['anomaly_scores'] = model_if.decision_function(df_city[anomaly_inputs])
# anomaly : generated by calling model_IF.predict() and is used to identify if a point is an outlier (-1) or an inlier (1)
df_city['anomaly'] = model_if.predict(df_city[anomaly_inputs])

Как видно из кода, алгоритму требуется только один параметр, так называемое «загрязнение», которое определяет долю выбросов в наборе данных. Здесь я установил его на 1%; очевидно, параметр можно изменить при необходимости.

После вызова метода «fit» мы можем получить результаты. Метод «decision_function» возвращает оценку аномалии, а метод «predict» возвращает +1, если конкретный объект считается выбросом, или -1 в случае выброса. Давайте отобразим только выбросы:

display(df_city[df_city['anomaly'] == -1])

Результат выглядит следующим образом:

Для этих свойств некоторые параметры необычны, и, наблюдая за результатом, я могу предположить, что количество комнат, площадь или цена были большими. Но одним из преимуществ метода Изолирующий лес является его интерпретируемость. Пакет Python SHAP позволяет использовать значения Шепли для графического объяснения результатов:

import shap
shap.initjs()

explainer = shap.Explainer(model_if.predict, df_city[anomaly_inputs])
shap_values = explainer(df_city[anomaly_inputs])

Сам анализ занимает около минуты. После этого мы можем получить результаты для каждого элемента в нашем списке. Например, рассмотрим свойство с номером 3030:

shap.plots.waterfall(shap_values[3030])

Мы видим, что цена была в порядке, но площадь объекта 211 м² и количество комнат 5 были обработаны алгоритмом как необычные.

Также можно увидеть, как работает алгоритм, отобразив точечную диаграмму. Например, давайте посмотрим, как «количество комнат» и «цена» влияют на значения Шепли:

display(shap.plots.scatter(shap_values[:,'num_rooms'], color=shap_values[:,"price_cold_eur"]))

Результат выглядит следующим образом:

Здесь мы видим, что количество комнат больше 4 больше всего влияет на оценку.

Облако слов

Я бы не стал рассматривать последний шаг как настоящий анализ, а просто для развлечения, давайте построим облако слов и посмотрим, какие слова наиболее популярны в названиях имений. С библиотекой Python WordCloud мы можем сделать это всего несколькими строками кода:

from wordcloud import WordCloud
import matplotlib.pyplot as plt

from nltk.corpus import stopwords
stop_words_de = stopwords.words('german')

# Generate
text = ""
for s in df.title:
    s_out = s.replace('/', ' ').replace(':', ' ').replace(',', ' ').replace('!', ' ').replace('-', ' ')
    text += s_out + " "
    
wordcloud = WordCloud(width=1600, height=1200, stopwords=set(stopwords_de), collocations=False, background_color="white").generate(text)

# Show
plt.figure(figsize=(16, 12), dpi=100)
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis("off")
plt.show()

Результат выглядит следующим образом:

Такие слова, как «wohnung» (квартира), «zimmer» (комната) и «balkon» (балкон) являются наиболее популярными, мы также можем встретить такие слова, как «helle» (светлый), «moderne» (современный), «schöne». (красивый) и так далее.

Заключение

Как сказал Алан Смит, бывший научный сотрудник Управления национальной статистики Великобритании, на TED talks в 2017 году, мы должны любить статистику, потому что это наука о нас. И очень интересно исследовать набор данных, например данные об аренде жилья, и находить там интересные закономерности. Надеюсь, читателям он тоже был интересен не только как пример использования Панд или Боке, но и как небольшой экскурс в жизнь и культуру другой страны. Очевидно, я не проверял все данные; например, может быть интересно узнать, сколько арендодателей разрешают жильцам держать в квартире домашних животных и т. д. Читатели могут проверить это самостоятельно; фрагментов кода из этой статьи должно быть достаточно для этого.

Если вам понравилась эта история, смело подписывайтесь на Medium, и вы будете получать уведомления о выходе моих новых статей, а также полный доступ к тысячам историй других авторов.

Спасибо за прочтение.