回帰分析と機械学習で中央線の高コスパ物件を探す(スクレイピング+前処理)

みんなが大好き中央線沿いで、コスパ高い物件を探してみます。

完全に以下のブログに触発されたものです。

www.analyze-world.com

やったこと

  • webから中央線沿いの物件情報をスクレイピング
  • モデルへの入力のため前処理
  • データの可視化と変数選択
  • 家賃予測モデルの生成
  • モデルを使用した家賃の予測と実際の家賃を比較したコスパの導出

やってみてよかったこと・気づいたこと

  • Pythonの扱い方を思い出した
  • Webスクレピングの練習になった
  • Pandasの練習になった
  • 改めて回帰分析の勉強をできた
  • 学生の時以来に機械学習した
  • 会社入ってから身につけたHTML+CSSとSQL周りのスキルが役にたった

Webからのスクレイピング

まず初めにデータ収集です。

Suumoさんからスクレイピングさせてもらいます。

今回対象とする駅は、The・中央線という感じの中野駅〜立川駅とします。 f:id:pompom168:20171204135004p:plain

これらの物件データをスクレイピングして、Pandasのデータフレームにまとめます。

まずHTMLの構造を確認します。

ブラウザのディベロッパーツールを使用するとやりやすいです。

Chromeでは、Windowsの場合:「F12」、Macの場合:「Command + Option + I」で表示できます。

Suumoのサイトでは、検索された物件リストの情報が id=js-bukkenListのdivタグの中にあり f:id:pompom168:20171204140643p:plain 各物件の情報が class=cassetteitemのdivタグの中にあるようです。 f:id:pompom168:20171204140742p:plain

以下、スクレイピングを行ったコードです。

#必要なライブラリをインポート
from bs4 import BeautifulSoup
import requests
import pandas as pd
from pandas import Series, DataFrame
import time

url = "http://suumo.jp/jj/chintai/ichiran/FR301FC001/?ar=030&bs=040&pc=30&smk=&po1=12&po2=99&shkr1=03&shkr2=03&shkr3=03&shkr4=03&rn=0305&ek=030527280&ek=030513930&ek=030500640&ek=030506640&ek=030528500&ek=030511640&ek=030536880&ek=030538740&ek=030531920&ek=030538710&ek=030514690&ek=030528740&ek=030512780&ek=030523100&ra=013&cb=0.0&ct=9999999&ts=1&ts=2&et=9999999&mb=0&mt=9999999&cn=9999999&fw2="

#データ取得
result = requests.get(url)
c = result.content

#HTMLを元に、オブジェクトを作る
soup = BeautifulSoup(c)

#物件リストの部分を切り出し
summary = soup.find("div",{'id':'js-bukkenList'})

#ページ数を取得
body = soup.find("body")
pages = body.find_all("div",{'class':'pagination pagination_set-nav'})
pages_text = str(pages)
pages_split = pages_text.split('</a></li>\n</ol>')
pages_split0 = pages_split[0]
pages_split1 = pages_split0[-3:]
pages_split2 = pages_split1.replace('>','')
pages_split3 = int(pages_split2)

#URLを入れるリスト
urls = []

#1ページ目を格納
urls.append(url)

#2ページ目から最後のページまでを格納
for i in range(pages_split3-1):
    pg = str(i+2)
    url_page = url + '&pn=' + pg
    urls.append(url_page)
    
name = [] #マンション名
address = [] #住所
locations = [] #立地1つ目(最寄駅/徒歩~分)
age = [] #築年数
height = [] #建物高さ
floor = [] #階
rent = [] #賃料
admin = [] #管理費
others = [] #敷/礼/保証/敷引,償却
floor_plan = [] #間取り
area = [] #専有面積


#各ページで以下の動作をループ
count = 0
for url in urls:
    #物件リストを切り出し
    result = requests.get(url)
    c = result.content
    soup = BeautifulSoup(c)
    summary = soup.find("div",{'id':'js-bukkenList'})
    
    if isinstance(summary,type(None)):
        continue
    
    #マンション名、住所、立地(最寄駅/徒歩~分)、築年数、建物高さが入っているcassetteitemを全て抜き出し
    cassetteitems = summary.find_all("div",{'class':'cassetteitem'})

    #各cassetteitemsに対し、以下の動作をループ
    for i in range(len(cassetteitems)):
        #各建物から売りに出ている部屋数を取得
        tbodies = cassetteitems[i].find_all('tbody')

        
        #マンション名取得
        subtitle = cassetteitems[i].find_all("div",{
            'class':'cassetteitem_content-title'})
        subtitle = str(subtitle)
        subtitle_rep = subtitle.replace(
            '[<div class="cassetteitem_content-title">', '')
        subtitle_rep2 = subtitle_rep.replace(
            '</div>]', '')

        #住所取得
        subaddress = cassetteitems[i].find_all("li",{
            'class':'cassetteitem_detail-col1'})
        subaddress = str(subaddress)
        subaddress_rep = subaddress.replace(
            '[<li class="cassetteitem_detail-col1">', '')
        subaddress_rep2 = subaddress_rep.replace(
            '</li>]', '')
        
        #部屋数だけ、マンション名と住所を繰り返しリストに格納(部屋情報と数を合致させるため)
        for y in range(len(tbodies)):
            name.append(subtitle_rep2)
            address.append(subaddress_rep2)

        #立地を取得
        sublocations = cassetteitems[i].find_all("li",{
            'class':'cassetteitem_detail-col2'})
    
        #立地は、1つ目だけを取得
        for x in sublocations:
            cols = x.find_all('div')
            for i in range(len(cols)):
                text = cols[i].find(text=True)
                for y in range(len(tbodies)):
                    if i == 0:
                        locations.append(text)
                        
        #築年数と建物高さを取得
        col3 = cassetteitems[i].find_all("li",{
            'class':'cassetteitem_detail-col3'})

        
        
        for x in col3:
            cols = x.find_all('div')
            for i in range(len(cols)):
                text = cols[i].find(text=True)
                for y in range(len(tbodies)):
                    if i == 0:
                        age.append(text)
                    else:
                        height.append(text)

    #階、賃料、管理費、敷/礼/保証/敷引,償却、間取り、専有面積が入っているtableを全て抜き出し
    tables = summary.find_all('table')

    #各建物(table)に対して、売りに出ている部屋(row)を取得
    rows = []
    for i in range(len(tables)):
        rows.append(tables[i].find_all('tr'))

    #各部屋に対して、tableに入っているtext情報を取得し、dataリストに格納
    data = []
    for row in rows:
        for tr in row:
            cols = tr.find_all('td')
            for td in cols:
                text = td.find(text=True)
                data.append(text)

    #dataリストから、階、賃料、管理費、敷/礼/保証/敷引,償却、間取り、専有面積を順番に取り出す
    index = 0
    for item in data:
        if '階' in item:
            floor.append(data[index])
            rent.append(data[index+1])
            admin.append(data[index+2])
            others.append(data[index+3])
            floor_plan.append(data[index+4])
            area.append(data[index+5])
        index +=1
    
    #プログラムを1秒間停止する(スクレイピングマナー)
    time.sleep(1)
    
    count += 1
    
    print(str(count) + 'ページ終了 / 全' + str(len(urls)) + 'ページ')

#各リストをシリーズ化
name = Series(name)
address = Series(address)
locations = Series(locations)
age = Series(age)
height = Series(height)
floor = Series(floor)
rent = Series(rent)
admin = Series(admin)
others = Series(others)
floor_plan = Series(floor_plan)
area = Series(area)

#各シリーズをデータフレーム化
suumo_df = pd.concat([name, address, locations, age, height, floor, rent, admin, others, floor_plan, area], axis=1)

#カラム名
suumo_df.columns=['マンション名','住所','立地','築年数','建物高さ','階','賃料','管理費', '敷/礼/保証/敷引,償却','間取り','専有面積']

#csvファイルとして保存
suumo_df.to_csv('bukken_chuoline.csv', sep = '\t',encoding='utf-16')  

前処理

次は、モデルに入力する変数とするための前処理です。 上記で紹介したブログと同じことをやっている部分の説明は割愛します。

#必要なライブラリをインポート
import pandas as pd
import numpy as np

#csv読み込み                
df = pd.read_csv("bukken_chuoline.csv", sep='\t', encoding='utf-16')

#不要な列を削除
df.drop(['Unnamed: 0'], axis=1, inplace=True)

#立地を「路線+駅」と「徒歩〜分」に分割
splitted1 = df['立地'].str.split(' 歩', expand=True)
splitted1.columns = ['立地11', '立地12']


#その他費用を、「敷金」「礼金」「保証金」「敷引,償却」に分割
splitted4 = df['敷/礼/保証/敷引,償却'].str.split('/', expand=True)
splitted4.columns = ['敷金', '礼金', '保証金', '敷引,償却']

#分割したカラムを結合
df = pd.concat([df, splitted1, splitted4], axis=1)

#分割前のカラムは分析に使用しないので削除しておく
df.drop(['立地','敷/礼/保証/敷引,償却','敷引,償却'], axis=1, inplace=True)


#立地を「路線」「駅」「徒歩〜分」に分割
splitted7 = df['立地11'].str.split('/', expand=True)
splitted7.columns = ['路線', '駅']
splitted7['徒歩'] = df['立地12']

#結合
df = pd.concat([df, splitted7], axis=1)

#不要なカラムを削除
df.drop(['立地11','立地12'], axis=1, inplace=True)

#「賃料」がNAの行を削除
df = df.dropna(subset=['賃料'])

#エンコードをcp932に変更しておく(これをしないと、replaceできない)
df['賃料'].str.encode('cp932')
df['敷金'].str.encode('cp932')
df['礼金'].str.encode('cp932')
df['保証金'].str.encode('cp932')
df['管理費'].str.encode('cp932')
df['築年数'].str.encode('cp932')
df['専有面積'].str.encode('cp932')


#数値として扱いたいので、不要な文字列を削除
df['賃料'] = df['賃料'].str.replace(u'万円', u'')
df['敷金'] = df['敷金'].str.replace(u'万円', u'')
df['礼金'] = df['礼金'].str.replace(u'万円', u'')
df['保証金'] = df['保証金'].str.replace(u'万円', u'')
df['管理費'] = df['管理費'].str.replace(u'円', u'')
df['築年数'] = df['築年数'].str.replace(u'新築', u'0') #新築は築年数0年とする
df['築年数'] = df['築年数'].str.replace(u'築', u'')
df['築年数'] = df['築年数'].str.replace(u'年', u'')
df['専有面積'] = df['専有面積'].str.replace(u'm', u'')
df['徒歩'] = df['徒歩'].str.replace(u'分', u'')

#「-」を0に変換
df['管理費'] = df['管理費'].replace('-',0)
df['敷金'] = df['敷金'].replace('-',0)
df['礼金'] = df['礼金'].replace('-',0)
df['保証金'] = df['保証金'].replace('-',0)


#文字列から数値に変換
df['賃料'] = pd.to_numeric(df['賃料'])
df['管理費'] = pd.to_numeric(df['管理費'])
df['敷金'] = pd.to_numeric(df['敷金'])
df['礼金'] = pd.to_numeric(df['礼金'])
df['保証金'] = pd.to_numeric(df['保証金'])
df['築年数'] = pd.to_numeric(df['築年数'])
df['専有面積'] = pd.to_numeric(df['専有面積'])
df['徒歩'] = pd.to_numeric(df['徒歩'])

#単位を合わせるために、管理費以外を10000倍。
df['賃料'] = df['賃料'] * 10000
df['敷金'] = df['敷金'] * 10000
df['礼金'] = df['礼金'] * 10000
df['保証金'] = df['保証金'] * 10000


#管理費は実質的には賃料と同じく毎月支払うことになるため、「賃料+管理費」を家賃を見る指標とする
df['賃料+管理費'] = df['賃料'] + df['管理費']

#敷金/礼金と保証金は同じく初期費用であり、どちらかが適用されるため、合計を初期費用を見る指標とする
df['敷/礼/保証金'] = df['敷金'] + df['礼金'] + df['保証金']

#階を数値化。地下はマイナスとして扱う
splitted10 = df['階'].str.split('-', expand=True)
splitted10.columns = ['階1', '階2']

splitted10['階1'].str.encode('cp932')
splitted10['階1'] = splitted10['階1'].str.replace(u'階', u'')
splitted10['階1'] = splitted10['階1'].str.replace(u'B', u'-')
splitted10['階1'] = pd.to_numeric(splitted10['階1'])
df = pd.concat([df, splitted10], axis=1)


#建物高さを数値化。地下は無視。
df['建物高さ'].str.encode('cp932')
df['建物高さ'] = df['建物高さ'].str.replace(u'地下1地上', u'')
df['建物高さ'] = df['建物高さ'].str.replace(u'地下2地上', u'')
df['建物高さ'] = df['建物高さ'].str.replace(u'地下3地上', u'')
df['建物高さ'] = df['建物高さ'].str.replace(u'地下4地上', u'')
df['建物高さ'] = df['建物高さ'].str.replace(u'地下5地上', u'')
df['建物高さ'] = df['建物高さ'].str.replace(u'地下6地上', u'')
df['建物高さ'] = df['建物高さ'].str.replace(u'地下7地上', u'')
df['建物高さ'] = df['建物高さ'].str.replace(u'地下8地上', u'')
df['建物高さ'] = df['建物高さ'].str.replace(u'地下9地上', u'')
df['建物高さ'] = df['建物高さ'].str.replace(u'平屋', u'1')
df['建物高さ'] = df['建物高さ'].str.replace(u'階建', u'')
df['建物高さ'] = pd.to_numeric(df['建物高さ'])


#indexを振り直す(これをしないと、以下の処理でエラーが出る)
df = df.reset_index(drop=True)

#間取りを「部屋数」「DK有無」「K有無」「L有無」「S有無」に分割
df['間取りDK'] = 0
df['間取りK'] = 0
df['間取りL'] = 0
df['間取りS'] = 0
df['間取り'].str.encode('cp932')
df['間取り'] = df['間取り'].str.replace(u'ワンルーム', u'1') #ワンルームを1に変換

for x in range(len(df)):
    if 'DK' in df['間取り'][x]:
        df.loc[x,'間取りDK'] = 1
df['間取り'] = df['間取り'].str.replace(u'DK',u'')

for x in range(len(df)):
    if 'K' in df['間取り'][x]:
        df.loc[x,'間取りK'] = 1        
df['間取り'] = df['間取り'].str.replace(u'K',u'')

for x in range(len(df)):
    if 'L' in df['間取り'][x]:
        df.loc[x,'間取りL'] = 1        
df['間取り'] = df['間取り'].str.replace(u'L',u'')

for x in range(len(df)):
    if 'S' in df['間取り'][x]:
        df.loc[x,'間取りS'] = 1        
df['間取り'] = df['間取り'].str.replace(u'S',u'')

df['間取り'] = pd.to_numeric(df['間取り'])


#カラムを入れ替え
df = df[['マンション名','間取り','間取りDK','間取りK','間取りL','間取りS','築年数','建物高さ','階1','専有面積','賃料+管理費','敷/礼/保証金',
                '駅','徒歩','賃料','管理費',
                '敷金','礼金','保証金']]

また、一番の最寄りが中央線の駅以外のデータが入っていましたので、それらを除去します。

#中央線以外の駅が入っているので除去
df = df.query('駅 in ["中野駅", "阿佐ヶ谷駅", "高円寺駅", "荻窪駅", "西荻窪駅", "吉祥寺駅", "三鷹駅", "武蔵境駅", "東小金井駅", "武蔵小金井駅", "国分寺駅", "西国分寺駅", "国立駅", "立川駅"]')

ここで一旦、データを保存しておきます。(後で見やすいため)

#csvで保存
df.to_csv('bukken_chuoline_prepro.csv', sep = '\t',encoding='utf-16')

次に、最寄り駅の情報は数値データでないため(質的変数)、ダミー変数を導入してモデルで扱えるようにします。

ダミー変数への変換方法は主に2つあります。

  1. 中野駅に0、阿佐ヶ谷駅に1、高円寺駅に2のように各駅に一意のインデックスを付ける

  2. 中野駅、阿佐ヶ谷駅などそれぞれを表す変数を用意し、対応するものを1、それ以外を0とする。(例:中野駅の物件なら中野駅を表す変数に1、それ以外を表す変数を0)とする。

通常は、2の手法を用います。

なぜなら1の方法では、意味のない数字の大小の情報が生じてしまうからです。

また、2の手法でも注意しなければならないことがあります。

それは、カテゴリの数(今回でいう駅の数)分ダミー変数を用意してしまうと、線形従属の関係になる変数が生じてしまい、多重共線性の問題が生じます。

よって通常は、カテゴリの数-1個のダミー変数を生成します。

pandasの場合、以下の一行で出来てしまいます。

#駅をダミー変数に変換
dummy_df = pd.get_dummies(df[['駅']], drop_first = True)

ダミー変数のデータフレームと、これまでのデータフレームをマージします。

#dfのマージ
df2 = pd.merge(df, dummy_df,left_index=True, right_index=True)

後は、モデルの入力に使用しないデータを取り除き、テストのため順番をランダムにして保存します。

#不要なカラムの削除
df2.drop(['マンション名','駅','敷金','礼金','保証金','管理費','賃料','敷/礼/保証金'], axis=1, inplace=True)

#テストのため順番ランダムに
df2 = df2.reindex(np.random.permutation(df2.index))

#モデルへの入力特徴量をcsvで保存
df2.to_csv('input_bukken_chuoline.csv', sep='\t', encoding='utf-16')

長くなってしまいましたが、スクレイピングと前処理はここまでです。

次は、解析に使用するデータの可視化と変数の選択を行います。