python

Анализ рынка недвижимости методом случайного леса

  • понедельник, 29 мая 2017 г. в 03:13:47
https://habrahabr.ru/post/329504/
  • Машинное обучение
  • Python
  • Data Mining


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


План статьи:


  1. Сбор данных
  2. Подготовка данных
  3. Анализ данных

1. Сбор данных


Для сбора информации по представленным квартирам с сайта агентства использовался Scrapy.
Вначале — в соответствии с туториалом


Сгенерировали проект
scrapy startproject tutorial

После чего в ./tutorial/spiders создали файл с описанием ''паука''
import scrapy

def extract_item_content(item, selection_expression, skip_chars=None):
    content = item.css(selection_expression).extract_first()
    if skip_chars and content:
        return content.strip(skip_chars)
    return content

class TvojaStolicaSpider(scrapy.Spider):
    name = "t-s"
    start_urls = ['http://www.t-s.by/buy/flats/']

    def parse(self, response):
        for item in response.css('ul.apart_body li.apart_item'):
            details_page_url = extract_item_content(item, 'div.item_descr h4 a::attr(href)')
            yield response.follow(details_page_url, callback=self.parse_details)

        next_page = response.css('li.arr a.page-lnk::attr(href)').extract_first()
        if next_page is not None:
            yield response.follow(next_page, callback=self.parse)

    def parse_details(self, response):
        params = {}
        for item in response.css('ul.about_params li.about_param'):
            key = item.css('div.param_name::text').extract_first()
            value = extract_item_content(item, 'div.param_descr::text', '\n\t ')

            if key == u'Микрорайон':
                value = extract_item_content(item, 'div.param_descr a::text', '\t')

            params[key] = value

        params[u'Цена'] = response.css('div.about_price_ye::text').extract_first()
        params[u'Описание'] = response.css('div.about_descr p::text').extract_first()
        return params

В нем указали "t-s" в качестве имени "паука" и url стартовой страницы. Из-за пейджинга она содержит не все ссылки на страницы с детальным описанием квартир, поэтому используем две callback-функции:


  • одну для получения ссылки на следующую страницу пейджинга
  • другую — для получения деталей описания квартиры

Подправили файл настроек для корректной работы с русскими символами

Добавили параметр в settings.py:


FEED_EXPORT_ENCODING = 'utf-8'

И запустили сбор данных

Командой


scrapy crawl t-s -o result.json

где t-s — имя "паука", а result.json — выходной файл.


В соответствии с документацией, "в силу исторических причин" spyder дописывает в конец существующего файла при каждом запуске вместо пересоздания нового. Поэтому если хотим каждый раз пересоздавать файл — можно сделать так:


scrapy crawl t-s -t json --nolog -o - > result.json

но лишимся логов в консоли.


Теперь можем перейти к следующему этапу:


2. Подготовка данных


Импортируем файл с данными и преобразовываем его в DataFrame
import json
import pandas as pd
import numpy as np

with open('result.json') as data_file:    
    data = json.load(data_file)

df = pd.io.json.json_normalize(data)

# Изменяем максимальное кол-во отображаемых строк и столбцов:
pd.set_option('display.max_columns', 200)
pd.set_option('display.max_rows', 25)

Размеры DataFrame:
df.shape

(410, 20)

— На старте имеем 410 записей и 20 признаков


В качестве индекса используем 'Код объекта', а сам столбец - удаляем
df.index = df[u'Код объекта'].apply(pd.to_numeric)
del df[u'Код объекта']

Теперь можем получать все данные по коду квартиры:
df.ix[818272]

Адрес                                            Ташкентская ул., 24 корпус 3
Балкон                                                    лоджия застекленная
Ближайшее метро                                                           NaN
Год капитального ремонта                                                    0
Год постройки                                                            1977
Город                                                                   Минск
Комнаты                                                                     3
Материал стен                                                    блок-комнаты
Микрорайон                                                            Чижовка
Описание                    Комната в трехкомнатной квартире в микрорайоне...
Площади                                                   71.4 / 13.95 / 9.16
Полы                                                                 линолеум
Район                                                         Заводской район
Санузел                                                            раздельный
Телефон                                                                  есть
Тип дома                                                          стандартные
Условия продажи                                                чистая продажа
Цена                                                            15 500 у. е. 
Этаж / Этажность                                                          2/9

Или только нужную их часть:
df.ix[818272][{u'Адрес',u'Цена',u'Этаж / Этажность'}]

Цена                               15 500 у. е. 
Этаж / Этажность                             2/9
Адрес               Ташкентская ул., 24 корпус 3

Это полезно, потому что в силу устройства сайта t-s.by код квартиры 818272 участвует в url:
http://www.t-s.by/buy/flats/818272/, поэтому при необходимости можно быстро посмотреть детали заинтересовавшей квартиры в браузере.


Названия столбцов:
df.columns

Index([u'Адрес', u'Балкон', u'Ближайшее метро', u'Год капитального ремонта',
       u'Год постройки', u'Город', u'Комнаты', u'Материал стен', u'Микрорайон',
       u'Описание', u'Площади', u'Полы', u'Район', u'Санузел', u'Телефон',
       u'Тип дома', u'Условия продажи', u'Цена', u'Этаж / Этажность'],
      dtype='object')

Частотное распределение значений в столбце 'Город'
df[u'Город'].value_counts()[:10]

Минск          354
Лесной           8
Прилуки          6
Заславль         6
Дзержинск        6
Фаниполь         4
Боровляны        4
Михановичи       3
Радошковичи      2
Свислочь         1

Поскольку исследование проводится для Минска, оставляем только записи к нему относящиеся
df = df[df[u'Город'] == u'Минск']
del df[u'Город']

Разбираемся с пропусками в данных


Некоторые столбцы не будут участвовать в конечной модели, но не удаляем их раньше времени


Описание

В итоговой модели этот столбец использоваться не будет, решено полагаться на реальные параметры квартиры, а не ее субъективное описание владельцем/риелтором


Адрес

Содержит название улицы и номер дома, в итоговой модели не используется, локация влиять на цену будет посредством столбцов 'Район' и 'Микрорайон'


Балкон
df[u'Балкон'].fillna(u'нету', inplace=True)
df[u'Балкон'].value_counts()

лоджия застекленная              167
балкон застекленный               62
2 лоджии застекленные             37
нету                              36
балкон                            28
лоджия                             8
2 балкона застекленные             5
2 лоджии                           3
лоджия застекленная + вагонка      3
3лз                                3
3л                                 1
балкон застекленный + вагонка      1

Сократим разнообразие значений в столбце:


def common_converter(mapping, param):
    if param in mapping:
        return mapping[param]
    return param

balcony_mapping = {
        u'балкон застекленный':u'балкон',
        u'лоджия застекленная':u'лоджия',
        u'лоджия застекленная + вагонка':u'лоджия',
        u'балкон застекленный + вагонка':u'балкон',
        u'2 балкона застекленные':u'2 балкона',
        u'2 лоджии застекленные':u'2 лоджии',
        u'3лз':u'3 лоджии',
        u'3л':u'3 лоджии'        
    }

df[u'Балкон'] = df[u'Балкон'].map(lambda x: common_converter(balcony_mapping, x))
df[u'Балкон'].value_counts()

лоджия       178
балкон        91
2 лоджии      40
нету          36
2 балкона      5
3 лоджии       4

Ближайшее метро
df[u'Ближайшее метро'].fillna(u'нету', inplace=True)
df[u'Ближайшее метро'].value_counts()

нету                         156
Уручье                        22
Каменная горка                21
Грушевка                      18
Кунцевщина                    16
Академия наук                 13
Спортивная                    10
Могилевская                   10
Малиновка                     10
Победы пл.                    10
Якуба Коласа пл.               9
Восток                         6
                            ... 
Пушкинская                     5
Петровщина                     5
Молодежная                     4
Парк Челюскинцев               4
Пролетарская                   3
Купаловская (Октябрьская)      3
Московская                     3
Михалово                       2
Тракторный завод               2
Первомайская                   2
Фрунзенская                    2
Немига                         1

Закономерно: в Уручье и Грушевке много новостроек, а в районе Каменной горки и Кунцевщины не так давно строилось много льготного жилья, которое продают теперь уже нельготники :)


Год постройки и Год капитального ремонта
# Вместо данных столбцов введем другие: 'Лет дому' и 'Лет с момента ремонта':
df[u'Год постройки'] = df[u'Год постройки'].apply(pd.to_numeric)
df[u'Год капитального ремонта'] = df[u'Год капитального ремонта'].apply(pd.to_numeric)

import datetime
current_year = datetime.datetime.now().year

def years_from_last_repair(row):
    if row[u'Год капитального ремонта'] == 0:
        row[u'Год капитального ремонта'] = row[u'Год постройки']
    return current_year - row[u'Год капитального ремонта']

df[u'Лет дому'] = df[u'Год постройки'].map(lambda x: current_year - x)
df[u'Лет с момента ремонта'] = df.apply(lambda row: years_from_last_repair(row), axis=1)

# Добавим столбец с флажком - был ли капремонт
df[u'Был капремонт'] = df[u'Год капитального ремонта']!=0

# Исходные два столбца удаляем:
df.drop({u'Год постройки', u'Год капитального ремонта'}, axis=1, inplace=True)

Комнаты
df[u'Комнаты'].value_counts()

1/1    106
2/2    101
3/3     85
4/4     14
3/2     11
3/1      9
2/1      7
2        6
3        4
4/1      3
4        2
4/3      2
6/6      1
4/2      1
5/5      1
1/2      1

Если в этом столбце первое число меньше второго — то это продается комната, а не квартира. Отбрасываем такие записи:


df = df[df[u'Комнаты'] != '1/2']

# Выделяем кол-во комнат:
df[u'Комнаты'] = df[u'Комнаты'].map(lambda x: x if x.find('/') == -1 else x.split('/')[0])
df[u'Комнаты'] = df[u'Комнаты'].apply(pd.to_numeric)
df[u'Комнаты'].value_counts()

2    114
3    109
1    106
4     22
6      1
5      1

— больше всего продается 2,3,1-комнатных


# Среднее кол-во комнат:
np.mean(df[u'Комнаты'])

2.1529745042492916

Материал стен
df[u'Материал стен'].value_counts()

панельный            160
кирпичный            100
каркасно-блочный      37
блок-комнаты          21
силикатно-блочный     19
монолитный            16

— здесь все в порядке, пропусков нет


Район и Микрорайон
df[u'Район'].value_counts()

Фрунзенский район     85
Первомайский район    55
Московский район      52
Ленинский район       36
Заводской район       34
Советский район       28
Центральный район     26
Октябрьский район     21
Партизанский район    16

— указан у всех записей


df[u'Микрорайон'].value_counts()

Малиновка                                                  18
Уручье                                                     18
Серебрянка                                                 17
Сухарево                                                   17
Р.Люксембург, К.Либкнехта                                  15
Кунцевщина                                                 13
Ангарская                                                  11
Чижовка                                                    10
Червякова, Шевченко                                        10
Каменная горка                                              9
Пушкина, Глебки, Притыцкого, Ольшевского, Кальварийская     9
Масюковщина                                                 9
                                                           ..
Дружба, Брилевичи                                           2
Тракторный Завод                                            2
Кижеватова, Асаналиева                                      2
Дзержинского, Хмелевского, Щорса                            2
Ванеева, Партизанский                                       1
Сельхоз посёлок                                             1
Район ДК "МАЗ"                                              1
Седых, Тикоцкого                                            1
Багратиона, Менделеева, Уральская                           1
Сосны                                                       1
Немига, Короля                                              1
Веснянка                                                    1

# Заменим пропущенные значения строкой 'Не указан':
df[u'Микрорайон'].fillna(u'Не указан', inplace=True)

Площади
df[u'Общая площадь'] = df[u'Площади'].map(lambda x: float(x.split(' / ')[0]))
df[u'Жилая площадь'] = df[u'Площади'].map(lambda x: float(x.split(' / ')[1]))
df[u'Площадь кухни'] = df[u'Площади'].map(lambda x: float(x.split(' / ')[2]))

# Удаляем столбец 'Площади':
df = df.drop(u'Площади', axis=1)

# Среднияя площадь типичной квартиры на рынке
np.mean(df[u'Общая площадь']), np.mean(df[u'Жилая площадь']), np.mean(df[u'Площадь кухни'])

(60.66762039660057, 35.14807365439093, 8.711161473087818)

Полы
df[u'Полы'].value_counts()

ламинированые        105
линолеум             104
паркет                55
деревянные            36
ДСП                    3
ковровое покрытие      2

df[u'Полы'].fillna(u'Не указано', inplace=True)

Санузел

При помощи


df[df[u'Санузел'].isnull()]

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


df.drop(821155, inplace=True)

toilet_mapping = {
    u'2 сан.узла':u'раздельный',
    u'3 сан.узла':u'раздельный'
    }

df[u'Санузел'] = df[u'Санузел'].map(lambda x: common_converter(toilet_mapping, x))
df[u'Санузел'].value_counts()

раздельный     268
совмещенный     84

Телефон
df[u'Телефон'].value_counts()

есть          273
нет            78
2 телефона      1

df[u'Телефон'] = df[u'Телефон'].map(lambda x: common_converter({u'2 телефона':u'есть'}, x))

Тип дома
df[u'Тип дома'].value_counts()

новостройка         78
стандартные         49
улучшеный проект    45
брежневка           29
хрущевка            21
сталинка            19
чешский проект      13

df[u'Тип дома'].fillna(u'не указан', inplace=True)

Условия продажи
df[u'Условия продажи'].value_counts()

чистая продажа          234
подбираются варианты     57
обмен                     7
обмен - разъезд           1

df[u'Условия продажи'].fillna(u'не указан', inplace=True)
df[u'Условия продажи'] = df[u'Условия продажи'].map(lambda x: common_converter({u'обмен - разъезд':u'обмен'}, x))

Цена
# Очищаем столбец 'price' и приводим к числовому:
# u'12 000 у. е. somestring' -> u'12 000' -> u'12000' -> 12000.0
df[u'Цена'] = df[u'Цена'].map(lambda x: float(x[:x.find(u' у. е.')].replace(' ','')))

%matplotlib inline
import seaborn as sns

# Распределение квартир по цене:
sns.distplot(df[u'Цена']/1000);


# Квартир в правой части "хвоста" распределения слишком мало.
# Ограничим выборку только квартирами стоимостью ниже некого порога, скажем 150 тыс у.е.:
df = df[df[u'Цена'] < 150000]

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


df[df[u'Цена'] < 35000][u'Описание']

Код объекта
818272    Комната в трехкомнатной квартире в микрорайоне...
822608    Две комнаты в трехкомнатной квартире. Стеклопа...
847586    Однокомнатная квартира в кирпичном доме. Комна...
844689    Дом находится в микрорайоне с хорошо развитой ...
834349     Однокомнатная квартира по привлекательной цен...
836501    Комната 17.6 метра в двухкомнатной квартире с ...
787265    Две комнаты 16 и 8.8 м2 (46/100 доли) в четыре...
777826    27/100 доли в четырёхкомнатной квартире. В пра...
829281    Комната с балконом в трехкомнатной квартире. О...
846239    1-на комнатная квартира на 4-ом этаже 5-ти эта...
830075    Кирпичный дом в Центральном районе. В квартире...

— видим, что здесь действительно либо комнаты, либо 'кирпичный дом', что не является целью нашего анализа


# Оставляем только квартиры стоимостью выше порога:
df = df[df[u'Цена'] > 35000]

# Распределение квартир теперь выглядит так:
sns.distplot(df[u'Цена']/1000);


Этаж/этажность

При помощи


df[df[u'Этаж / Этажность'].isnull()]

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


df.loc[df[u'Этаж / Этажность'].isnull(), u'Этаж / Этажность'] = u'5/9'

df.ix[847125]

Адрес                                                     Одинцова ул., 69
Балкон                                                            2 лоджии
Ближайшее метро                                             Каменная горка
Комнаты                                                                  1
Материал стен                                                    кирпичный
Микрорайон                                                       Не указан
Описание                 Однокомнатная квартира в кирпичном доме 1995 г...
Полы                                                                паркет
Район                                                    Фрунзенский район
Санузел                                                         раздельный
Телефон                                                                нет
Тип дома                                                    чешский проект
Условия продажи                                             чистая продажа
Цена                                                                 52000
Этаж / Этажность                                                       5/9
Лет дому                                                                22
Лет с момента ремонта                                                   22
Был капремонт                                                        False
Общая площадь                                                         43.9
Жилая площадь                                                         18.3
Площадь кухни                                                          8.8

df[u'Этаж'] = df[u'Этаж / Этажность'].map(lambda x: int(x.split('/')[0]))
df[u'Этажность'] = df[u'Этаж / Этажность'].map(lambda x: int(x.split('/')[1]))

df[u'Этаж'].value_counts()

2     53
5     43
1     36
4     36
3     31
9     24
7     22
6     21
8     19
12     8
10     6
11     6
19     5
18     5
14     3
17     3
16     2
13     1
20     1

sns.distplot(df[u'Этаж'], kde=False, bins=20);


— Преобладают квартиры на нижних этажах по 5й


df[u'Этажность'].value_counts()

9     128
5      59
12     25
19     25
10     20
4      16
20     10
2       7
7       6
16      5
13      4
14      3
8       3
3       3
11      2
21      2
24      1
15      1
17      1
18      1
6       1
22      1
25      1

sns.distplot(df[u'Этажность'], kde=False);


— больше всего квартир в 9 и 5-этажках


np.median(df[u'Этаж']), np.median(df[u'Этажность'])

(5.0, 9.0)

— т.е. в среднем квартира расположена на 5 этаже 9-этажного дома


df[u'Первый этаж'] = df[u'Этаж'].map(lambda x: 1 if x==1 else 0)

df[u'Первый этаж'].value_counts()

0    289
1     36

df[u'Последний этаж'] = df[u'Этаж / Этажность'].map(lambda x: 1 if x.split('/')[0] == x.split('/')[1] else 0)
df[u'Последний этаж'].value_counts()

0    276
1     49

# Удаляем столбец 'Этаж / Этажность':
df = df.drop(u'Этаж / Этажность', axis=1)

Убеждаемся в отсутствии пропусков
df.info()

Data columns (total 24 columns):
Адрес                    325 non-null object
Балкон                   325 non-null object
Ближайшее метро          325 non-null object
Комнаты                  325 non-null int64
Материал стен            325 non-null object
Микрорайон               325 non-null object
Описание                 322 non-null object
Полы                     325 non-null object
Район                    325 non-null object
Санузел                  325 non-null object
Телефон                  325 non-null object
Тип дома                 325 non-null object
Условия продажи          325 non-null object
Цена                     325 non-null float64
Лет дому                 325 non-null int64
Лет с момента ремонта    325 non-null int64
Был капремонт            325 non-null bool
Общая площадь            325 non-null float64
Жилая площадь            325 non-null float64
Площадь кухни            325 non-null float64
Этаж                     325 non-null int64
Этажность                325 non-null int64
Первый этаж              325 non-null int64
Последний этаж           325 non-null int64

Формируем вектора X и Y
X = df.drop({u'Цена', u'Описание', u'Адрес'}, axis=1)
Y = df[u'Цена']

На этом закончим подготовку данных, а начнем собственно


3. Анализ данных


Добавим несколько методов для создания енкодеров
from sklearn.preprocessing import LabelEncoder
from sklearn.preprocessing import OneHotEncoder

# Добавляет в DataFrame df новый столбец с именем column_name+'_le', содержащий номера категорий, 
# соответствующие столбцу column_name. Исходный столбец column_name удаляется
#
def encode_with_LabelEncoder(df, column_name):
    label_encoder = LabelEncoder()
    label_encoder.fit(df[column_name])
    df[column_name+'_le'] = label_encoder.transform(df[column_name])
    df.drop([column_name], axis=1, inplace=True)
    return label_encoder

# Кодирование с использованием ранее созданного LabelEncoder
#
def encode_with_existing_LabelEncoder(df, column_name, label_encoder):
    df[column_name+'_le'] = label_encoder.transform(df[column_name])
    df.drop([column_name], axis=1, inplace=True)

# Вначале кодирует столбец column_name при помощи LabelEncoder, потом добавляет в DataFrame df новые столбцы 
# с именами column_name=<категория_i>. Столбцы column_name и column_name+'_le' удаляются
# Usage: df, label_encoder = encode_with_OneHotEncoder_and_delete_column(df, column_name)
#
def encode_with_OneHotEncoder_and_delete_column(df, column_name):
    le_encoder = encode_with_LabelEncoder(df, column_name)
    return perform_dummy_coding_and_delete_column(df, column_name, le_encoder), le_encoder

# То же, что предыдущий метод, но при помощи уже существующего LabelEncoder
#
def encode_with_OneHotEncoder_using_existing_LabelEncoder_and_delete_column(df, column_name, le_encoder):
    encode_with_existing_LabelEncoder(df, column_name, le_encoder)
    return perform_dummy_coding_and_delete_column(df, column_name, le_encoder)

# Реализует Dummy-кодирование
#
def perform_dummy_coding_and_delete_column(df, column_name, le_encoder):
    oh_encoder = OneHotEncoder(sparse=False)
    oh_features = oh_encoder.fit_transform(df[column_name+'_le'].values.reshape(-1,1))
    ohe_columns=[column_name + '=' + le_encoder.classes_[i] for i in range(oh_features.shape[1])]

    df.drop([column_name+'_le'], axis=1, inplace=True)

    df_with_features = pd.DataFrame(oh_features, columns=ohe_columns)
    df_with_features.index = df.index
    return pd.concat([df, df_with_features], axis=1)

Кодируем категориальные признаки при помощи Label и Dummy-кодирования
phone_le_converter = encode_with_LabelEncoder(X,u'Телефон')
X, balcony_le_encoder = encode_with_OneHotEncoder_and_delete_column(X,u'Балкон')
X, metro_le_encoder = encode_with_OneHotEncoder_and_delete_column(X,u'Ближайшее метро')
X, wall_materials_le_encoder = encode_with_OneHotEncoder_and_delete_column(X,u'Материал стен')
X, ground_le_encoder = encode_with_OneHotEncoder_and_delete_column(X,u'Полы')
X, region_le_encoder = encode_with_OneHotEncoder_and_delete_column(X,u'Район')
X, subregion_le_encoder = encode_with_OneHotEncoder_and_delete_column(X,u'Микрорайон')
X, toilet_le_encoder = encode_with_OneHotEncoder_and_delete_column(X,u'Санузел')
X, house_type_le_encoder = encode_with_OneHotEncoder_and_delete_column(X,u'Тип дома')
X, sell_conditions_le_encoder = encode_with_OneHotEncoder_and_delete_column(X,u'Условия продажи')

X.shape

(325, 131)

— столбцов с признаками хоть и стало более сотни, но это еще терпимо


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


Определяем оптимальное количество деревьев
from sklearn.ensemble import RandomForestRegressor
from sklearn.cross_validation import KFold
from sklearn.cross_validation import cross_val_score

records_count = Y.count()
# Кросс-валидация по 5 блокам с перемешиванием
kf = KFold(n = records_count, n_folds=5, shuffle=True, random_state=1)

from sklearn.model_selection import GridSearchCV

# Мерой качества выбрано среднеквадратичное отклонение
def determine_forest_quality(trees_count):
    clf = RandomForestRegressor(n_estimators = trees_count, random_state=1)
    return cross_val_score(clf, X, Y, scoring='r2', cv=kf).mean()

for k in range(1,75,5):
    quality = determine_forest_quality(k)
    print (k, quality)

(1, 0.52030399213798784)
(6, 0.78408930021238521)
(11, 0.77670005453090307)
(16, 0.7768642836394235)
(21, 0.78408041490733349)
(26, 0.78620421428818033)
(31, 0.78902652992194366)
(36, 0.78733229366765278)
(41, 0.78844165910326591)
(46, 0.78772597694981916)
(51, 0.79053299052554316)
(56, 0.78934052514939657)
(61, 0.78879625272969778)
(66, 0.78759528635105114)
(71, 0.7900711517951875)

Решено было выбрать кол-во деревьев равное 51, при котором обеспечивалась точность 79%.
Подбор параметров деревьев не дал ощутимого прироста качества, его дала в основном предварительная работа с признаками.


Обучили регрессор с выбранным количеством деревьев
clf = RandomForestRegressor(n_estimators = 51, random_state=1)
clf.fit(X, Y)

RandomForestRegressor(bootstrap=True, criterion='mse', max_depth=None,
           max_features='auto', max_leaf_nodes=None,
           min_impurity_split=1e-07, min_samples_leaf=1,
           min_samples_split=2, min_weight_fraction_leaf=0.0,
           n_estimators=51, n_jobs=1, oob_score=False, random_state=1,
           verbose=0, warm_start=False)

Определили десятку самых важных признаков:
features = X.columns.values
importances = clf.feature_importances_
indices = np.argsort(importances)[::-1]

num_to_plot = 10
feature_indices = [ind+1 for ind in indices[:num_to_plot]]

for i in range(num_to_plot):
    print i, features[feature_indices[i]], round(importances[indices[i]],2)

0 Жилая площадь 0.66
1 Площадь кухни 0.05
2 Район=Заводской район 0.03
3 Материал стен=силикатно-блочный 0.02
4 Этаж 0.02
5 Был капремонт 0.02
6 Микрорайон=Дзержинского, Хмелевского, Щорса 0.02
7 Полы=паркет 0.02
8 Лет с момента ремонта 0.01
9 Первый этаж 0.01

Такие параметры, как 'Жилая площадь', 'Площадь кухни', 'Этаж' и 'Был капремонт' выглядят ожидаемо, а вот некоторые из остальных в топ-10 выглядят интересно — это 'Заводской район', 'микрорайон=Дзержинского' и 'Материал стен=силикатно-блочный'.


Делаем предсказания:
predictions = pd.Series(clf.predict(X), index=Y.index)

Интерес представляют записи, для которых модель сильно ошибается в ту или иную сторону
res_info = pd.DataFrame(columns=[u'Ошибка,%',u'Ошибка,$',u'Цена м.кв.'])
for i in Y.index:
    error = Y[i] - predictions[i]
    rel_error = error/predictions[i]*100
    res_info.loc[i] = pd.Series({
            u'Ошибка,%':round(rel_error,1),
            u'Ошибка,$':int(error),
            u'Цена м.кв.':int(Y[i]/X[u'Общая площадь'][i])
    })

Недооцененные квартиры:
res_info.sort_values(by=u'Ошибка,%')[:5]

Ошибка,% Ошибка,$ Цена м.кв.
845979 -15.6 -9252.0 906.0
838613 -15.4 -7649.0 961.0
806952 -15.1 -9988.0 896.0
830893 -13.7 -10979.0 891.0
798393 -13.1 -13560.0 1232.0

Переоцененные квартиры:
res_info.sort_values(by=u'Ошибка,%', ascending=False)[:5]

Ошибка,% Ошибка,$ Цена м.кв.
777692 20.7 23452.0 2242.0
795105 18.1 10582.0 1703.0
843263 16.0 18209.0 1668.0
812051 15.4 15362.0 2065.0
810427 14.9 16735.0 2057.0

Как было сказано ранее, детали можно посмотреть в браузере по адресу http://www.t-s.by/buy/flats/code/, где code — код квартиры


Стоит признать, что по сумме факторов данные квартиры выделяются из основной массы.
На этом все!


P.S. Как оказалось, за время с момента выгрузки данных с t-s.by до окончания написания статьи, данное агенство немного изменило шаблон страницы с детальной информацией о квартире: часть полей убрали, остальные переехали. Поэтому не думаю, что сильно им помешаю, предоставив код crawler-а, парсящего прошлую версию их сайта.


Исходный выгруженный json со всеми исходными полями сохранился и находится в репозитории.