LoginSignup
18
14

More than 5 years have passed since last update.

SUUMOのお買い得中古不動産TOP30 by GCN

Last updated at Posted at 2019-03-26

ゴール

GCNを使ってSUUMOからお買い得物件TOP30を見つけちゃいます

背景

ReNomというサイトでこんなGCNを使った不動産関連のチュートリアルがあり、簡単にできそうだったので勉強がてらやってみました。全ソース公開します。

対象読者

python始めたばかりの方
東京都内の中古不動産購入予定者

方法

肝心なGCN部分はチュートリアルをまんまパクって、データはSUUMOの東京中古不動産情報から引っ張ってくるというやり方です。
大まかな流れは以下の通り。

  1. スクレイピング
  2. 前処理
  3. 学習
  4. 結果確認
  5. まとめ
  6. appendix

ちなみに、ご利用規約的にも大丈夫そう。
スクリーンショット 2019-03-30 11.20.29.png

環境

python3
jupyter lab

ライブラリ

ReNom,ReNomRG,BeautifulSoup等

ポイント

今回は、駅名・路線名のような情報を単純にワンホット化(数値データでないデータを0,1のみで表現する方法)するのではなく、
ターゲットエンコーディングという方法を使用しました。
ワンホットだと駅の数、路線の数に応じて変数の数が増えてしまうので、計算に時間かかります。
ワンホットで試しにやってみたところ、変数が多すぎて自分のMacbook air '11 メモリ4GB (貧弱...)では標準化できずにフリーズしました。
具体的には、suumoさんは駅毎の坪単価と路線毎の坪単価を持っているので、駅名と路線名をこの坪単価で置き換えます。

1. スクレイピング

東京都内のSUUMOに掲載されている中古不動産全件を取得します。
スクリーンショット 2019-03-10 18.51.06.png

まずは上図のページ情報を取得します。

#必要なライブラリをインポート
from bs4 import BeautifulSoup
import requests
import pandas as pd
from pandas import Series, DataFrame
import time
import datetime
import numpy as np
import scipy.stats
from sklearn import preprocessing
from tqdm import tqdm

#東京都の中古マンション一覧
url = 'https://suumo.jp/jj/bukken/ichiran/JJ010FJ001/?ar=030&bs=011&ta=13&jspIdFlg=patternShikugun&kb=1&kt=9999999&mb=0&mt=9999999&ekTjCd=&ekTjNm=&tj=0&cnb=0&cn=9999999'

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

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

soupにこのページ(1ページ目の)情報が格納されました。

スクリーンショット 2019-03-10 19.22.00.png
このページからid='js-bukkenList'(このページの不動産情報全件)を取得し、そこから各物件の価格を予想する際の説明変数名を取得します。
スクリーンショット 2019-03-11 0.15.42.png

#物件リストの部分を切り出し
summary = soup.find("div", id='js-bukkenList') #1ページ分を取得
dt = summary.find("div",class_='ui-media').find_all('dt') #dtを全て取得
cols = [t.text for t in dt]
cols.insert(0, 'url') #後ほど物件情報詳細を確認するためurl列を追加

次にページ数を取得し、2ページ目以降のurlを生成していきます。

スクリーンショット 2019-03-10 19.10.27.png

#ページ数を取得
body = soup.find("body")
page = body.find("div",class_='pagination pagination_set-nav')
li = page.find_all('li')
pg_length = int(li[-1].text)

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

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

#2ページ目から最後のページまでを格納
for i in range(pg_length)[:-1]:
    pg = str(i+2) #2ページ目から
    url_page = url + '&pn=' + pg #ページ数に合わせたurl
    urls.append(url_page)

全ページのurlを生成出来ました。
ここから全ページ
の物件情報(aタグとddタグ情報)を取得していきます。
スクリーンショット 2019-03-11 0.15.48.png

#各ページで以下の動作をループ
bukkens =[]
for url in tqdm(urls):
    #物件リストを切り出し
    result = requests.get(url)
    c = result.content
    soup = BeautifulSoup(c)
    summary = soup.find("div",id='js-bukkenList')

    #マンション名、住所、立地(最寄駅/徒歩~分)、築年数、建物高さが入っているproperty_unitsを全て抜き出し
    property_units = summary.find_all("div",class_='property_unit-content')

    #各property_unitsに対し、以下の動作をループ
    for item in property_units:
        l = []
        #マンションへのリンク取得
        h2 = item.find("h2",class_='property_unit-title')
        href = h2.find("a").get('href')
        l.append(href)
        for youso in item.find_all('dd'):
            l.append(youso.text)
        bukkens.append(l)
    time.sleep(0.5)

東京都内の不動産情報全件をbukkensに格納しました。これを一旦csvに出力します。

#列名を設定し、csvに一旦export 
df = pd.DataFrame(bukkens,columns=cols)
df.to_csv('bukken.csv')

#csvのimportと中身の確認
df = pd.read_csv('bukken.csv')
df[0:5]

ここまででスクレイピングは完了です。取得したデータはこんな感じでDataFrameに格納されています。
スクリーンショット 2019-03-10 19.40.08.png

2.前処理

各列のデータから余分な文字列を除去していきます。

#販売価格に「〜」が含まれている行を削除
df = df[~df['販売価格'].str.contains('~')]
#価格を値のみへ
# _df = df['販売価格'].str.extract('\n([億0-9]+)万円') #seriesじゃないとstr使えない
_df = df['販売価格'].str.extract('(.+)[億]+').astype(float).fillna(0)*10000
_df2 = df['販売価格'].str.extract('[\n|億]([0-9]+)万円').astype(float).fillna(0)
df['price'] = _df + _df2
#price列に欠損があれば、drop
df.dropna(subset=['price'],inplace=True)
df['price'] = df['price'].astype(int)
df = df[df['price'] != 0]

#市区最寄り駅を求める
df['市区'] = df['所在地'].str.extract('都(.+)[市|区]')
df[['line','station','distance']] = df['沿線・駅'].str.extract('(.+)「(.+)」(.+)')
#専有面積
df['m2'] = df['専有面積'].str.extract('([\d\.]+)m2').astype('float')
#間取り
df['room'] = df["間取り"].str.extract('(\d+).+').fillna(1).astype('int')
df['living'] = df["間取り"].str.count('L')
df['Dining'] = df["間取り"].str.count('D')
df['kitchen'] = df["間取り"].str.count('K')
df['others'] = df["間取り"].str.count('S')
#バルコニー
df['balcony'] = df['バルコニー'].str.extract('([\d\.]+)m2').fillna(0).astype('f4')
#築年数
year = datetime.date.today().year
df['age'] = year - df['築年月'].str.extract('(.+)年').astype('int')

#不要列削除
df2 = df.drop(['販売価格','所在地','沿線・駅','専有面積','間取り','バルコニー','築年月'],axis=1)

#バスと徒歩の時間を抽出
df2['bus'] = df2['distance'].str.extract('バス(\d+)分').astype(float)
df2['walk'] = df2['distance'].str.extract('歩(\d+)分').astype(float)
#それぞれ欠損を穴埋め
df2[['bus','walk']] = df2[['bus','walk']].fillna(0)
df2[0:5]

一旦df2に余分な文字が除去されたデータを格納しました。
スクリーンショット 2019-03-10 19.47.26.png
ここであらかじめSUUMOから取得しておいた市区毎の平均坪単価駅毎の平均坪単価を説明変数に加えます。これがターゲットエンコーディングです! 取得方法についてはappendixを見てください。

#区毎の坪単価の読み込み
lp = pd.read_csv('land_price.csv')
lp['市区'] = lp['itemName'].str.extract('(.+)[市|区|郡]')
lp = lp.drop(['Unnamed: 0', 'itemName'],axis=1)
lp.columns = ['ld_price','市区']

#駅毎の坪単価の読み込み
sp = pd.read_csv('station_price.csv')
sp['station'] = sp['itemName']
sp = sp.drop(['Unnamed: 0', 'itemName'],axis=1)
sp.columns = ['st_price','station']

#市区毎、駅毎の坪単価平均をマージ
df2 = pd.merge(df2, lp, on='市区')
df2 = pd.merge(df2, sp, on='station')
df2[0:5]

マージした結果がこちらです。
スクリーンショット 2019-03-10 20.02.51.png
再右列にデータが追加されています。駅毎の平均坪単価が算出されていないケースについては市区毎の平均坪単価を代用します。

#駅の平均坪単価が算出されていない場合は市区平均坪単価を代用
df2.loc[df2['st_price'].isnull(), 'st_price'] = df2['ld_price']

ワンホット化(路線名のみ)、標準化を行います。

#不要列を削除し、ワンホット化
df3 = pd.get_dummies(df2.drop(['url','物件名','市区','station','distance'],axis=1))
df3.info()

#標準化
temp = df3.iloc[:,2:]
df4 = (temp - temp.mean()) / temp.std(ddof=0)
print(df4.isnull().any(axis=0).sum())
df4[-10:]

前処理が完了しました。
結果がこちらです。路線名がワンホットされており、その他についても標準化されています。
スクリーンショット 2019-03-10 20.08.57.png

ようやく準備完了!

3.学習

学習用とテスト用にデータを分けます。
後から行番号を使いたいので、indicestrain_test_splitの引数に設定します。

#学習用(`~train`)とテスト用(`~test`)にデータ分割
from sklearn.model_selection import train_test_split
X = df4.values
y = df3['price'].values
indices = df3['Unnamed: 0'].values
X_train, X_test, y_train_org, y_test_org, i_train, i_test = train_test_split(X, y, indices, test_size=0.2)
y_train = np.log(y_train_org)#logにすることにより不動産価格の差異が小さくなり、精度が上がる
y_test = np.log(y_test_org)

ここからはGCNを使ったチュートリアルをそのまま使っています。

#ハイパーパラメータの設定(参考元チュートリアルのまま)
epoch = 100
batch_size = 16
num_neighbors = 5
channel = 10

#ReNomのGCNNを使用
import renom as rm
from renom.optimizer import Adam
from renom_rg.api.regression.gcnn import GraphCNN
from renom_rg.api.utility.feature_graph import get_corr_graph, get_kernel_graph, get_dbscan_graph
#インデックス行列の取得
index_matrix = get_corr_graph(X_train, num_neighbors)

#GCNNのレイヤーの定義
model = rm.Sequential([
    GraphCNN(feature_graph=index_matrix, channel=channel, neighbors=num_neighbors),
    rm.Relu(),
    rm.Flatten(),
    rm.Dense(1)
])

#最適化関数はAdam
optimizer = Adam()

#学習
train_loss_list = []
valid_loss_list = []

for e in range(epoch):
        N = X_train.shape[0]

        perm = np.random.permutation(N)
        loss = 0
        total_batch = N // batch_size

        for j in range(total_batch):
            index = perm[j * batch_size: (j + 1) * batch_size]
            train_batch_x = X_train[index].reshape(-1, 1, X_train.shape[1], 1)
            train_batch_y = y_train[index]

            # Loss function
            model.set_models(inference=False)
            with model.train():
                batch_loss = rm.mse(model(train_batch_x), train_batch_y.reshape(-1, 1))

            # Back propagation
            grad = batch_loss.grad()

            # Update
            grad.update(optimizer)
            loss += batch_loss.as_ndarray()

        train_loss = loss / (N // batch_size)
        train_loss_list.append(train_loss)

        # validation
        model.set_models(inference=True)
        N = X_test.shape[0]

        valid_predicted = model(X_test.reshape(-1, 1, X_test.shape[1], 1))
        valid_loss = float(rm.mse(valid_predicted, y_test.reshape(-1, 1)))
        valid_loss_list.append(valid_loss)

        if e % 5 == 0 and valid_loss < valid_loss_list[e - 1]:
            model.save("model1.h5")
            print('save at', e, 'epoch')

        if e % 10 == 0:
            print("epoch: {}, valid_loss: {}".format(e, valid_loss))

学習結果はこのようになりました。
スクリーンショット 2019-03-26 23.10.43.png

4.結果確認

テスト(test)用のデータ(実際の不動産価格)とGCNNが予測した不動産価格(predict)を比較します。

#予測と実際の価格の比較
test = np.exp(y_test)
predict = np.exp(valid_predicted)
plt.figure(figsize=(8, 8))
plt.plot([0, 80000], [0, 80000], c='k', alpha=0.6, label = 'diagonal line') # diagonal line
plt.scatter(test, predict)
# plt.scatter(registered_true, registered_pred,label='registered')
plt.xlim(0, 80000)
plt.ylim(0, 80000)
plt.xlabel('actual estate price', fontsize=16)
plt.ylabel('predicted estate price', fontsize=16)
plt.legend()
plt.grid()

概ね予測した不動産価格と実際の不動産価格の差異が小さいことがわかります。
スクリーンショット 2019-03-26 23.11.47.png

モデルは完成したので、全データに対して予測をしてみましょう!

#一度モデルのパラメータをリセット
for layer in model:
    setattr(layer, "params", {})    

#学習時に保存したweightを読み込み
model.load("model1.h5")

#全物件に対しGCNNで予測を行う。
log_predict = model(X.reshape(-1, 1, X.shape[1], 1))

GCNNの予測結果を確認しましょう。

#実際の価格と予測の比較
import matplotlib.pyplot as plt
predict = np.exp(log_predict)

plt.figure(figsize=(8, 8))
plt.plot([0, 80000], [0, 80000], c='k', alpha=0.6, label = 'diagonal line') # diagonal line
plt.scatter(y, predict)
plt.xlim(0, 80000)
plt.ylim(0, 80000)
plt.xlabel('actual estate price', fontsize=16)
plt.ylabel('predicted estate price', fontsize=16)
plt.legend()
plt.grid()

全データに対しての予測結果はこちらです。若干ばらつきが出てますね〜。。
スクリーンショット 2019-03-26 23.15.50.png

ここからお待ちかねのお買い得物件リストアップです!

#実際の価格に対して予測がどれだけ乖離しているか(どれだけお得かどうか)の算出
df5 = pd.DataFrame([indices, predict, y]).T
df5.columns=['Unnamed: 0', 'pred', 'actual']
df5['Unnamed: 0'] = df5['Unnamed: 0'].astype(int)
df6 = pd.merge(df5, df2, on='Unnamed: 0')
df6['diff'] = df6['pred'] - df6['actual']
df6['proportion'] = df6['diff'] / df6['actual']

#予測が実際の価格から50%以上乖離している物件
df7 = df6[abs(df6['proportion']) > 0.5].sort_values('proportion', ascending=False)
#お買い得物件TOP10
df7[0:9]

上位はこれです!predが不動産価格の予測結果,actualが実際の価格を表しています。
1番お買い得な物件は実際の価格の約6倍との結果となっています。確かに125万円は安いのでは?
スクリーンショット 2019-03-26 23.19.06.png

TOP30の結果をみてみましょう。
No.3戸越銀座とNo.4千歳船橋は上の図にも出てますが、35LDK,32LDKあることになってますね〜それは予測が4億になる。
こういう記載誤りにも気づけますね
スクリーンショット 2019-03-27 0.22.50.png

個人的に気になったNo.29の新宿の物件レグノ・セレーノを見てみましょう

似た条件(新宿区、100平米以上、3K以上、駅徒歩7分以内)で検索してみました。
こちら

スクリーンショット 2019-03-27 0.08.26.png
スクリーンショット 2019-03-27 0.08.46.png

この広さ、築年数、駅徒歩分数でこの値段は安い気がしませんか?決して買えないですが、、、

5.まとめ

スクレイピングとReNomを使うだけで、こんなに簡単にお得な物件を見つけることができました!
お金のある方はNo.29の新宿の物件レグノ・セレーノを買っちゃうのはありではないでしょうか。笑
一切責任は追えませんが、、

まだ階数や複数の最寄駅情報のようなそのほかの定量的情報は取れていないので、精度向上の余地はまだまだあります。内装の雰囲気、治安のような定性的情報も追加できれば、まさに不動産テックといえるレベルになりますね。

2019/03/30 追記
ReNomRGというGUIを使えばモデル構築はGUI操作のみでできるっぽいです!これ使えばスクレイピングしたデータをいれれば回帰モデルつくれちゃいますね。今度やってみよっと

Appendix

市区毎の平均坪単価と駅毎の坪単価平均を取得します。

市区毎の坪単価平均

こちらもSUUMOから情報を取得します。
スクリーンショット 2019-03-26 22.35.38.png

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

#東京都市区毎の坪単価情報掲載ページ
url = 'https://suumo.jp/tochi/soba/tokyo/area/'

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

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

#市区毎のデータ平均坪単価情報はid='js-graphData'から取得
#scriptタグ内にjson形式でデータが存在する
body = soup.find("script", id='js-graphData')

#取得したbodyに含まれる余分な'\r\n'を削除
body_text = body.text.replace('\r\n', '')

json形式のデータを読み込む

import json
body_json = json.loads(body_text)

#dataframeにつっこむ
landPrice = pd.DataFrame(body_json)
#itemNum(平均坪単価の値)がnullなところには0で補間
landPrice['itemNum'][landPrice['itemNum']=='']=0
#itemNumが0より大きい市区のみのデータを利用
landPrice = landPrice[landPrice['itemNum'].astype(int)>0]

#csvに出力
landPrice[['itemName','itemPrice']].to_csv('land_price.csv')

landPriceの中身はこんな感じ。
スクリーンショット 2019-03-26 22.51.19.png

駅毎の坪単価平均

こちらもSUUMOから情報を取得します。
スクリーンショット 2019-03-26 22.55.51.png
このページのJR山手線をクリックすると、
スクリーンショット 2019-03-26 22.56.04.png
のページが開き、市区毎の平均坪単価を取得する際と同じような流れで駅毎の平均坪単価を取得可能。

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

#東京都の沿線一覧
url = 'https://suumo.jp/tochi/soba/tokyo/ensen/'

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

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

body = soup.find("tbody")
link = body.find_all("a")

各沿線へのurlのリストを作成する。

urls = []
for l in link:
    urls.append(l.get('href'))

市区毎の平均坪単価を取得した際のjsonデータを取得する処理を関数化する。

def get_json(url):

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

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

    #ページ内容取得(json)
    body = soup.find("script", id='js-graphData')
    body_text = body.text.replace('\r\n', '')
    body_json = json.loads(body_text)
    stPrice = pd.DataFrame(body_json)
    return stPrice

各沿線へのページにアクセスし、上記に定義した関数を使い、jsonデータをconcatしていく

suumo = 'https://suumo.jp'
stPrices = pd.DataFrame()
for url in tqdm(urls):
    url = suumo + url
    stPrice = get_json(url)
    stPrices = pd.concat([stPrices,stPrice])

stPricesの中身はこちら
スクリーンショット 2019-03-26 23.03.45.png

#重複削除
stPrices.drop_duplicates(subset='itemName', inplace=True)

実は東京都内以外の駅も含まれているので、東京のデータのみをcsvに出力

stPrices[(stPrices['itemPrice'].isnull())&(stPrices['itemListUrl'].str.contains('tokyo'))]
stPrices[['itemName','itemPrice']].to_csv('station_price.csv')

これで市区毎の平均坪単価と駅毎の平均坪単価を取得できました。

18
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
14