Grabduck

Как я покупал квартиру

:

Я хотел написать статью про линейную регрессию, но потом подумал, да ну её, лучше куплю квартиру. И пошёл искать, что предлагают. А предлагают, как оказалось, много чего. В подходящий мне ценовой диапозон попало больше 500 квартир. И что, мне теперь все это просматривать? Ну нееет, программист я в конце концов или не программист. Надо это дело как-то автоматизировать.

Сбор данных


Прежде чем что-то решать, было бы неплохо взглянуть на общую картину, увидеть некую выжимку из данных. А для этого данные нужно сначала собрать. Меня интересовали квартиры в Минске до $60k (надеюсь, москвичи не подавились слюной, узнав, что за такие деньги реально купить квартиру?). Google сразу выдал несколько сайтов о недвижимости, среди которых наибольшее количество нужных мне настроек поиска оказалось на irr.by. Дизайн у него, конечно, не фонтан, но и я не девочка блондинка, чтобы выбирать сайт недвижимости по цвету. Да и данные в виде HTML-страниц меня в любом случае не устраивали.

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

  • цена (далее — переменная price)
  • возраст (age)
  • расстояние до метро (dist_to_subway)
  • этаж (storey) и этажность дома (storey_no)
  • наличие балкона или лоджии (balcony)
  • общая (total_space) и жилая площадь (living_space), а также площадь кухни (kitchen_space)
  • количество раздельных комнат (room_no)
  • тип санузла (restroom_com для общего, restroom_sep для раздельного)

Единственное, что вызвало вопросы, — это расстояние до метро. Сайты недвижимости обычно не предоставляют такой информации. Есть только название улицы, номер дома и ближайшая станция, а вот сколько до неё идти — об этом ни слова. К счастью, оказалось, что проблема определения координат по адресу не нова, и называется соответсвующий процесс не иначе как геокодирование, а Корпорация Бабла даже предоставляет бесплатный сервис для этого доброго дела. После получаса программирования с перерывом на кофе и печеньки модуль определения расстояния до метро по адресу был готов. (Следует отметить, что результаты оказались весьма точными — из примерно 50 проверенных адресов только 2 указали на улицу, но не на дом; остальные же были полностью корректны. Также стоит обратить внимание на то, что сервис лучше не DDOS-ить — если не делать небольшие перерывы между запросами, могут возникать ошибки.)

Поскольку продавцы далеко не всегда тщательно и аккуратно заполняют поля для описания квартиры, данные были неполны. По-хорошему, незаполненные поля нужно было отмечать как NA (Not Available) и передавать их дальше в таком виде. Но дело было вечером, а делать ещё было чего, поэтому я решил пойти по упрощённой схеме и ещё на этапе сбора данных забивать значения по умолчанию. При отсутствии информации о годе постройки я забивал 1980 (соответственно, возраст — 32 года), расстояние до метро — 2000 метров, этаж — 4, этажность — 7. Очень просто и почти наугад. Количество комнат, площадь и цена были обязательными параметрами, и при их отсутствии квартира просто отбрасывалась (хотя в итоге ни одного случая отсутствия хотя бы одного из этих параметров выявлено не было).

Отдельно нужно рассказать про тип санузла. Предвидя числовые вычисления я понимал, что работать со значениями «общий/раздельный» гораздо сложней, чем с числами, поэтому пришлось завести 2 отдельные переменные, по одной на каждый тип. При этом если одна переменная была равна 1, то вторая обязательно равнялась 0. В статистике это называется фиктивными или индикаторными переменными.

Но хватит про туалеты, пора посмотреть на данные.

Первый взгляд на данные


Одним из самых популярных средств анализа данных является проект R. R представляет собой среду разработки и язык программирования с широкими возможностями для манипулирования и визуализации данных, а также статистического анализа и машинного обучения. Существует несколько сред разработки для удобного редактирования скриптов, таких как RStudio и плагин для Emacs, но для большинства задач достаточно обычной консоли. Всё это кроссплатформено и совершенно бесплатно, то есть даром. R несомненно заслуживает отдельной развёрнутой статьи, здесь же я ограничусь описанием тех функций и конструкций языка, которые непосредственно буду использовать.

Парсер объявлений, который я описывал выше, сохранял полученные данные в виде CSV файла на диске. Чтобы загрузить его в R достаточно вызвать следующую функцию:

  > dat <- read.csv("/path/to/dataset.csv")

Первое, что бросается в глаза, это необычный оператор присваивания `<-`. Вместо него можно использовать и привычный символ `=`, однако это не приветствуется. Кроме того, стрелочный оператор может быть и инвертирован, т.е. предыдущую строку можно было эквивалетно переписать как:
  > read.csv("/path/to/dataset.csv") -> dat

Объект dat (имя «data» уже используется для функции загрузки встроенных наборов данных) имеет тип `data.frame`. Пусть вас не смущает точка в имени типа — в R точки являются абсолютно легальной частью идентификатора, как, например, `_` в C или `-` в Lisp (имена с точкой часто встречаются в старом коде, сейчас, по крайней мере для функций, их стараются не использовать из-за введения систем объектно-ориентированного программирования, интерпретирующих точки по своему). Фрейм данных напоминает собой таблицу в реляционных БД: колонки могут иметь разные типы, а доступ к ним можно осуществлять как по индексу, так и по имени колонки. Например, чтобы получить все данные из колонки price (6-ой по счёту) достаточно написать:
  > dat[6]  

или
  > dat["price"]       # есть также специальный синтаксис - dat$price

Синтаксис доступа к отдельным элементам фрейма данных похож на синтаксис доступа к элементам матрицы в традиционных языках программирования:
  > dat[3, 6]          # 3 запись из 6 колонки

Однако есть и несколько нестандартных функций:
  > dat[1:10, 1:6]     # подфрейм, образованный из первых 10 строк и первых 6 колонок
  > dat[1:10, c(3, 5)] # первые 10 строк и колонки 3 и 5 
  > dat[, 6]           # все строки 6-й колонки (аналогично dat[6])
  > dat[, -6]          # все строки всех колонок, кроме 6
  > dat[,]             # все строки всех колонок (аналогично dat)

Как я уже говорил, R предоставляет прекрасные средства визуализации данных. Одним из них является функция plot(). Эта функция полиморфна и может быть использована для объектов разных типов, но в простейшем случае она принимает просто 2 вектора (или любых типа, которые можно привести к вектору) одинаковой длины и отображает их точечную диаграму (scatter plot). Больше информации о функции plot() можно получить, напечатав в консоли R следующее:
  > ?plot

Для начала меня интересовало, на сколько комнат я могу рассчитывать за такие деньги. Следующая команда отображает точечную диаграмму зависимости цены от количества комнат:
  > plot(dat$room_no, dat$price)

Результат:

Ого! Есть 3-хкомнатные и даже 4-хкомнатные квартиры! Но всё не может быть так просто. Обычно на цену сильно влияет расстояние до метро. Посмотрим на значение переменной dist_to_subway для этих квартир:
  > dat[dat$room_no == 4, ]$dist_to_subway
   [1] 2000.000 2000.000 2000.000 2000.000 4305.613

В первой строке мы вначале фильтруем данные, оставляя только квартиры с 4 комнатами (`dat[dat$room_no == 4, ]`), а затем бёрем значения интересующего нас атрибута (`$dist_to_subway`). Во второй строке — результат в виде вектора. Как можно видеть, все квартиры имеют либо значение по умолчанию (2000 метров), либо больше. В любом случае это значит, что топать до метро придётся долго. Возможно, кого-то это устраивает, но не меня. С трёхкомнатными та же ситуация. А вот среди двухкомнатных оказалось много квартир со вполне приемлимым расстоянием до метро. На них я и решил остановиться, выбросив из данных 1-, 3- и 4-хкомнатные, а также отбросив колонки с количеством комнат (оно у нас фиксировано) и с URL страницы с описанием квартиры (для вычислений он нам ни к чему). Также, как оказалось, все из отфильтрованных квартир имеют раздельный санузел, а значит колонки restroom_sep и restroom_com для всех записей одинаковы, а значит брать их в расчёт также нет смысла.
  > dat2 <- dat[dat$room_no == 2, -с(7, 8, 9, 13)]

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

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

  > cor(dat2)
                       age     balcony dist_to_subway kitchen_space living_space
 age             1.0000000  0.23339483     0.23677636   -0.30167358  -0.18938523
 balcony         0.2333948  1.00000000    -0.06881481    0.05694279  -0.03505876
 dist_to_subway  0.2367764 -0.06881481     1.00000000    0.22700865  -0.21201038
 kitchen_space  -0.3016736  0.05694279     0.22700865    1.00000000   0.10018058
 living_space   -0.1893852 -0.03505876    -0.21201038    0.10018058   1.00000000
 price          -0.2246434  0.18848129    -0.11713353    0.35152990   0.22979332
 storey         -0.1740015  0.12504337    -0.03107719    0.22760853   0.09702503
 storey_no      -0.4683041 -0.28689325    -0.15872038    0.10098619   0.02122686
 total_space    -0.3732784  0.02748897     0.03466465    0.62723545   0.61874577
                     price      storey   storey_no total_space
 age            -0.2246434 -0.17400151 -0.46830412 -0.37327839
 balcony         0.1884813  0.12504337 -0.28689325  0.02748897
 dist_to_subway -0.1171335 -0.03107719 -0.15872038  0.03466465
 kitchen_space   0.3515299  0.22760853  0.10098619  0.62723545
 living_space    0.2297933  0.09702503  0.02122686  0.61874577
 price           1.0000000  0.35325897  0.24603010  0.51735302
 storey          0.3532590  1.00000000  0.26811766  0.18082811
 storey_no       0.2460301  0.26811766  1.00000000  0.14940533
 total_space     0.5173530  0.18082811  0.14940533  1.00000000

Как видно из матрицы, большая корреляция (> 0.6) наблюдается только между общей и жилой площадью, а также между общей площадью и площадью кухни. Это позволяет нам пренебречь ещё 2-мя атрибутами — living_space и kitchen_space — положившись на total_space.

Визуализация зависимостей


Здравый смысл подсказывает, что цена линейно зависит от общей площади. До формального определения линейной зависимости мы ещё дойдём, сейчас же ограничимся визуальным аспектом: если одна переменная (например, цена) линейно зависит от другой (например, площади квартиры), то на точечной диаграмме их зависимости должна просматриваться прямая линия. Ну что ж, проверим это предположение:
  > plot(dat2$dist_to_subway, dat2$price)

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

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

  > plot(dat2$dist_to_subway, dat2$price)

  > plot(dat2$age, dat2$price)

Как видно, если отбросить точки, соответсвующие значениям по умолчанию, то можно заметить вполне чёткую зависимость, однако эта зависимость обратная — чем больше расстояние до метро, тем меньше цена, и то же самое с возрастом дома.

Линейная модель


Основываясь на информации, полученной при визуализации данных, можно сделать вывод, что цена линейно зависит от параметров квартиры. Также можно выдвинуть гипотезу, что среднестатистическая цена квартиры с некоторым набором параметров является показателем её «хорошести». В этом случае мы можем вывести функцию зависимости качества квартиры от её параметров. А имея такую функцию, легко посчитать, какие из имеющихся на рынке квартир переоценены их хозяевами, а какие сильно недооценены. Именно последние нас и будут интересовать — ведь заплатить за товар меньше, чем он реально стоит, всегда приятней, чем наоборот.

Так всё-таки, что такое линейная зависимость? И почему она так называется? Чтобы понять это, вспомним школьный курс математики, а именно уравнение линии на плоскости:

                                              y = k * x + b

Знакомо? Думаю, да. Именно это уравнение и выражает линейную зависимость переменной y от переменной x, и именно его (хотя и с большим количеством «шума») мы видели на графиках зависимости цены от таких параметров как площадь, возраст и расстояние от метро. k здесь показывает угол наклона графика функции к оси X, а b — смещение по оси Y относительно начала координат.

А что, если у нас 2 свободные переменные? Тогда речь уже идёт об уравнении плоскости в пространстве:

                                      z = k1 * x + k2 * y + b

Если обобщить эту идею до n свободных переменных, то мы получим уравнение так называемой гиперплоскости в гиперпространстве:

                      h(X) = k0 + k1 * x1 + k2 * x2 + ... + kn * xn

Здесь x1..xn — это измерения гиперпространства, а h(X) — положение зависимой точки на гиперплоскости при заданных значениях переменных вектора X.

В случае нашего (уменьшенного) фрейма данных уравнение имеет вид:

      price = k0 + k1 * age + k2 * balcony + k3 * dist_to_subway + k4 * storey + k5 * storey_no + k6 * total_space

Ну что ж, осталось только найти коэффициенты k0..k6 и формула готова! Однако, как подобрать эти коэффициенты так, чтобы вычисленная цена наиболее точно соответствовала реальным данным? Здесь в ход и идёт линейная регрессия. Линейная регрессия пытается подобрать коэффициенты так, чтобы минимизировать квадрат разности между предсказанным значением зависимой переменной (цены, в нашем случае) и его реальным, известным из статистических данных. Более подробно про вычисление коэффициентов можно послушать по ссылкам в конце статьи, а здесь же я воспользуюсь средствами R, а именно функцией lm() (сокращение от Linear Model):
  > model <- lm(price ~ age + balcony + dist_to_subway + storey + storey_no + total_space, data = dat2)

Здесь символ ~ указывает на то, что мы передаём в функцию lm() аргумент типа «формула». Формула описывает общий вид уравнения, для которого мы хотим вычислить коэффициенты, в данном случае R понимает, что мы хотим получить уравнение, аналогичное указанному выше. Второй аргумент (в данном случае именованный) показывает, в каком фрейме данных искать указанные переменные (age, balcony, etc.). То же самое можно записать и короче:
  > model <- lm(price ~ ., data = dat2)

Здесь точка указывает, что левая часть уравнения зависит от всех переменных указанного фрейма данных (кроме как от себя сомой, разумеется).

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

  > coef(model)
     (Intercept)            age        balcony dist_to_subway         storey 
   21601.0057018     31.7479138   1981.3750585     -0.3962895    529.9350262 
       storey_no    total_space 
     594.3711746    523.7914531

(Intercept) соответсвует коэффициенту k0 и может быть интерпретирован как минимальная стоимость квартиры при всех параметрах равных нулю (сложно, конечно, представить квартиру площадью ноль квадратных метров, но идею, я думаю, вы поняли). Названия остальных коэффициентов говорят сами за себя. Коэффициент при переменной total_space показывает реальную стоимость квадратного метра (в отличие от той, которую обычно расчитывают риэлторы, не учитывающие коэффициента сдвига — intercept). Наличие балкона прибавляет квартире сразу 2 тысячи долларов, а вот расстояние до метро уменьшает стоимость на 40 центов с каждым метром. Единственный параметр, который вызывает сомнения, это возраст. По логике, чем старше квартира, тем дешевле она должна быть. Однако коэффициент при этой переменной положительный, хотя и не очень большой. Объяснений для такого феномена может быть несколько. Во-первых, как можно было видеть из графиков, разброс цен очень сильный, а это добавляет огромное количество шума. Во-вторых, способ сбора данных далеко не совершенен, а значит многие квартиры, которые мы пометили как 32-летние (значение по умолчанию) на самом деле могли быть гораздо старше. Ну и в-третьих, мы просто не учитываем множества других факторов, таких как проведение капитального ремонта в доме, который сильно поднимает цену и который мы вообще не брали в расчёт.

Однако наиболее важным для нас вариантом использования построенной модели является расчёт реальной стоимости квартиры. И в этом нам поможет полиморфная функция predict(), которая принимает на вход модель и фрейм данных, и возвращает вектор «предсказанных» значений зависимой переменной:

  > predicted.cost <- predict(model, dat2)

Ещё немного уличной магии для визуализации результата:
  > actual.price <- dat2$price         # сохраняем вектор цен в отдельную переменную для удобства 
  > plot(predicted.cost, actual.price) # предсказанная стоимость vs. цены из имеющихся данных
  > par(new=TRUE, col="red")           # параметры графика: рисовать на той же конве, использовать красный
  > dependency <- lm(predicted.cost, actual.price)  # ещё одна модель, на этот раз вспомогательная
  > abline(dependency)                 # отображаем вспомогательную модель в виде линии


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

Можно даже пойти дальше и посчитать коэффициенты недооценённости квартир. Для этого достаточно разделить

  > sorted <- sort(predicted.cost / actual.price, decreasing = TRUE)
  > sorted[1:10]
       343      233       15      485      326       81      384      279 
  1.182516 1.154181 1.145964 1.144113 1.132918 1.132496 1.132098 1.129221 
       385      175 
  1.126982 1.115920

Как можно видеть, 343-я по счёту (в изначальном фрейме данных) квартира недооценена почти на 20% (а это около $10k, как никак), следующая за ней 233 — на 15% и т.д. Неплохая может получится экономия, не так ли?

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

Заключение

Весь анализ, включая написание парсера и исследование геокодинга, занял меньше 8 часов. В методе всё ещё много ошибок и недоработок: расстояние до метро можно было бы измерять гораздо точней, за значение по умолчанию следовало бы брать среднее арифметическое из известных записей, а также неплохо было бы использовать и другие признаки, такие как состояние квартиры и ремонт. Однако даже такого наивного подхода хватило, чтобы значительно уменьшить количество ручной работы в разы. Отсюда можно сделать несколько выводов. Во-первых, интеллектуальный анализ данных применим не только к большим промышленным системам, но и к обычным бытовым вопросам. Во-вторых, он позволяет получить значительно более точное представление о данных, чем простой перебор вручную. Ну и, в-третьих, он значительно экономит время на исследование.

Оформление документов на новую квартиру уже началось. Пора бы задуматься о ремонте. Так, где у нас там сайт стройматериалов?..

Ссылки:

1. Исходники парсера. Для использования парсера достаточно скачать flatparser.jar (нажать на соотсветствующую ссылку, затем на View Raw) и запустить в соответствии с README.
2. Курс по машинному обучению на Coursera, где, в частности, подробно рассказывается про линейную регрессию.