Итак, к чему я. Увлеклась я изучением такого популярного python’а. А лучший способ изучить что-либо, как вы знаете, — практика. А еще я интересуюсь недвижимостью. И попалась мне на глаза интересная задачка о недвижимости в Москве: проранжировать округа Москвы по усредненной стоимости аренды средней однушки? Батюшки, я подумала, да тут вам и геолокация, и выгрузка с сайта, и анализ данных — прекрасная практическая задача.
Воодушевившись замечательными статьями тут на Хабре (в конце статьи добавлю ссылки), приступим!
Задача у нас пройтись по существующим инструментам внутри python’а, разобрать технику — как решать подобные задачи и провести время с удовольствием, а не только с пользой.
Скрапинг Циана
На середину марта 2020 года на циане получилось собрать почти 9 тысяч предложений об аренде 1-комнатной квартиры в Москве, сайт отображает 54 страницы. Работать будем с jupyter-notebook 6.0.1, python 3.7. Прогружаем данные с сайта и сохраняем в файлы с помощью библиотеки requests.
Чтобы сайт нас не забанил, замаскируемся под человека, добавив задержку в запросах и задав хедер, чтобы со стороны сайта мы выглядели, как очень шустрый человек, делающий запросы через браузер. Не забываем каждый раз проверять ответ от сайта, а то вдруг нас раскрыли и уже забанили. Более подробно и детально про скрапинг сайтов можно почитать, например, тут: Web Scraping с помощью python.
Удобно так же добавить декораторы для оценки скоростей выполнения наших функций и ведения логов. Настройка level=logging.INFO позволяет указать тип выводимых в лог сообщений. Так же можно донастроить модуль для вывода лога в текстовый файл, для нас это излишне.
def timer(f): def wrap_timer(*args, **kwargs): start = time.time() result = f(*args, **kwargs) delta = time.time() - start print (f'Время выполнения функции {f.__name__} составило {delta} секунд') return result return wrap_timer def log(f): def wrap_log(*args, **kwargs): logging.info(f"Запущена функция {f.__doc__}") result = f(*args, **kwargs) logging.info(f"Результат: {result}") return result return wrap_log logging.basicConfig(level=logging.INFO) @timer @log def requests_site(N): headers = ({'User-Agent':'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.5 Safari/605.1.15'}) pages = [106 + i for i in range(N)] n = 0 for i in pages: s = f"https://www.cian.ru/cat.php?deal_type=rent&engine_version=2&page={i}&offer_type=flat®ion=1&room1=1&type=-2" response = requests.get(s, headers = headers) if response.status_code == 200: name = f'sheets/sheet_{i}.txt' with open(name, 'w') as f: f.write(response.text) n += 1 logging.info(f"Обработана страница {i}") else: print(f"От страницы {i} пришел ответ response.status_code = {response.status_code}") time.sleep(np.random.randint(7,13)) return f"Успешно загружено {n} страниц" requests_site(300)
Единый датафрейм
Для скрапинга страниц на выбор BeautifulSoup и lxml. Используем «прекрасный суп» просто за его прикольное название, хотя, говорят, что lxml быстрее.
Можно сделать красиво, взять список файлов из папки с помощью библиотеки os, отфильтровать нужные нам по расширению и пройтись по ним. Но мы сделаем проще, так как точное число файлов и точные их названия нам известны. Разве что добавим украшательство в виде прогресс бара, используя библиотеку tqdm
from bs4 import BeautifulSoup import re import pandas as pd from dateutil.parser import parse from datetime import datetime, date, time def read_file(filename): with open(filename) as input_file: text = input_file.read() return text import tqdm site_texts = [] pages = [1 + i for i in range(309)] for i in tqdm.tqdm(pages): name = f'sheets/sheet_{i}.txt' site_texts.append(read_file(name)) print(f"Прочитано {len(site_texts)} файлов.") def parse_tag(tag, tag_value, item): key = tag value = "None" if item.find('div', {'class': tag_value}): if key == 'link': value = item.find('div', {'class': tag_value}).find('a').get('href') elif (key == 'price' or key == 'price_meter'): value = parse_digits(item.find('div', {'class': tag_value}).text, key) elif key == 'pub_datetime': value = parse_date(item.find('div', {'class': tag_value}).text) else: value = item.find('div', {'class': tag_value}).text return key, value def parse_digits(string, type_digit): digit = 0 try: if type_digit == 'flats_counts': digit = int(re.sub(r" ", "", string[:string.find("пр")])) elif type_digit == 'price': digit = re.sub(r" ", "", re.sub(r"₽", "", string)) elif type_digit == 'price_meter': digit = re.sub(r" ", "", re.sub(r"₽/м²", "", string)) except: return -1 return digit def parse_date(string): now = datetime.strptime("15.03.20 00:00", "%d.%m.%y %H:%M") s = string if string.find('сегодня') >= 0: s = "{} {}".format(now.day, now.strftime("%b")) s = string.replace('сегодня', s) elif string.find('вчера') >= 0: s = "{} {}".format(now.day - 1, now.strftime("%b")) s = string.replace('вчера',s) if (s.find('мар') > 0): s = s.replace('мар','mar') if (s.find('фев') > 0): s = s.replace('фев','feb') if (s.find('янв') > 0): s = s.replace('янв','jan') return parse(s).strftime('%Y-%m-%d %H:%M:%S') def parse_text(text, index): tag_table = '_93444fe79c--wrapper--E9jWb' tag_items = ['_93444fe79c--card--_yguQ', '_93444fe79c--card--_yguQ'] tag_flats_counts = '_93444fe79c--totalOffers--22-FL' tags = { 'link':('c6e8ba5398--info-section--Sfnx- c6e8ba5398--main-info--oWcMk','undefined c6e8ba5398--main-info--oWcMk'), 'desc': ('c6e8ba5398--title--2CW78','c6e8ba5398--single_title--22TGT', 'c6e8ba5398--subtitle--UTwbQ'), 'price': ('c6e8ba5398--header--1df-X', 'c6e8ba5398--header--1dF9r'), 'price_meter': 'c6e8ba5398--term--3kvtJ', 'metro': 'c6e8ba5398--underground-name--1efZ3', 'pub_datetime': 'c6e8ba5398--absolute--9uFLj', 'address': 'c6e8ba5398--address-links--1tfGW', 'square': '' } res = [] flats_counts = 0 soup = BeautifulSoup(text) if soup.find('div', {'class': tag_flats_counts}): flats_counts = parse_digits(soup.find('div', {'class': tag_flats_counts}).text, 'flats_counts') flats_list = soup.find('div', {'class': tag_table}) if flats_list: items = flats_list.find_all('div', {'class': tag_items}) for i, item in enumerate(items): d = {'index': index} index += 1 for tag in tags.keys(): tag_value = tags[tag] key, value = parse_tag(tag, tag_value, item) d[key] = value results[index] = d return flats_counts, index from IPython.display import clear_output sum_flats = 0 index = 0 results = {} for i, text in enumerate(site_texts): flats_counts, index = parse_text(text, index) sum_flats = len(results) clear_output(wait=True) print(f" Файл {i + 1} flats = {flats_counts}, добавлено итого {sum_flats} квартир") print(f"Итого sum_flats ({sum_flats}) = flats_counts({flats_counts})")
Интересным ньюансом оказалось то, что цифра, указанная сверху страницы и обозначающая общее количество квартир, найденных по запросу, отличается от страницы к странице. Так, в нашем примере это 5 402 предложение отсортированы по умолчанию находится в диапазоне от 5343 до 5402, постепенно снижаясь с увеличением номера страницы запроса (но не на количество отображенных объявлений). К тому же оказалось возможным продолжать выгружать страницы за пределами ограничения в количестве страниц, указанных на сайте. В нашем случае на сайте было предложено всего 54 страницы, но мы смогли выгрузить 309 страниц, только с более старыми объявлениями, итого 8640 объявлений об аренде квартир.
Расследование данного факта оставим за рамками данной статьи.
Обработка датафрейма
Итак, имеем единый датафрейм с сырыми данными по 8640 предложениям. Проведем поверхностный анализ средних и медианных цен по округам, посчитаем среднюю стоимость аренды квадратного метра квартиры и стоимость квартиры в округе «в среднем».
Будем исходить из следующих допущений для нашего исследования:
- Отсутствие повторов: все найденные квартиры — действительно существующие квартиры. На первом этапе повторяющиеся квартиры по адресу и по квадратуре мы отсеяли, но если у квартиры немного разная квадратура или адрес — такие варианты считаем разными квартирами.
- Средняя квартира в округе — квартира со средней квадратурой для округа.
Сейчас можно уйти в глубокие обсуждения — что считать «средней» квартирой в округе? Можно закопаться (и это будет правильно) в параметрах каждой найденной квартиры и найди средние значения таких показателей, как площадь, этаж, близость к метро, смежность или раздельность комнат и сан. узла, наличие лоджии или балкона, качество ремонта, год и тип постройки дома и многие другие показатели. Оставим это на будущие «изыскания» и остановимся на определении: среднюю квартиру в округе будем считать по средней квадратуре. А чтобы исключительные варианты или «выбросы» (единичные квартиры с непривычно большим метражом или с неожиданной низкой стоимостью) не искажали наш результат, определим их и удалим из исследования.
Нам понадобятся:
price_per_month — цена за месяц ареды в рублях
square — площадь
okrug — округ, в данном исследовании весь адрес нам не интересен
price_meter — цена аренды за 1 кв метр
df['price_per_month'] = df['price'].str.strip('/мес.').astype(int) #price_int new_desc = df["desc"].str.split(",", n = 3, expand = True) df["square"]= new_desc[1].str.strip(' м²').astype(int) df["floor"]= new_desc[2] new_address = df['address'].str.split(',', n = 3, expand = True) df['okrug'] = new_address[1].str.strip(" ") df['price_per_meter'] = (df['price_per_month'] / df['square']).round(2) #price_std df = df.drop(['index','metro', 'price_meter','link', 'price','desc','address','pub_datetime','floor'], axis='columns')
Теперь «займемся» выбросами вручную по графикам. Для визуализации данных посмотрим три библиотеки: matplotlib, seaborn и plotly.
Гистограммы данных. Matplotlib позволяет просто и быстро отобразить все диаграммы по интересующим нас группам данных, большего нам и не надо. Рисунок ниже, по которому всего 1 предложение в Митино не могут служить качественной оценкой средней квартиры, удалим. Еще интересная картира в ЮАО: большинство предложений (более 500 шт) с арендной стоимостью ниже 1000 руб., и всплеск предложений (почти 300 шт) на 1700 руб за квадратный метр. В дальнейшем можно посмотреть почему так происходит — покопавшись в других показателях по этим квартирам.
Всего одна строчка кода дает там гистограммы по сгруппированным наборам данных:
hists = df['price_per_meter'].hist(by=df['okrug'], figsize=(16, 14), color = "tab:blue", grid = True)
Разброс значений. Ниже представила графики с помощью всех трех библиотек. seaborn по умолчанию — более красивая и яркая, зато plotly позволяет сразу отображать значения при наведении мышки, что нам очень удобно для выбора значений «выбросов», которые мы будем удалять.
matplotlib
fig, axes = plt.subplots(nrows=4,ncols=3,figsize=(15,15)) for i, (name, group) in enumerate(df_copy.groupby('okrug')): axes = axes.flatten() axes[i].scatter(group['price_per_meter'],group['square'], color ='blue') axes[i].set_title(name) axes[i].set(xlabel='Стоимость за 1 кв.м.', ylabel='Площадь, м2') axes[i].label_outer() fig.tight_layout()
seaboarn
sns.pairplot(vars=["price_per_meter","square"], data=df_copy, hue="okrug", height=5)
plotly
Думаю, тут будет достаточно примера по одному округу.
import plotly.express as px for i, (name, group) in enumerate(df_copy.groupby('okrug')): fig = px.scatter(group, x="price_per_meter", y="square", facet_col="okrug", width=400, height=400) fig.update_layout( margin=dict(l=20, r=20, t=20, b=20), paper_bgcolor="LightSteelBlue", ) fig.show()
Результаты
Итак, почистив данные, экспертно удалив выбросы, имеем 8602 «чистых» предложения.
Далее, посчитаем основные статистики по данным: среднее, медиану, стандартное отклонение, получаем следующий рейтинг округов Москвы по мере уменьшения средней стоимости арендной платы за среднюю квартиру:
Можно порисовать красивые гистограммы, сравнивая, например, средние и медианные цены в округе:
Что можно еще сказать про структуру предложений по аренде квартир на основе данных:
- В ЦАО, ЗАО квартиры несколько переоценены, так как средние цены на квартиры выше, чем цена на аренду в среднем. Это некий введеный нами “индекс” стоимости аренды квартиры в округе, стоимость средней квартиры по средней цене в округе (средняя квадратура в округе на среднюю цену в округе). Следует заменить, что это тренировочное упражнение и в бою, конечно же, следует гораздо ответственнее и сложнее отнестись к созданию индекса цены, возможно, следует ввести много других параметров, от которых будет зависеть “эталонная” цена аренды. А вот в ВАО и НАО, например, цены слегка занижены.
- Медианные цены тоже представляют достаточно интересную информацию. Среднее значение в целом довольно чувствительно к выбрасам. Это как в анекдотах про «среднюю температуру по больнице». В интернете много статей на простом языке объясняющих разницу между данными статистическими показателями, например «Средние» значения — ваш враг. Как не попасться на удочку усреднения. Медиана же более устойчива к выбросам и позволяет более точно характеризовать предложения. Так, в ЮАО и СВАО, например, медианная цена аренды достаточно ниже, чем средняя цена, а это значит все-таки такая более низкая, чем средняя, цена будет более точно описывать ситуацию с рынком аренды в округе. См. гистограмму.
- Стандартное отклонение характеризует меру разброса значений предложений, насколько “густо” сосредоточены точки, насколько цены колеблются от среднего. Видим, что наибольший разбор у нас в ЦАО, а наименьший — в ЮЗАО и ЗелАО.
Немного о работе с геоданными
Отдельной, невероятно интересной и красивой главой идет тема геоданные, отображение наших данных в привязке к карте. Очень подробно и детально можно посмотреть, например, в статьях:
Визуализация результатов выборов в Москве на карте в Jupyter Notebook
Ликбез по картографическим проекциям с картинками
OpenStreetMap как источник геоданных
Кратко, OpenStreetMap наше все, удобные инструменты это: geopandas, cartoframes (говорят, он уже погиб?) и folium, который мы и будем использовать.
Вот как будут выглядеть наши данные на интерактивной карте.
Материалы, которые оказались полезными в работе над статьей:
- «Web Scraping с помощью python»
- «Как я создал веб-скрапер на Python для поиска жилья»
- «Как программисты ищут квартиры»
- «Покупка оптимальной квартиры с R»
- «Визуализация результатов выборов в Москве на карте в Jupyter Notebook»
- «Ликбез по картографическим проекциям с картинками»
- «OpenStreetMap как источник геоданных»
Надеюсь, вам было интересно, как и мне.
Спасибо, что дочитали. Конструктивная критика приветствуется.
Исходники и датасеты выложены на гитхабе тут.
ссылка на оригинал статьи https://habr.com/ru/post/494202/
Добавить комментарий