【Python】Webスクレイピングからデータ分析までの流れ

こんにちは、@0yanです。
今日はデータ分析の実践練習で、Webスクレイピング～データ分析をやってみました。
いずれ中古マンション購入したいなと思っているので、題材は気になる路線の中古マンション情報としました。


# 環境
- Windows 10 Pro
- Python 3.7.3（Anaconda）
- Jupyter Notebook


# やったこと
1. Webスクレイピング
2. CSV書き込み/読み込み
3. データ前処理
4. 分析


## 1. Webスクレイピング
使用したパッケージは以下のとおりです。

### 使用したパッケージ

```python
import datetime
import re
import time
from urllib.parse import urljoin

from bs4 import BeautifulSoup
import pandas as pd
import requests
```

### スクレイピングの流れ
私がスクレイピングしたページは

+ 物件データはすべてddタグに入っている
+ 1物件あたり8個のデータをもつ（物件名、販売価格、所在地、沿線・駅、専有面積、間取り、バルコニー、築年月）
+ 1ページあたり30物件のデータをもつ（8データ/物件 × 30物件/ページ = 120データ/ページ）
+ ページのTOPとBOTTOMには、ddタグでナビゲーションメニューのデータあり

という構成でした。最終的に、

```python
[{'物件名': spam, '販売価格': spam, '所在地': spam, '沿線・駅': spam,
  '専有面積': spam, '間取り': spam, 'バルコニー': spam, '築年月': spam},
 {'物件名': spam, '販売価格': spam, '所在地': spam, '沿線・駅': spam,
  '専有面積': spam, '間取り': spam, 'バルコニー': spam, '築年月': spam},
 ･･･,
 {'物件名': spam, '販売価格': spam, '所在地': spam, '沿線・駅': spam,
  '専有面積': spam, '間取り': spam, 'バルコニー': spam, '築年月': spam}]
```

のように辞書型の物件情報が要素のリストを作成し、pandas.DataFrameに渡したいので、以下の手順（コメント参照）でスクレイピングしました。

```python
# 物件情報を入れる変数（辞書型）と、それを入れる変数（リスト）の初期化
property_dict = {}
properties_list = []


# 「property_dict」を生成する際に必要なキーのリスト「key_list」を生成
key_list = ['物件名', '販売価格', '所在地', '沿線・駅', '専有面積', '間取り', 'バルコニー', '築年月']
key_list *= 30  # 30物件/ページ


# 1ページ目のBeautifulSoupインスタンス生成
first_page_url = '某不動産サイト中古マンション情報検索結果（1ページ目）URL'
res = requests.get(first_page_url) 
soup = BeautifulSoup(res.text, 'html.parser')


# 93ページ分繰り返し（一度きりなので最大ページ数の取得は省略）
for page in range(93):
    # 物件データが入っているddタグのリスト「dd_list」を生成
    dd_list = [re.sub('[\n\r\t\xa0]', '', x.get_text()) for x in soup.select('dd')]  # 余計な改行等は除外
    dd_list = dd_list[8:]  # 余計なページTOPのデータを除外

    # 辞書型の物件データが要素のリスト「properties_list」を生成
    zipped = zip(key_list, dd_list)
    for i, z in enumerate(zipped, start=1):
        if i % 8 == 0:
            properties_list.append(property_dict)
            property_dict = {}
        else:
            property_dict.update({z[0]: z[1]})
        
    # 次ページのURL（ベース部分以降）を取得
    next_page = soup.select('p.pagination-parts>a')[-1]
    
    # 次ページのBeautifulSoupインスタンスを生成
    base_url = 'https://xxx.co.jp/'  # 某不動産サイトのURL
    dynamic_url = urljoin(base_url, next_page.get("href"))
    time.sleep(3)
    res = requests.get(dynamic_url)
    soup = BeautifulSoup(res.text, 'html.parser')
```

最後に、物件リスト`properties_list`を`pandas.DataFrame`に渡し、DataFrameを生成しました。

```python
df = pd.DataFrame(properties_list)
```


## 2. CSV書き込み/読み込み
毎回スクレイピングするのは面倒かつサイト側に負荷がかかってしまうので、CSVに一旦書き込んだのち、それを読み込んで使うことにしました。

```python
csv_file = f'{datetime.date.today()}_中古マンション購入情報.csv'
df.to_csv(csv_file, encoding='cp932', index=False)
df = pd.read_csv(csv_file, encoding='cp932')
```


## 3. データ前処理
「データ分析は前処理に8割の時間がかかる」という言葉をよく聞きますが、「これのことか･･･」と実感しました。
以下の前処理を行いました。

+ 販売価格：「1億2000万円※権利金含む」のようになっている → intに変換
+ 専有面積：「54.66m2※壁芯含む」のようになっていたり、「-」が混ざっている → floatに変換
+ バルコニー：同上
+ 沿線・駅から「路線」「最寄り駅」「徒歩」のカラムを作成
+ 所在地から「都道府県」「市区町村」のカラムを作成
+ フィルタリングにより必要なカラムのみのDataFrameに

```python
import re

# 販売価格に金額以外が入っているレコードを除外
df = df[df['販売価格'].str.match('[0-9]*[万]円') | df['販売価格'].str.match('[0-9]億円') | df['販売価格'].str.match('[0-9]*億[0-9]*万円')]

# 販売価格[万円]を追加
price = df['販売価格'].apply(lambda x: x.replace('※権利金含む', ''))
price = price.apply(lambda x: re.sub('([0-9]*)億([0-9]*)万円', r'\1\2', x))  # 1憶2000万円 → 12000
price = price.apply(lambda x: re.sub('([0-9]*)億円', r'\10000', x))  # 1億円 → 10000
price = price.apply(lambda x: re.sub('([0-9]*)万円', r'\1', x))  # 9000万円 → 9000
price = price.apply(lambda x: x.replace('@00', '0'))  # 考慮できない → 0に変換
price = price.apply(lambda x: x.replace('21900～31800', '0'))  # 同上
df['販売価格[万円]'] = price.astype('int')
df = df[df['販売価格[万円]'] > 0]  # 0のレコード除外

# 専有面積[m2]を追加
df['専有面積[m2]'] = df['専有面積'].apply(lambda x: re.sub('(.*)m2.*', r'\1', x))
df['専有面積[m2]'] = df['専有面積[m2]'].apply(lambda x: re.sub('-', '0', x)).astype('float')  # 考慮できない → 0に変換
df = df[df['専有面積[m2]'] > 0]  # 0のレコード除外

# バルコニー[m2]を追加
df['バルコニー[m2]'] = df['バルコニー'].apply(lambda x: re.sub('(.*)m2.*', r'\1', x))
df['バルコニー[m2]'] = df['バルコニー[m2]'].apply(lambda x: re.sub('-', '0', x)).astype('float')  # 考慮できない → 0に変換
df = df[df['バルコニー[m2]'] > 0]  # 0のレコード除外

# 路線を追加
df['路線'] = df['沿線・駅'].apply(lambda x: re.sub('(.*線).*', r'\1', x, count=5))

# 最寄り駅を追加
df['最寄り駅'] = df['沿線・駅'].apply(lambda x: re.sub('.*「(.*)」.*', r'\1', x, count=5))

# 徒歩[分]を追加
df['徒歩[分]'] = df['沿線・駅'].apply(lambda x: re.sub('.*歩([0-9]*)分.*', r'\1', x)).astype('int')

# 都道府県を追加
df['都道府県'] = df['所在地'].apply(lambda x: re.sub('(.*?[都道府県]).*', r'\1', x))

# 市区町村を追加
df['市区町村'] = df['所在地'].apply(lambda x: re.sub('.*[都道府県](.*?[市区町村]).*', r'\1', x))

# 必要なカラムのdfに上書き
df = df[['物件名', '販売価格[万円]', '路線', '最寄り駅', '徒歩[分]',
         '間取り', '専有面積[m2]', 'バルコニー[m2]',
         '都道府県', '市区町村', '所在地']]
```


## 4. 分析
住みたいと思っている路線全体と、その中でも特に気になっている駅（3駅）の2LDK以上を分析してみました。

### 路線全体
#### フィルタリング
```python：
route = ['A線', 'B線', 'C線', 'D線', 'E線', 'F線']
floor_plan = ['2LDK', '2LDK+S（納戸）',
              '3DK', '3DK+S（納戸）', '3LDK', '3LDK+S（納戸）', '3LDK+2S（納戸）',
              '4DK', '4DK+S（納戸）', '4LDK', '4LDK+S（納戸）']
filtered = df[df['路線'].isin(route) & df['間取り'].isin(floor_plan)]
```

#### 相関分析
```python
import matplotlib.pyplot as plt
import seaborn as sns
%matplotlib inline

sns.pairplot(filtered)
plt.show()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/d61d228b-9703-6d00-2d98-83bd123d1fb1.png)

販売価格と専有面積には若干の相関はありそうですが、それ以外は販売価格にそこまで影響しなさそうです。


#### 販売価格の分布
```python
import matplotlib.pyplot as plt
import seaborn as sns
%matplotlib inline

sns.boxplot(x='販売価格[万円]', data=filtered)
plt.show()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/0163df6d-cf13-ba7c-66f4-fd07e3af64dc.png)

路線全体の物件のうち、50%は4,000～7,500万円付近に固まっていました。
都内はやっぱり高いな･･･。


#### 統計量
```python
filtered.describe()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/40638452-f0c0-fcff-8751-9d54fed0d998.png)

+ 中央値（50%）は68.99m2で5400万円
+ 25～75%の50%は3980～7500万円

路線全体では以上のとおりでした。


### 路線の中でも特に気になっている駅（3駅）
ここからは気になっている駅（3駅）を調べました。

```python
station = ['A駅', 'B駅', 'C駅']
grouped = filtered[filtered['最寄り駅'].isin(station)]
```


#### 販売価格の分布
3つの駅の販売価格の分布を調べました。

```python
import matplotlib.pyplot as plt
import seaborn as sns
%matplotlib inline

sns.violinplot(x='販売価格[万円]', y='最寄り駅', data=grouped)
plt.show()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/31595fea-7938-8817-12a5-47ebd4f6b024.png)

一番気になっているのは一番下（緑）ですが、双峰型の分布となっておりました。恐らく、タワマンとそれ以外で二極化しているものと思われます。


#### 統計量
```python
grouped.describe()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/04747509-afbb-067a-709b-cd524b1eecb2.png)

+ 物件数は100件
+ 中央値（50%）は専有面積64.26m2で6830万円。路線全体の68.99m2/5400万円と比較すると割高
+ 25～75%の50%は5299～8087万円。路線全体の3980～7500万円と比較すると全体的に高額


### 一番気になっている駅
一番気になっている駅で5000万円未満の物件ないかな･･･ということで、更に分析してみました。


#### 間取り毎の分析
##### 物件数
```python
c = filtered[filtered['最寄り駅'] == 'C駅']
c.groupby(by='間取り')['間取り'].count()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/9b6a882e-9fb9-a72c-a4ee-5a89d546b357.png)


##### 販売価格
```python
import matplotlib.pyplot as plt
import seaborn as sns
%matplotlib inline

sns.swarmplot(x='販売価格[万円]', y='間取り', data=c)
plt.show()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/32ef5efb-39f7-9d96-7581-18a4a74bb5eb.png)

5,000万円未満は7件しかありませんでした･･･。


#### 5,000万円未満の物件
5,000万円未満の物件を更に調べてみました。

```python
c_u5k = c[c['販売価格[万円]'] < 5000]
c_u5k = c_u5k[['物件名', '間取り', '専有面積[m2]', 'バルコニー[m2]',
               '販売価格[万円]', '所在地', '徒歩[分]']].sort_values(['物件名', '間取り'])
c_u5k
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/03a00fa2-39c8-5658-a5fa-ed821b3b32ea.png)

7件だけなので、Googleマップで所在地を調べたところ、気になったのが1471番。
3LDK、69.5㎡で4,280万円とこの駅付近では割安に見えますが、そもそも気になっている3駅の3LDKの相場からすると本当に割安なのか？
調べてみました。

```python
grouped[grouped['間取り'] == '3LDK'].describe()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/b8b65390-798f-3319-f0ae-1b93a0e9afd8.png)

結果、

+ 中央値は73.7m2/7180万円
+ 1471番は69.5m2なので比較するなら25%の値
+ 25%は69.8m2/6607万円 → 差は -0.3m2/-2327万円

と相当割安でした。
なんでだろうと調べてみたら、1985年建造とかなり古い物件であることが発覚。
内装写真がなく「リフォームできます！」と書かれていることから、相当老朽化が進んでいることが推測できます。

が、仮にリノベーションで1,000万円かけたとしてもまだ割安です。
お金があったら欲しかったな･･･と思う今日この頃。


# 感想
Webスクレイピング初めてでしたが、今後、必要になる可能性が高いので非常に有益な実践練習でした。
「好きこそものの上手なれ」といいますが、やっぱり興味あるものを分析する等、やりたいことをやるのが一番の上達の早道だなと改めて感じた一日でした。


# データ分析を学んだサイト
[かめ@米国データサイエンティスト](https://twitter.com/usdatascientist)さんのサイト「[データサイエンスのためのPython入門講座](https://datawokagaku.com/python_for_ds_summary/)」で勉強させて頂きました。
重要なポイントが分かり易くまとめられております。
オススメです。


# 参考文献
Webスクレイピングを行うにあたり、以下の記事から勉強させて頂きました。

@tomson784 さんの記事
[Pythonでページ遷移を繰り返しながらスクレイピング](https://qiita.com/tomson784/items/88a3fd2398a41932762a)

@Chanmoro さんの記事
[10分で理解する Beautiful Soup](https://qiita.com/Chanmoro/items/db51658b073acddea4ac)

ありがとうございました！