뉴욕 부동산 매물 데이터 분석

Crawling

해당 사이트에서 등록되있는 매물의 주소, 가격, 침실 수 및 욕실 수를를 이용하여 임대료를 예측해 보자.
크롤링을 할 사이트는 RentHop이고 사이트의 URL은 http://www.renthop.com 이다.

우선 크롤링에 사용되는 패키지는 다음과 같다.

import numpy as np
import pandas as pd
import requests
import matplotlib.pyplot as plt
%matplotlib inline

뉴욕시 아파트 데이터를 사용해 보겠다. 해당 데이터의 URL은 https://www.renthop.com/nyc/apartments-for-rent 이다. 해당 페이지의 HTML 코드를 가져오는 코드는 다음과 같다.

r = requests.get('https://www.renthop.com/nyc/apartments-for-rent')

#HTML코드를 가져와서 r객체에 저장 후 content를 통해 가져온 값을 확인할 수 있다.
r.content
'''
[output] : b'<!doctype html>\n<html lang="en">\n<head>\n<meta ...
'''

HTML 코드 분석을 수행하기 위해 BeautifulSoup 패키지를 사용한다.

from bs4 import BeautifulSoup

soup = BeautifulSoup(r.content, "htmllib")

방금 만든 soup를 사용해 아파트 데이터를 구문 분석할 수 있다. 이제 해야할 일은 사이트 페이지의 목록 데이터를 포함하는 div태그를 검색하는 것이다. 아래 코드는 div태그에서 class가 search-info가 들어간 것들을 찾아내서 listing_divs라는 이름의 리스트에 추가한다. 이것들 각각이 하나의 매물에 대한 정보를 포함하고 있는 원소라고 볼 수 있다.

listing_divs = soup.select('div[class*=search-info]')
listing_divs
'''
[output] :
[<div class="search-info pl-md-4">
 <div>
 <div class="float-right font-size-9" style="padding-top: 2px;">
 <span class="font-gray-2 d-none d-sm-inline-block"></span>
 <span class="d-none d-sm-inline-block font-gray-2">Score:</span>
 <span class="d-none d-sm-inline-block b font-blue" id="listing-61091688-hopscore" style="">93.4</span>
 </div> ...
 '''
 

len(listing_divs)
'''
[output] : 22
'''

리스트의 원소가 22개이므로 해당 페이지에서 총 22개의 매물을 찾았다는 의미이다.

그 중 첫번째 원소를 가지고 각 원소로 부터 어떻게 데이터를 파싱할지 알아보겠다.

listing_divs[0]
'''
[output] :  
<div class="search-info pl-md-4">
<div>
<div class="float-right font-size-9" style="padding-top: 2px;">
<span class="font-gray-2 d-none d-sm-inline-block"></span>
<span class="d-none d-sm-inline-block font-gray-2">Score:</span>
<span class="d-none d-sm-inline-block b font-blue" id="listing-61091688-hopscore" style="">93.4</span>
</div>
<a class="font-size-11 listing-title-link b" href="https://www.renthop.com/listings/649-prospect-place/c3/61091688" id="listing-61091688-title">649 Prospect Place, Apt C3</a>
<div class="font-size-9 overflow-ellipsis" id="listing-61091688-neighborhoods" style="line-height: 130%;">
Crown Heights, Central Brooklyn, Brooklyn
</div>
</div>
<div style="margin-top: ...
'''
listing_divs.select('a[id*=title]')
'''
[output] : [<a class="font-size-11 listing-title-link b" href="https://www.renthop.com/listings/liberty-street/5c/60336196" id="listing-60336196-title">Liberty Street</a>]
listing_divs[0].select('a[id*=title]')[0]['href']
'''
[output] : 'https://www.renthop.com/listings/liberty-street/5c/60336196'
listing_divs[0].select('a[id*=title]')[0].string
'''
[output] : 'Liberty Street'
listing_divs[0].select('div[id*=hood]')[0]
'''
[output] : <div class="font-size-9 overflow-ellipsis" id="listing-60336196-neighborhoods" style="line-height: 130%;">Financial District, Downtown Manhattan, Manhattan</div>
'''
listing_divs[0].select('div[id*=hood]')[0].string.replace('\n', '')
'''
[output] : 'Financial District, Downtown Manhattan, Manhattan'
'''
href = listing_divs[0].select('a[id*=title]')[0]['href']
addy = listing_divs[0].select('a[id*=title]')[0].string
hood = listing_divs[0].select('div[id*=hood]')[0]\
.string.replace('\n','')

print(href + '\n' + addy + '\n' + hood)
'''
[output] : https://www.renthop.com/listings/liberty-street/5c/60336196
		   Liberty Street
           Financial District, Downtown Manhattan, Manhattan

Mining

import re

temp = '''
<div class="font-size-10 b d-inline-block">
$2,850
</div>

기타 등등 여러가지 HTML 코드들

문자열이름.index('검색하고하는 문자열')

를 사용하면 검색하고자하는 문자열의 위치값이 리턴된다.

temp.index('$'
'''
[output] : 45
'''

위 코드는 temp라는 문자열에서 '$'의 위치. 즉, 인덱스가 어딨는지 묻는 코드이다.

temp[45:45+100]
'''
[output] : '$2,850\n</div>\n\n기타 등등 여러가지 HTML 코드들\n'
'''
temp[45:45+100].split()
'''
[output] : ['$2,850', '</div>', '기타', '등등', '여러가지', 'HTML', '코드들']
'''
temp[45:45+100].split(0)
'''
[output] : '$2,850'
'''

이제 위 코드를 적용해보겠다.

먼저 해야할 작업이 있다.

type(listing_divs[0])
'''
[output] : bs4.element.Tag
'''

현재 listing_divs[0]의 타입이 문자열이 아니라 BeautifulSoup의 객체(함수의 기능을 갖고 있는 변수)이다.

BeautifulSoup의 객체에 .text를 하면 문자열로 변환이 된다.

type(listing_divs[0].text)
'''
[output] : str
'''

타입을 확인하면 문자열로 바뀐 것을 확인할 수 있다.

이제 우리가 앞서 사용했던 문자열의 index 기능을 사용할 수 있게 된다.

index_num = listing_divs[0].text.index('$') 
contains_word_str = listing_divs[0].text[index_num:index_num+100]
contains_word_list = contains_word_str.split()
contains_word_list[0]
'''
[output] : '$2,250'
'''
index_num = listing_divs[0].text.index(' Bed\n') 
contains_word_str = listing_divs[0].text[index_num-10:index_num+4]
contains_word_list = contains_word_str.split('\n')
#print(contains_word_list)
print(contains_word_list[-1])
'''
[output] : 1 Bed
'''
index_num = listing_divs[0].text.index(' Bath\n') 
contains_word_str = listing_divs[0].text[index_num-10:index_num+5]
contains_word_list = contains_word_str.split('\n')
# print(contains_word_list)
print(contains_word_list[-1])
'''
[output] : 1.5 Bath
'''
listing_list = []
for idx in range(len(listing_divs)):
    indv_listing = []
    current_listing = listing_divs[idx]
    href = current_listing.select('a[id*=title]')[0]['href']
    addy = current_listing.select('a[id*=title]')[0].string
    hood = current_listing.select('div[id*=hood]')[0]\
    .string.replace('\n','')
    
    indv_listing.append(href)
    indv_listing.append(addy)
    indv_listing.append(hood)

    # Add Price
    try:
      index_num = current_listing.text.index('$') 
      contains_word_str = current_listing.text[index_num:index_num+100]
      contains_word_list = contains_word_str.split()
      indv_listing.append(contains_word_list[0])
    except:
      indv_listing.append('-')

    # Add Bed
    try:
      index_num = current_listing.text.index(' Bed\n') 
      contains_word_str = current_listing.text[index_num-10:index_num+4]
      contains_word_list = contains_word_str.split('\n')
      indv_listing.append(contains_word_list[-1])
    except:
      indv_listing.append('-')

    # Add Bath
    try:
      index_num = current_listing.text.index(' Bath\n') 
      contains_word_str = current_listing.text[index_num-10:index_num+5]
      contains_word_list = contains_word_str.split('\n')
      indv_listing.append(contains_word_list[-1])
    except:
      indv_listing.append('-')

    listing_list.append(indv_listing)
listing_list
'''
[output] : 
[['https://www.renthop.com/listings/saint-johns-place-and-ralph-avenue/1f/61548798',
  'Saint Johns Place and Ralph Av...',
  'Crown Heights, Central Brooklyn, Brooklyn',
  '$2,250',
  '1 Bed',
  '1.5 Bath'],
 ['https://www.renthop.com/listings/135-william-street/7a/61722645',
  '135 William Street, Apt 7A',
  'Financial District, Downtown Manhattan, Manhattan',
  '$8,375',
  '5 Bed',
  '2 Bath'], ...
  '''
len(listing_list)
'''
[output] : 22
'''

지금까지 실행한 것은 하나의 페이지에 대해서 수행한 것이고 이제 여러 개의 페이지에 접근하는 구조 재구성해보겠다.

 iterable url

url_prefix = "https://www.renthop.com/search/nyc?max_price=50000&min_price=0&page="
page_no = 1
url_suffix = "&sort=hopscore&q=&search=0"

# test url

for i in range(3):
    target_page = url_prefix + str(page_no) + url_suffix
    print(target_page)
    page_no += 1
    
'''
[output] : 
https://www.renthop.com/search/nyc?max_price=50000&min_price=0&page=1&sort=hopscore&q=&search=0
https://www.renthop.com/search/nyc?max_price=50000&min_price=0&page=2&sort=hopscore&q=&search=0
https://www.renthop.com/search/nyc?max_price=50000&min_price=0&page=3&sort=hopscore&q=&search=0
'''
def parse_data(listing_divs):
    listing_list = []
    for idx in range(len(listing_divs)):
        indv_listing = []
        current_listing = listing_divs[idx]
        href = current_listing.select('a[id*=title]')[0]['href']
        addy = current_listing.select('a[id*=title]')[0].string
        hood = current_listing.select('div[id*=hood]')[0]\
        .string.replace('\n','')

        indv_listing.append(href)
        indv_listing.append(addy)
        indv_listing.append(hood)

        # Add Price
        try:
          index_num = current_listing.text.index('$') 
          contains_word_str = current_listing.text[index_num:index_num+100]
          contains_word_list = contains_word_str.split()
          indv_listing.append(contains_word_list[0])
        except:
          indv_listing.append('-')

        # Add Bed
        try:
          index_num = current_listing.text.index(' Bed\n') 
          contains_word_str = current_listing.text[index_num-10:index_num+4]
          contains_word_list = contains_word_str.split('\n')
          indv_listing.append(contains_word_list[-1])
        except:
          indv_listing.append('-')

        # Add Bath
        try:
          index_num = current_listing.text.index(' Bath\n') 
          contains_word_str = current_listing.text[index_num-10:index_num+5]
          contains_word_list = contains_word_str.split('\n')
          indv_listing.append(contains_word_list[-1])
        except:
          indv_listing.append('-')

        listing_list.append(indv_listing)
    return listing_list

페이지 수를 100으로 설정하고 request해보도록 하겠다.

all_pages_parsed = []
for i in range(100):
    target_page = url_prefix + str(page_no) + url_suffix
    print(target_page)
    r = requests.get(target_page)
    
    soup = BeautifulSoup(r.content, 'html5lib')
    
    listing_divs = soup.select('div[class*=search-info]')
    
    one_page_parsed = parse_data(listing_divs)
    
    all_pages_parsed.extend(one_page_parsed)
    
    page_no += 1
'''
[output] :
https://www.renthop.com/search/nyc?max_price=50000&min_price=0&page=4&sort=hopscore&q=&search=0
https://www.renthop.com/search/nyc?max_price=50000&min_price=0&page=5&sort=hopscore&q=&search=0
https://www.renthop.com/search/nyc?max_price=50000&min_price=0&page=6&sort=hopscore&q=&search=0
https://www.renthop.com/search/nyc?max_price=50000&min_price=0&page=7&sort=hopscore&q=&search=0
https://www.renthop.com/search/nyc?max_price=50000&min_price=0&page=8&sort=hopscore&q=&search=0
...
'''
len(all_pages_parsed)
'''
[output] : 2158
'''
all_pages_parsed
'''
[output] : 
[['https://www.renthop.com/listings/east-83-street/4r/61724107',
  'East 83 Street',
  'Yorkville, Upper East Side, Upper Manhattan, Manhattan',
  '$2,695',
  '2 Bed',
  '1 Bath'],
 ['https://www.renthop.com/listings/500-east-77th-street/1205/61547869',
  '500 East 77th Street, Apt 1205...',
  'Upper East Side, Upper Manhattan, Manhattan',
  '$6,295',
  '2 Bed',
  '2 Bath'],
  ...
  '''

이렇게 쌓인 데이터를 데이터 프레임으로 확인해보겠다.

리스트의 원소가 리스트인 이중 리스트는 다음과 같이 pd.DataFrame()으로 감싸주어서 데이터프레임으로 로드가 가능하다.

이때, columns라는 인자값으로 컬럼명의 리스트를 주면, 각컬럼에 다음과 같이 출력된다.

df = pd.DataFrame(all_pages_parsed, columns=['url', 'address', 'neighborhood', 'rent', 'beds', 'baths'])
df
'''
[output] :
	url													address										  neighborhood						rent	beds	baths
0	https://www.renthop.com/listings/1013-pacific-...	1013 Pacific Street, Apt 2L	Prospect Heights, Northwestern Brooklyn, Brooklyn	$2,400	3 Bed	1 Bath
1	https://www.renthop.com/listings/east-72nd-str...	East 72nd Street	Upper East Side, Upper Manhattan, Manhattan	$18,250	4 Bed	3.5 Bath
2	https://www.renthop.com/listings/tik-tok-ho-fl...	tik-TOK HO Flex-2	Financial District, Downtown Manhattan, Manhattan	$3,030	1 Bed	2 Bath
3	https://www.renthop.com/listings/301-e-47th-st...	301 East 47th Street	Turtle Bay, Midtown East, Midtown Manhattan, M...	$3,196	1 Bed	1 Bat
...
'''

beds 열에 있는 데이터의 종류를 출력해보겠다.

df['beds'].unique()
'''
[output] : array(['3 Bed', '4 Bed', '1 Bed', '-', '2 Bed', '5 Bed', '6 Bed', ' 2 Bed', ' 1 Bed'], dtype=object)
'''

'-'의 경우 앞서 Crawl할 단어를 찾아보고 try except문에서 해당 페이지에 해당 단어를 찾지 못했을 경우에 넣도록 코딩을 했다. 그 외에는 대체적으로 잘 들어간 것 같지만 앞에 띄어쓰기가 있는 경우도 있다.

Python에서 엄연히 '2 Bed'와 ' 2 Bed'는 다른 문자열이다. 뒤의 문자열은 앞에 문자열과 달리 앞에 공백이 존재하기 때문이다. 마찬가지로 '1 Bed'와 ' 1 Bed'도 다른 단어로 인식되어 출력된 것을 확인할 수 있다.

우선 ' 2 Bed'와 같이 예상하지 못한 값이 들어왔을 때, 제대로 들어간건지가 궁금할 수 있다. 그런데 우리는 크롤링 과정에서 URL열에 해당 페이지의 URL을 수집하였으므로 직접 URL에 들어가서 확인해보면 된다.

데이터 프레임의 beds열의 값이 ' 2 Bed'인 행의 'url'열의 값을 가져오는 코드는 다음과 같이 작성할 수 있다.

df[df['beds']==' 2 Bed']['url'].values
'''
[output] : array(['https://www.renthop.com/listings/1775-york-avenue/31b/60896694'],dtype=object)
'''

해당 URL에 접속하여 확인한 결과 2 Bed임을 확인했다. 그렇다면 어차피 데이터가 1개밖에 되지 않으므로 수작업으로

' 2 Bed'를 '2 Bed'로 바꿔보겠다. 데이터프레임에서 특정 값에 접근하여 해당 값을 수정하는 방법으로 at이라는 것을 사용할 수 있다.

우선 바꾸고자 하는 값이 있는 위치의 행을 추적한다.

df[df['beds']==' 2 Bed']
'''
[output] : 
url								address				neighborhood						rent	beds	baths
369	https://www.renthop.com/listings/1775-york-ave...	1775 York Avenue, Apt 31B	Yorkville, Upper East Side, Upper Manhattan, M...	$6,495	2 Bed	2 Bath
'''

그리고 해당 행의 인덱스를 확인한다. 이 데이터의 인덱스는 369이다.

데이터프레임의 이름.at['인덱스', '열의이름']

을 사용하면 해당 데이터의 값을 가져올 수 있다.

df.at[369, 'beds']
'''
[output] : ' 2 Bed'
'''

더 중요한 것은 여기에 다른 값을 덮어쓸 수 있다는 것이다.

df.at[369, 'beds'] = '2 Bed'
df.at[369, 'beds']
'''
[output] : '2 Bed' 
'''

' 2 Bed'에서 '2 Bed'로 값이 변환된 것을 알 수 있다.

다시 한 번 데이터프레임의 beds열에 존재하는 모든 종류의 값들을 출력해보자.

df['beds'].unique()
'''
[output] : array(['-', '3 Bed', '2 Bed', '1 Bed', '4 Bed', '5 Bed', '6 Bed',
       ' 5 Bed', ' 1 Bed', ' 2 Bed', ' 3 Bed'], dtype=object)
'''

' 2 Bed' 외에도 예상치 못한 값이 아직 남아있는 것을 확인 할 수 있다.

현재 값들의 특징이 왼쪽 공백으로 인한 경우로 lstrip()을 통해 공백을 제거할 수 있다.

코드는 다음과 같다.

df['beds'] = df['beds'].apply(lambda x: x.lstrip())
df['beds'].unique()
'''
[output] : array(['-', '3 Bed', '2 Bed', '1 Bed', '4 Bed', '5 Bed', '6 Bed'],
      dtype=object)
'''

값이 없었들 때는 '-' 값이 들어가있다는 것만 기억하자.

마찬가지로 bath열에 대해서도 값의 종류를 확인해보겠다.

df['beds'] = df['beds'].apply(lambda x : x.lstrip())
df.unique()
'''
[output] : array(['-', '3 Bed', '2 Bed', '1 Bed', '4 Bed', '5 Bed', '6 Bed'],
      dtype=object)
'''

결과적으로 전처리를 거친 데이터프레임은 다음과 같다.

df
'''
[output] : 
	url	address	neighborhood	rent	beds	baths
0	https://www.renthop.com/listings/10-hanover-sq...	10 Hanover Square, Apt 08U	Financial District, Downtown Manhattan, Manhattan	$3,446	-	1 Bath
1	https://www.renthop.com/listings/239-north-9th...	239 North 9th Street, Apt STUD...	Williamsburg, Northern Brooklyn, Brooklyn	$3,560	-	1 Bath
2	https://www.renthop.com/listings/528-west-136-...	528 West 136 Street, Apt 36	Hamilton Heights, West Harlem, Upper Manhattan...	$2,500	3 Bed	1 Bath
...
'''

총 2,152개의 행을 가졌으며 6개의 열을 가지고 있다. 중복도 제거해 보겠다.

df.drop_duplicates(inplace=True)
df
'''
[output] : 	url	address	neighborhood	rent	beds	baths
0	https://www.renthop.com/listings/wall-st/2022/...	Wall St	Financial District, Downtown Manhattan, Manhattan	$7,329	4 Bed	2 Bath
1	https://www.renthop.com/listings/431-w-37th-st...	431 W 37th Street, Apt ONE BED...	Hell's Kitchen, Midtown Manhattan, Manhattan	$4,659	1 Bed	1 Bath
2	https://www.renthop.com/listings/69-malcolm-x-...	69 Malcolm X Boulevard, Apt 1R...	Bedford-Stuyvesant, Northern Brooklyn, Brooklyn	$3,199	4 Bed	1.5 Bath
...
'''

열이 1852개로 줄었다. 중복데이터가 300개가 존재했었다는 의미이다.

이제 각 열의 데이터 타입을 확인해보겠다.

df.info()
'''
[output] : 
<class 'pandas.core.frame.DataFrame'>
Int64Index: 1852 entries, 0 to 2151
Data columns (total 6 columns):
 #   Column        Non-Null Count  Dtype 
---  ------        --------------  ----- 
 0   url           1852 non-null   object
 1   address       1851 non-null   object
 2   neighborhood  1852 non-null   object
 3   rent          1852 non-null   object
 4   beds          1852 non-null   object
 5   baths         1852 non-null   object
dtypes: object(6)
memory usage: 101.3+ KB
'''

모두 문자열 데이터이다. beds의 열에 '-' 값인 경우에 대해서 데이터프레임을 출력해보겠다.

df[df['beds']==='-']
'''
[output] : 
	url	address	neighborhood	rent	beds	baths
8	https://www.renthop.com/listings/440-east-78th...	440 East 78th Street, Apt 2H	Upper East Side, Upper Manhattan, Manhattan	$2,295	-	1 Bath
9	https://www.renthop.com/listings/808-columbus-...	808 Columbus Ave, Apt 12F	Manhattan Valley, Upper West Side, Upper Manha...	$4,406	-	1 Bath
17	https://www.renthop.com/listings/1273-3rd-aven...	1273 3rd Avenue, Apt 16A	Upper East Side, Upper Manhattan, Manhattan	$2,258	-	1 Bath
'''

288개의 '-' 값이 beds의 열에 값이 없어서 들어가 있는 것을 볼 수 있다.

후에 머린 러닝 모델을 돌려볼 것이기 때문에 rent, beds, baths에 대해서 모두 숫자로 값을 변경하겠다.

과정은 다음과 같다.

  • beds의 열에서 'Bed'를 제거해서 모든 값에 숫자만 남긴다.
  • beds의 열에서 '-' 값을 가지는 경우 방이 별도로 없다는 의미이므로 숫자 0을 넣는다.
  • 정수값만 남게된 beds 열 전체를 데이터 타입을 정수형(int)로 변환한다.
  • rent 열에서 %dhk ,를 제거하여 숫자만 남도록하고 정수형(int)로 변환한다.
  • baths 열에서 'Bath'를 제거하여 실수형(float)로 변환한다.

아래의 코드는 이 과정을 순차적으로 진행하는 코드이다.

df['beds'] = df['beds'].map(lambda x: x.replace(' Bed', ''))
df['beds'] = df['beds'].replace('-', 0)
df['beds']= df['beds'].astype(int)
df['rent'] = df['rent'].map(lambda x: str(x).replace('$','').replace(',','')).astype('int')
df['baths'] = df['baths'].map(lambda x: x.replace(' Bath', '')).astype('float')
df
'''
[output] : 
	url	address	neighborhood	rent	beds	baths
0	https://www.renthop.com/listings/wall-st/2022/...	Wall St	Financial District, Downtown Manhattan, Manhattan	7329	4	2.0
1	https://www.renthop.com/listings/431-w-37th-st...	431 W 37th Street, Apt ONE BED...	Hell's Kitchen, Midtown Manhattan, Manhattan	4659	1	1.0
2	https://www.renthop.com/listings/69-malcolm-x-...	69 Malcolm X Boulevard, Apt 1R...	Bedford-Stuyvesant, Northern Brooklyn, Brooklyn	3199	4	1.5
...
'''

이로서 rent, beds, baths 열에는 수치형 데이터만 남게됐다. 다시 데이터의 타입을 확인해보자.

df.info()
'''
[output] : 
<class 'pandas.core.frame.DataFrame'>
Int64Index: 1852 entries, 0 to 2151
Data columns (total 6 columns):
 #   Column        Non-Null Count  Dtype  
---  ------        --------------  -----  
 0   url           1852 non-null   object 
 1   address       1851 non-null   object 
 2   neighborhood  1852 non-null   object 
 3   rent          1852 non-null   int64  
 4   beds          1852 non-null   int64  
 5   baths         1852 non-null   float64
dtypes: float64(1), int64(2), object(3)
memory usage: 101.3+ KB
'''

인근 지역을 나타내는 'neighborhood' 열에 대해서 카운트하여 출력해보겠다.

df.groupby('neighborhood')['rent'].count().to_frame('count')\
.sort_values(by='count', ascending=False)
'''
[output] :
neighborhood						count
Yorkville, Upper East Side, Upper Manhattan, Manhattan	146
Upper East Side, Upper Manhattan, Manhattan		142
Financial District, Downtown Manhattan, Manhattan	140
'''

특정 키워드에 대해서 카운트를 할 수도 있다. 'Upper East Side'가 언급된 데이터는 몇 개가 있는지 카운트 해보자.

df[df['neighborhood'].str.contains('Upper East Side')]['neighborhood'].value_counts()
'''
[output] : 
Yorkville, Upper East Side, Upper Manhattan, Manhattan        146
Upper East Side, Upper Manhattan, Manhattan                   142
Lenox Hill, Upper East Side, Upper Manhattan, Manhattan         9
Carnegie Hill, Upper East Side, Upper Manhattan, Manhattan      6
 Yorkville, Upper East Side, Upper Manhattan, Manhattan         1
 Upper East Side, Upper Manhattan, Manhattan                    1
Name: neighborhood, dtype: int64
'''

각 인근 지역별로 평균 렌트비는 얼마인지 출력해겠다.

df.groupby('neighborhood')['rent'].mean().to_frame('mean')\
.sort_values(by='mean', ascending=False)
'''
[output] : 
neighborhood 			mean	
Battery Park City, Downtown Manhattan, Manhattan	12829.000000
Two Bridges, Downtown Manhattan, Manhattan	10764.500000
Lenox Hill, Upper East Side, Upper Manhattan, Manhattan	8760.777778
Flatiron District, Midtown Manhattan, Manhattan	8450.000000
NoMad, Midtown Manhattan, Manhattan	7773.222222
...
'''

Battery Park City, Downtown Manhattan, Manhattan이 가장 렌트비가 높고, 렌트비가 가장 낮은 곳은 Dyker Heights, Southwestern Brooklyn, Brooklyn이다. 이쯤에서 전처리한 데이터를 유실하지 않도록 한 번 저장하는 것을 권한다.

df.to_csv('NYC_DF.csv', index = False)
df = pd.read_csv('NYC_DF.csv', sep = ',')
df
'''
[output] : 
	url	address	neighborhood	rent	beds	baths
0	https://www.renthop.com/listings/wall-st/2022/...	Wall St	Financial District, Downtown Manhattan, Manhattan	7329	4	2.0
1	https://www.renthop.com/listings/431-w-37th-st...	431 W 37th Street, Apt ONE BED...	Hell's Kitchen, Midtown Manhattan, Manhattan	4659	1	1.0
2	https://www.renthop.com/listings/69-malcolm-x-...	69 Malcolm X Boulevard, Apt 1R...	Bedford-Stuyvesant, Northern Brooklyn, Brooklyn	3199	4	1.5
...
'''

Visualization

이 부분은 GoogleMapAPI 데이터를 전처리하는 과정이다. 이 과정은 이런 방법이 있다는 정도만 살펴보겠다.

!pip install googlemaps

여기서는 우편 번호만 추출하려고 한다. 코드 노드는 아래와 같은 구조로 추적할 수 있다.

import googlemaps
gmaps = googlemaps.Client(key='')#google key : https://webruden.tistory.com/378 (블로그참고)
ta = df.loc[3,['address']].values[0] + ' '\
+ df.loc[3,['neighborhood']].values[0].split(', ')[-1]
geocode_result = gmaps.geocode(ta)
for piece in geocode_result[0]['address_components']:
    if 'postal_code' in piece['types'] :
        print(piece['short_name'])

위에서 re.match는 Python에서 제공하는 정규 표현식을 사용한 것이다.
정규 표현식에 대해서 다음과 같이 따로 설명하는 자료를 아래 링크를 남기겠다.

정규 표현식 이론 설명 : https://wikidocs.net/21703

이만 전처리한 데이터를 저장하겠다.

df['zip'] = df.apply(get_zip, axis=1)
df.to_csv('NYC_ZIP.csv', index=False)
df = pd.read_csv('NYC_ZIP.csv', sep=',', dtype={'zip':object})

우편 번호를 얻을 수 없는 경우에는 NULL 값(결측값)을 넣도록 하였기 때문에, 결측값이 아닌 경우의 데이터는 몇 개인지 확인해보겠다.

df[df['zip'].notnull()].count()
'''
[output] :
url             940
address         940
neighborhood    940
rent            940
beds            940
baths           940
zip             940
dtype: int64
'''

우편 번호(zip)인 데이터는 총 940개이다.
기존 데이터는 1942개지만 이 중 940개의 데이터만 사용하겠다.

zdf = df[df['zip'].notnull()].copy()
zdf['zip'] = zdf['zip'].str.replace('\.0', '')#float 소수점 아래 데이터를 제거
zdf_mean = zdf.groupby('zip')['rent'].mean().to_frame('avg_rent')\
.sort_values(by='avg_rent', ascending=False).reset_index()

우편 번호를 기준으로 데이터를 시각화하는 가장 좋은 방법은 데이터를 색상 스펙트럼에 따라서 표현한 heat map이다.

패키지 folium을 사용하여 rent값에 대한 heat map을 그려보겠다.

구글 검색을 통해 우편 번호가 연결되어져 있는 뉴욕의 GeoJSON 파일을 다운로드해야한다.

다음 코드를 실행하여 다운 받는다.

!wget https://raw.githubusercontent.com/fedhere/PUI2015_EC/master/mam1612_EC/nyc-zip-code-tabulation-areas-polygons.geojson

heat map에 사용하고 싶은 열뿐만 아니라, 히트맵의 키가 되는 열을 참조하도록 한다. 다른 옵션으로 팔레트와 색상을 조정하기 위한 다른 인수를 조정할 수 있다.

import folium

NYC = folium.Map(location=[40.748817, -73.985428], zoom_start=13)
NYC.choropleth(
	geo_data = open('nyc-zip-code-tabulation-areas-polygons.geojson').read(),
    data = zdf_mean,
    columns = ['zip', 'avg_rent'],
    key_on = 'feature.properties.postalCode',
    fill_color = 'YlOrBd', fill_opacity = 0.7, line_opacity = 0.2,
)

NYC

해당 코드의 결과는 다음과 같다.

heat map이 완성되면 어느 지역의 월세가 높고 낮은 지 알 수 있다. 어떤 지역을 임대할 지 정하는데 도움이 되기는 하지만, 희귀 모델링을 통해 좀 더 분석해보겠다.


Modeling

여기서는 침실의 개수가 임대료에 미치는 영향을 알아보겠다. 두 개의 패키지를 사용하겠다. 그 중 첫번째는 statsmodels이고, 두번째는
patsy로 statsmodels의 사용을 더 쉽게 만들어 준다.

import patsy
import statsmodels.api as sm

model변수는 smf의 OLS(최소제곱법)을 사용하여 회귀모형을 만든다. fomular는 '예측 대상 ~ 예측을 위한 변수' 로 예측대상으로는 임대료, 예측을 위한 변수는 우편 번호와 침대 개수로 지정하겠다. 해당 공식은 우편 번호와 침대 개수가 임대료에 어떤 영향을 미치는지 알고 싶다는 것을 의미한다.

zdf
'''
[output] : 
url	address	neighborhood	rent	beds	baths	zip
0	https://www.renthop.com/listings/1013-pacific-...	1013 Pacific Street, Apt 2L	Prospect Heights, Northwestern Brooklyn, Brooklyn	2400	3	1.0	11238
5	https://www.renthop.com/listings/354-east-83-s...	354 East 83 Street, Apt 4J	Yorkville, Upper East Side, Upper Manhattan, M...	1750	0	1.0	10028
8	https://www.renthop.com/listings/145-4th-avenu...	145 4th Avenue	Greenwich Village, Downtown Manhattan, Manhattan	3167	1	1.0	10003
...
'''

현재 zdf는 더미형 변수이다. 원-핫 인코딩이 필요하다. pasty.dmatrices를 사용하면 자동으로 할 수 있다.

f = 'rent ~ C(zip) + beds'
y, X = patsy.dmatrices(f, zdf, return_type = 'dataframe')

참고 : https://kiyoja07.blogspot.com/2019/03/python-linear-regression.html

patsy.dmatrices()에 데이터프레임과 공식을 전달한다. 아래의 코드를 보면 patsy는 예측 변수에 X 행렬을, 응답 변수에 y 벡터를 설정해 sm.OLS를 입력하고, fit()을 호출해 모델을 실행한다. 마지막으로 모델의 결과를 출력한다.

아래 모델의 결과를 보자. 일단 관측을 한 데이터의 개수(No. Observations)는 940개이다. 조정 R2(adjusted R2 / 테이블상으로는 Adj. R-squared)는 0.487이다. R-squared는 앞서 회귀분석을 실시한 "임대료 = 우편번호 + 방의개수 * weight"라는 모델식의 적합성을 말해준다. 결과는 선형 회귀분석 모델이 임대료의 변동성의 48.7%를 설명한다는 의미이다. R-squared는 0~1의 값을 가지고 0이면 모델의 설명력이 전혀 없는 상태, 1이면 모델이 완벽하게 데이터를 설명해주는 상태이다. 사회과학에서는 보통 0,4이상 이면 괜찮은 모델이라고 판단한다.

중요한 것은 F-통계 확률이 2.64e-94라는 것이다. 회귀모형에 대한 (통계적) 유의미성 검증 결과, 유의미함 (p < 0.05) 이것이 왜 중요하냐면 침대 개수와 우편 번호만을 사용해서 해당 모델이 제 3의 임대료의 편차를 설명할 수 있다는 것을 의미한다.

아래 테이블 형태의 출력부분의 각 열은 모델에서 각 독립 변수의 정보를 제공한다. 왼쪽에서 오른쪽으로 다음 정보를 확인할 수 있다. 변수의 모델과의 계수, 표준 오차(standard error), t-통계, t-통계의 P값, 95% 신뢰 구간이다.

P 값의 열을 보면 독립 변수들의 통계적 유의성(statistically significant)를 결정할 수 있다.

회귀 모델에서의 통계적 유의성은 독립 변수와 응답 변수간의 관계가 우연히 발생하지는 않았다는 것을 의미한다.

일반적으로 통계학자들은 P값이 0.5인 것을 기준으로 결정한다. P값이 0.5라는 것은 해당 결과가 우연히 발생할 확률이 5%뿐이라는 것이다. 침실의 개수는 여기서 확실히 유의성을 가진다.

X.head()
'''
[output] : 
Intercept	C(zip)[T.10002]	C(zip)[T.10003]	C(zip)[T.10004]	C(zip)[T.10005]	C(zip)[T.10006]	C(zip)[T.10007]	C(zip)[T.10009]	C(zip)[T.10010]	C(zip)[T.10011]	C(zip)[T.10012]	C(zip)[T.10013]	C(zip)[T.10014]	C(zip)[T.10016]	C(zip)[T.10017]	C(zip)[T.10018]	C(zip)[T.10019]	C(zip)[T.10021]	C(zip)[T.10022]	C(zip)[T.10023]	C(zip)[T.10024]	C(zip)[T.10025]	C(zip)[T.10026]	C(zip)[T.10027]	C(zip)[T.10028]	C(zip)[T.10029]	C(zip)[T.10030]	C(zip)[T.10031]	C(zip)[T.10032]	C(zip)[T.10033]	C(zip)[T.10034]	C(zip)[T.10035]	C(zip)[T.10036]	C(zip)[T.10037]	C(zip)[T.10038]	C(zip)[T.10040]	C(zip)[T.10065]	C(zip)[T.10069]	C(zip)[T.10075]	C(zip)[T.10128]	...	C(zip)[T.10710]	C(zip)[T.11102]	C(zip)[T.11103]	C(zip)[T.11201]	C(zip)[T.11203]	C(zip)[T.11204]	C(zip)[T.11205]	C(zip)[T.11206]	C(zip)[T.11207]	C(zip)[T.11208]	C(zip)[T.11209]	C(zip)[T.11210]	C(zip)[T.11211]	C(zip)[T.11212]	C(zip)[T.11213]	C(zip)[T.11215]	C(zip)[T.11216]	C(zip)[T.11217]	C(zip)[T.11218]	C(zip)[T.11220]	C(zip)[T.11221]	C(zip)[T.11222]	C(zip)[T.11223]	C(zip)[T.11225]	C(zip)[T.11226]	C(zip)[T.11229]	C(zip)[T.11230]	C(zip)[T.11231]	C(zip)[T.11232]	C(zip)[T.11233]	C(zip)[T.11235]	C(zip)[T.11236]	C(zip)[T.11237]	C(zip)[T.11238]	C(zip)[T.11249]	C(zip)[T.11385]	C(zip)[T.7030]	C(zip)[T.7302]	C(zip)[T.7631]	beds
0	1.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	...	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	1.0	0.0	0.0	0.0	0.0	0.0	3.0
5	1.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	1.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	...	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0
8	1.0	0.0	1.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	...	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	1.0
9	1.0	0.0	1.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	...	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0
10	1.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	1.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	...	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0	0.0
5 rows × 84 columns
'''
y
'''
[output] : 
	rent
0	2400.0
5	1750.0
8	3167.0
9	2100.0
10	1700.0
...	...
1926	3051.0
1930	5450.0
1935	5500.0
1938	1800.0
1939	2244.0
940 rows × 1 columns
'''
results = sm.OLS(y, X).fit()
results.summary()
'''
[output] : 
OLS Regression Results
Dep. Variable:	rent	R-squared:	0.532
Model:	OLS	Adj. R-squared:	0.487
Method:	Least Squares	F-statistic:	11.73
Date:	Sun, 25 Jul 2021	Prob (F-statistic):	2.64e-94
Time:	06:57:00	Log-Likelihood:	-7958.5
No. Observations:	940	AIC:	1.609e+04
Df Residuals:	856	BIC:	1.649e+04
Df Model:	83		
Covariance Type:	nonrobust		
coef	std err	t	P>|t|	[0.025	0.975]
Intercept	4009.4975	245.660	16.321	0.000	3527.331	4491.664
C(zip)[T.10002]	-2621.2222	412.632	-6.352	0.000	-3431.111	-1811.333
C(zip)[T.10003]	-2028.3065	335.012	-6.054	0.000	-2685.847	-1370.766
C(zip)[T.10004]	-1826.5109	736.625	-2.480	0.013	-3272.313	-380.708
...
C(zip)[T.7030]	-2161.5176	736.392	-2.935	0.003	-3606.863	-716.172
C(zip)[T.7302]	-2060.2139	386.079	-5.336	0.000	-2817.986	-1302.442
C(zip)[T.7631]	-2738.3896	393.658	-6.956	0.000	-3511.037	-1965.742
beds	1018.0201	42.360	24.032	0.000	934.878	1101.162
Omnibus:	709.508	Durbin-Watson:	1.921
Prob(Omnibus):	0.000	Jarque-Bera (JB):	22587.558
Skew:	3.095	Prob(JB):	0.00
Kurtosis:	26.203	Cond. No.	114.


Warnings:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
  • Adj.R-squred : 보통 설명력이라고 말하는 값인데 주어진 데이터를 현재 모형이 얼마나 잘 설명하고 있는지를 나타내는 지수이다.
  • Prob(F-statistics) : 모형에 대한 p-value 로 통상 0.05이하인 경우 통계적으로 유의하다고 판단한다. 2.64e-94는 2.64 * 10^-94를 의미하므로 유의하다.
  • P>[t] : 각 독립변수의 계수에 대한 p-value로 해당 독립변수가 유의미한지 판단한다. 0.05 이하인 경우 통계적으로 유의미하다고 판단하며, 0.05보다 큰 경우에는 해당 변수는 통계적으로 유의미하지않다. (예측에 별 도움이 안 되는 변수라는 의미이다.)

좋은 웹페이지 즐겨찾기