Часть 1 - Визуализация данных, предварительная обработка и базовая модель прогнозирования

Вы когда-нибудь искали квартиру? Хотите добавить машинное обучение и сделать процесс более интересным?

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

Прежде всего, я хочу прояснить этот момент и объяснить, что означает «оптимальная квартира». Это квартира с набором различных характеристик, таких как «площадь», «район», «количество балконов» и так далее. И за эти характеристики квартиры мы ожидаем определенной цены. Похоже на функцию, которая принимает несколько параметров и возвращает число. Или, может быть, черный ящик, дающий некоторую магию.

Но… есть большое «но», иногда вы можете столкнуться с завышенной ценой квартиры по ряду причин, например, из-за хорошего географического положения. Также есть более престижные районы в центре города и районы за городом. Или… иногда люди хотят продать свои квартиры, потому что переезжают в другую точку Земли. Другими словами, есть много факторов, которые могут повлиять на цену. Знакомо?

Маленький шаг в сторону

Прежде чем продолжить, позвольте мне сделать небольшое лирическое отступление.

Я прожил в Екатеринбурге (город между Европой и Азией, один из городов, где в 2018 году проводился чемпионат мира по футболу) 5 лет.
Я был влюблен в эти бетонные джунгли. И я ненавидел этот город за русскую зиму и общественный транспорт. Это растущий город, и каждый месяц здесь продаются тысячи и тысячи квартир.

Да, это перенаселенный, загрязненный город. В то же время - хорошее место для анализа рынка недвижимости. Я получил много объявлений о квартирах из Интернета. И я буду использовать эту информацию в дальнейшем.

Также я попытался визуализировать различные предложения на карте Екатеринбурга.

В Екатеринбурге в июле 2019 года продано более 2 тысяч однокомнатных квартир. У них была другая цена, от менее миллиона до почти 14 миллионов рублей.

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

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

Цель

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

Конечно, ничего идеального нет, и я был готов допустить ошибку в расчетах. Обычно для такого рода задач используют среднюю ошибку прогноза, и я был готов к 10% ошибке. Например, если у вас есть 2–3 миллиона российских рублей, вы можете проигнорировать ошибку в 200–300 тысяч, вы можете себе это позволить. Как мне показалось.

Подготовка

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

import pandas as pd
df = pd.read_csv(‘flats.csv’)
df.shape

2310 квартир за месяц, из этого можно было извлечь что-нибудь полезное. А как насчет общего обзора данных?

df.describe()

Ничего экстраординарного нет - долгота, широта, цена квартиры (ярлык «стоимость») и так далее. Да, на тот момент я использовал «стоимость» вместо «цена», надеюсь, это не приведет к недоразумениям, пожалуйста, считайте их такими же.

Уборка

Все ли записи имеют одинаковое значение? Некоторые из них представляют собой квартиры в виде кабинки, там можно работать, но жить там не хочется. Это маленькие тесные комнаты, а не настоящая квартира. Пусть их уберут.

df = df[df.total_area >= 20]

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

numerical_fields = ['total_area','cost']
for col in numerical_fields:
    mask = ~np.isnan(df[col])
    sns.distplot(df[col][mask],  color="r",label=col)
    plot.show()

Ну… ничего особенного, вроде нормальная раздача. Может, нам нужно пойти глубже?

sns.pairplot(df[numerical_fields])

Упс ... что-то не так. Удалите выбросы в этих полях и попробуйте снова проанализировать наши данные.

#Remove outliers
df = df[abs(df.total_area - df.total_area.mean()) <= (3 * df.total_area.std())]
df = df[abs(df.cost - df.cost.mean()) <= (3 * df.cost.std())]
#Redraw our data
sns.pairplot(df[numerical_fields])

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

Трансформация

Надпись «год», указывающая на год постройки, должна быть преобразована в нечто более информативное. Пусть это будет возраст постройки, иными словами, насколько старый конкретный дом.

df['age'] = 2019 -df['year']

Посмотрим на результат.

df.head()

Есть все виды данных, категориальные, Nan-значения, текстовое описание и некоторая геоинформация (долгота и широта). Отложим последние, потому что на этом этапе они бесполезны. Мы вернемся к ним позже.

df.drop(columns=["lon","lat","description"],inplace=True)

Категориальные данные

Обычно для категориальных данных люди используют разные виды кодирования или такие вещи, как CatBoost, которые дают возможность работать с ними, как с числовыми переменными.
Но можно ли использовать что-то более логичное и интуитивно понятное? Пришло время сделать наши данные более понятными, не теряя их смысла.

Районы

Что ж, существует более двадцати возможных округов, можем ли мы добавить в нашу модель более 20 дополнительных переменных? Конечно, могли, но… должны ли? Мы люди и можем сравнивать вещи, не так ли?

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

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

Я использовал ту же идею, что и для визуализации квартиры. Самый «престижный» и «дорогой» район окрашен в красный цвет, а наименее - в синий. Цветовая температура, вы об этом помните?

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

district_map =  {'alpha': 2, 
                 'beta': 4,
                 ...
                 'delta':3,
                 ...
                 'epsilon': 1}
df.district = df.district.str.lower()
df.replace({"district": district_map}, inplace=True)

Такой же подход будет использован для описания внутреннего качества квартиры. Иногда требуется ремонт, иногда квартира в хорошем состоянии и готова к проживанию. А в других случаях стоит потратить дополнительные деньги на то, чтобы он выглядел лучше (сменить краны, покрасить стены). Также могут быть коэффициенты использования.

repair = {'A': 1,
          'B': 0.6,
          'C': 0.7,
          'D': 0.8}
df.repair.fillna('D', inplace=True)
df.replace({"repair": repair}, inplace=True)

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

Мы используем тот же подход, что и раньше, плюс делаем предположение о строках, о которых мы ничего не знаем. Да, иногда люди не предоставляют всю информацию о своей квартире. Но, исходя из истории, можно попробовать угадать материал стен. В определенный период времени (например, период правления Хрущева) - мы знаем о типовых материалах для строительства.

walls_map = {'brick': 1.0,
             ...
             'concrete': 0.8,
             'block': 0.8,
             ...
             'monolith': 0.9,
             'wood': 0.4}
mask = df[df['walls'].isna()][df.year >= 2010].index
df.loc[mask, 'walls'] = 'monolith'
mask = df[df['walls'].isna()][df.year >= 2000].index
df.loc[mask, 'walls'] = 'concrete'
mask = df[df['walls'].isna()][df.year >= 1990].index
df.loc[mask, 'walls'] = 'block'
mask = df[df['walls'].isna()].index
df.loc[mask, 'walls'] = 'block'
df.replace({"walls": walls_map}, inplace=True)
df.drop(columns=['year'],inplace=True)

Также есть информация о балконе. По моему скромному мнению - балкон - вещь действительно полезная, поэтому не мог удержаться от размышлений.

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

Что ж, если информации нет, значит «балкона нет».

df.balcony.fillna(0,inplace=True)

После этого сбрасываем столбцы с информацией о году постройки (для него у нас есть хорошая альтернатива). Кроме того, мы удалили столбец с информацией о типе здания, потому что он имеет много NaN-значений, и я не нашел возможности заполнить эти пробелы. И мы отбрасываем все строки с NaN, которые у нас есть.

df.drop(columns=['type_house'],inplace=True)
df = df.astype(np.float64)
df.dropna(inplace=True)

Проверка

Итак ... мы использовали нестандартный подход и заменили категориальные значения на их числовое представление. И вот мы закончили преобразование наших данных.

Часть данных была удалена, но в целом это неплохой набор данных. Посмотрим, что с ней произошло.

def show_correlation(df):
    sns.set(style="whitegrid")
    corr = df.corr() * 100
# Select upper triangle of correlation matrix
    mask = np.zeros_like(corr, dtype=np.bool)
    mask[np.triu_indices_from(mask)] = True
# Set up the matplotlib figure
    f, ax = plt.subplots(figsize=(15, 11))
# Generate a custom diverging colormap
    cmap = sns.diverging_palette(220, 10)
# Draw the heatmap with the mask and correct aspect ratio
    sns.heatmap(corr, mask=mask, cmap=cmap, center=0,
                linewidths=1, cbar_kws={"shrink": .7}, annot=True,
                fmt=".2f")
    plot.show()
    # df[columns] = scale(df[columns])
    return df
df1 = show_correlation(df.drop(columns=['cost']))

Эээ… стало очень интересно.

Положительная корреляция

Общая площадь - балконы. Почему нет? Если наша квартира большая, будет балкон.

Отрицательная корреляция

Общая площадь - возраст. Чем новее квартира, тем больше площадь для проживания. Звучит логично, новые квартиры более просторны, чем старые.

Возраст - балкон. Чем старше квартира, тем меньше в ней балконов. Похоже на корреляцию через другую переменную. Возможно, это треугольник Возраст-Балкон-Площадь, где одна переменная неявно влияет на другую. Отложите это на время.

Возраст - район. Более старая квартира с большой вероятностью попадет в более престижные районы. Может быть, это связано с более высокой ценой возле центра?

Также мы могли видеть корреляцию между зависимыми и независимыми переменными.

plt.figure(figsize=(6,6))
corr = df.corr()*100.0
sns.heatmap(corr[['cost']],
            cmap= sns.diverging_palette(220, 10),
            center=0,
            linewidths=1, cbar_kws={"shrink": .7}, annot=True,
            fmt=".2f")

Вот так…

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

Между парами «возраст / стоимость» и «район / стоимость» существует отрицательная корреляция. Квартира в новом доме менее доступна, чем в старом. А в деревне квартиры дешевле.

Во всяком случае, это кажется ясным и понятным, поэтому я решил пойти с этим.

Модель

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

Подготовьте наши данные для следующих действий

from sklearn.model_selection import train_test_split 
y = df.cost
X = df.drop(columns=['cost'])
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

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

def predict(X, y_test, model):
    y = model.predict(X)
    score = round((r2_score(y_test, y) * 100), 2)
    print(f'Score on {model.__class__.__name__} is {score}')
    return score
def train_model(X, y, regressor):
    model = regressor.fit(X, y)
    return model
from sklearn.linear_model import LinearRegression
regressor = LinearRegression()
model = train_model(X_train, y_train, regressor)
predict(X_test, y_test, model)

Что ж… 76,67% точности. Это большое число или нет? На мой взгляд, неплохо. Более того, это хорошая отправная точка. Конечно, это не идеально, и есть потенциал для улучшения.

При этом - мы пытались спрогнозировать только одну часть данных. А как насчет применения той же стратегии для других данных? Да, время для перекрестной проверки.

def do_cross_validation(X, y, model):
    from sklearn.model_selection import KFold, cross_val_score
    regressor_name = model.__class__.__name__
    fold = KFold(n_splits=10, shuffle=True, random_state=0)
    scores_on_this_split = cross_val_score(estimator=model, X=X,
                                           y=y, cv=fold, scoring='r2')
    scores_on_this_split = np.round(scores_on_this_split * 100, 2)
mean_accuracy = scores_on_this_split.mean()
    print(f'Crossvaladaion accuracy on {model.__class__.__name__} is {mean_accuracy}')
    return mean_accuracy
do_cross_validation(X, y, model)

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

И время для последнего шага. Мы рассмотрим лучшую особенность линейной регрессии - интерпретируемость.

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

Попробуем интерпретировать нашу модель

def estimate_model(model):
    
    sns.set(style="white", context="talk")
    f, ax = plot.subplots(1, 1, figsize=(10, 10), sharex=True)
    sns.barplot(x=model.coef_, y=X.columns, palette="vlag", ax=ax)
    for i, v in enumerate(model.coef_.astype(int)):
        ax.text(v + 3, i + .25, str(v), color='black')
ax.set_title(f"Coefficients")
estimate_model(regressor)

Картина выглядит вполне логичной. Балкон / Стены / Площадь / Ремонт дают положительный вклад в фиксированную цену .

Чем дальше флет, тем больше отрицательный вклад. Также применимо для возраста. Чем старше квартира, тем ниже будет цена.

Итак, это было увлекательное путешествие.
Мы начали с нуля, использовали нетипичный подход к преобразованию данных, основанный на человеческой точке зрения (числа вместо фиктивных переменных), проверенных переменных и их соотношении друг с другом. После этого мы построили нашу простую модель, применили кросс-валидацию для ее тестирования. И как вишенка на торте - посмотрите на внутреннее устройство модели, что вселяет в нас уверенность в правильности нашего пути.

Но! Это не конец нашего пути, а всего лишь перерыв. Мы постараемся изменить нашу модель в будущем и, возможно (только возможно), это повысит точность прогнозов.

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