Classificação dos distritos de Moscou por preço de aluguel com Python

Agora a programação penetra cada vez mais profundamente em todas as áreas da vida. E talvez tenha se tornado graças ao python muito popular agora. Se há cinco anos, para análise de dados, você tinha que usar um pacote inteiro de várias ferramentas: C # para descarregar (ou canetas), Excel, MatLab, SQL e constantemente "pular" para lá, limpar, verificar e reconciliar dados. Agora, python, graças a um grande número de excelentes bibliotecas e módulos, na primeira aproximação substitui com segurança todas essas ferramentas e, em conjunto com o SQL, em geral, “montanhas podem ser montadas”.

Então o que estou fazendo? Fiquei interessado em aprender um python tão popular. E a melhor maneira de aprender algo, como você sabe, é praticar. E também estou interessado em imóveis. E me deparei com um problema interessante sobre imóveis em Moscou: classificar os distritos de Moscou pelo custo médio de aluguel de uma odnushka média? Pais, pensei, aqui você tem geolocalização, upload do site e análise de dados - uma ótima tarefa prática.

Inspirados nos maravilhosos artigos aqui no Habré (no final do artigo, adicionarei links), vamos começar!

A tarefa para nós é percorrer as ferramentas existentes no python, desmontar a técnica - como resolver esses problemas e passar tempo com prazer, e não apenas com benefícios.

  1. Raspando ciano
  2. Quadro de dados único
  3. Processamento de quadros de dados
  4. resultados
  5. Um pouco sobre como trabalhar com dados geográficos

Raspando ciano


Em meados de março de 2020, foi possível coletar quase 9 mil propostas para alugar um apartamento de um quarto em Moscou em ciano, o site exibe 54 páginas. Trabalharemos com o jupyter-notebook 6.0.1, python 3.7. Carregamos dados do site e os salvamos em arquivos usando a biblioteca de solicitações .

Para que o site não nos proíba, nos disfarçaremos como pessoa adicionando um atraso nas solicitações e definindo um cabeçalho para que, na lateral do site, pareçamos uma pessoa muito inteligente fazendo solicitações por meio de um navegador. Não se esqueça de verificar a resposta do site toda vez, caso contrário, de repente somos descobertos e já banidos. Você pode ler mais e mais detalhadamente sobre a raspagem de site, por exemplo, aqui: raspagem da Web usando python .

Também é conveniente adicionar decoradores para avaliar a velocidade de nossas funções e registro. Configurando level = logging.INFO permite especificar o tipo de mensagens exibidas no log. Você também pode configurar o módulo para enviar o log para um arquivo de texto, para nós isso é desnecessário.

O código
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&region=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)


Quadro de dados único


Para raspar páginas, escolha BeautifulSoup e lxml . Usamos "sopa bonita" simplesmente por seu nome legal, embora eles digam que o lxml é mais rápido.

Você pode fazer isso lindamente, pegar uma lista de arquivos de uma pasta usando a biblioteca do sistema operacional , filtrar as extensões de que precisamos e passar por elas. Mas facilitaremos, pois sabemos o número exato de arquivos e seus nomes exatos. A menos que adicionemos decoração na forma de uma barra de progresso, usando a biblioteca tqdm

O código

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})")


Uma nuance interessante foi que a figura indicada na parte superior da página e indicando o número total de apartamentos encontrados a pedido difere de página para página. Portanto, em nosso exemplo, essas 5.402 ofertas são classificadas por padrão, variando de 5343 a 5402, diminuindo gradualmente com o aumento do número de páginas da solicitação (mas não pelo número de anúncios exibidos). Além disso, foi possível continuar descarregando páginas além dos limites do número de páginas indicado no site. No nosso caso, apenas 54 páginas foram oferecidas no site, mas conseguimos descarregar 309 páginas, apenas com anúncios mais antigos, para um total de 8640 anúncios de aluguel de apartamentos.

Uma investigação desse fato ficará fora do escopo deste artigo.

Processamento de quadros de dados


Portanto, temos um único quadro de dados com dados brutos nas ofertas 8640. Realizaremos uma análise superficial dos preços médios e medianos nos distritos, calcularemos o preço médio do aluguel por metro quadrado do apartamento e o custo do apartamento no distrito "em média".

Continuaremos com as seguintes premissas para o nosso estudo:

  • Falta de repetições: todos os apartamentos encontrados são realmente existentes. No primeiro estágio, eliminamos apartamentos repetidos no endereço e na quadratura, mas se o apartamento tiver uma quadratura ou endereço um pouco diferente, consideramos essas opções como apartamentos diferentes.
  • — .
    — «» ? ( ) , , , , . , , , . «» : . «» ( ) , .

Vamos precisar de:

price_per_month - preço mensal do aluguel em rublos
quadrados - área
okrug - distrito, neste estudo todo o endereço não é interessante para nós
price_meter - preço do aluguel por 1 metro quadrado

O código
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')


Agora, "cuidaremos" das emissões manualmente, de acordo com os cronogramas. Para visualizar os dados, Vamos olhar três bibliotecas: matplotlib , Seaborn e plotly .

Histogramas de dados . O Matplotlib permite exibir rápida e facilmente todos os gráficos dos grupos de dados que nos interessam, não precisamos de mais. A figura abaixo, segundo a qual apenas uma proposta em Mitino não pode servir como uma avaliação qualitativa do apartamento médio, é excluída. Outra imagem interessante no Okrug administrativo do sul: a maioria das ofertas (mais de 500 unidades) com valor de aluguel abaixo de 1000 rublos e um aumento nas ofertas (quase 300 unidades) em 1700 rublos por metro quadrado. No futuro, você poderá ver por que isso acontece - remexendo em outros indicadores para esses apartamentos.

Apenas uma linha de código fornece histogramas para conjuntos de dados agrupados:

hists = df['price_per_meter'].hist(by=df['okrug'], figsize=(16, 14), color = "tab:blue", grid = True)



A dispersão de valores . A seguir, apresentamos os gráficos usando as três bibliotecas. O seaborn por padrão é mais bonito e brilhante, mas com plotagem permite exibir valores imediatamente quando você passa o mouse, o que é muito conveniente para selecionar os valores de "outliers" que excluiremos.

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

eu acho que haverá suficiente exemplo para um distrito.

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()



resultados


Assim, depois de limpar os dados e remover as emissões habilmente, temos 8602 ofertas "limpas".
Em seguida, calculamos as principais estatísticas de acordo com os dados: média, mediana, desvio padrão, obtemos a seguinte classificação dos distritos de Moscou à medida que o custo médio de aluguel de um apartamento médio diminui:



Você pode desenhar histogramas bonitos comparando, por exemplo, os preços médios e medianos no distrito:



O que mais pode dizer sobre a estrutura das propostas para aluguel de apartamentos com base em dados:

  • , , , . “” , ( ). , , , , , , “” . , , .
  • . . « ». , «» — . . . , , , , , - , , . . .
  • , “” , . , , — .


Um capítulo separado, incrivelmente interessante e bonito, é o tópico de geodados, a exibição de nossos dados em relação ao mapa. Você pode olhar com muito detalhes e detalhes, por exemplo, nos seguintes artigos:
Visualização dos resultados das eleições em Moscou em um mapa em um Jupyter Notebook
Likbez em projeções cartográficas com imagens do
OpenStreetMap como fonte de geodados

Resumidamente, o OpenStreetMap é tudo, ferramentas convenientes são: geopandas , cartoframes (eles dizem que já morreu?) e fólio , que usaremos.

Veja como serão os nossos dados em um mapa interativo.



Materiais que se mostraram úteis no trabalho do artigo:


Espero que você esteja interessado, como eu.

Obrigado pela leitura. Críticas construtivas são bem-vindas.

Fontes e conjuntos de dados são publicados no github aqui .

All Articles