PythonにWEBスクレイピングをやらせてCPUやGPUのコストパフォーマンスをなるべく簡単に比較する

# 動機
自作PCの構成を考えていると，予算の中で最も性能のいいパソコンは何なのかをよく考えるけど，その情報は意外と少ない．多くのサイトでは，ベンチマークスコアの一覧，価格の一覧の一方はまとめらているが，それを同時に見ながらどのコスパがいい・悪いを比較できる情報先はあまりないように思う．これはおそらく，価格変動が激しいのでコストパフォーマンスの変動も大きいことが理由なんだと思う．

そんな中，価格コムなどでの価格調査をpythonによるWEBスクレイピングにやらせる[記事](https://happy-shibusawake.com/web-scraping1/559/)を発見，これは使えそうということで，WEBスクレイピングの勉強も兼ねて実装してみた．

# 目的
なるべくリアルタイムの市場状況（価格・在庫）を反映したCPU・GPUのコストパフォーマンスを，PythonのWEBスクレイピングを使って調べる．

# 開発環境
実装環境は以下の通り．
- MacBook Pro 14 (M1 Pro)
- Python 3.9.19

# 必要なライブラリ
全てpipで入る．
- requests (HTTP通信で情報をとりに行く)
- bs4 (http通信で取得したhtmlデータをわかりやすく処理する)
- pandas (取得したデータを格納してハンドリングしやすくするため)
- plotly (取得したデータをベースにグラフを描く)

# 基本的な設計指針
基本的にやることは，以下の3つ．
1. ベンチマークスコア掲載サイトからベンチマークスコアの一覧を取得する
1. 価格コムから価格の一覧を取得する
1. 価格とベンチマークパフォーマンスの関係を保存する

スクレイピングでは，なるべく取得先のサーバーに負荷をかけないように心がけ，上記の3つの操作は個別のプロセスとして行うようにしつつ，各プロセスの中でも負担を下げるようにする（十分でなければ教えてください）．

# コードの中身の要点
基本的な構造はCPUとGPUで違いはないので，CPUの方で話を進める．

## CPUのベンチマークスコア一覧取得
CPUのベンチマークスコアの取得先はPassmarkにした．個人的には体感的な性能に最も近いかなと．
あくまで大体の性能と価格のバランスを見ることが目的なので正直何でもいいけど．

https://www.cpubenchmark.net/high_end_cpus.html

ページの中身の取得は数行で済む．

```python
import requests
from bs4 import BeautifulSoup

### original passmark data page url
html = 'https://www.cpubenchmark.net/high_end_cpus.html'

### retrieve html text information
req = requests.get(html)
req.encoding = req.apparent_encoding

### convert to bueatiful soup object
bsObj = BeautifulSoup(req.text, "html.parser")
```
BeautifulSoupによって生のHTMLデータを扱いやすくしている．
あとは取得したいデータを検索していく．個人的にHTMLは詳しくないが，このくらいシンプルなスクレイピングなら基本的な階層構造とタグの名前さえわかれば何とかなると思う．

このサイトは表として掲載しているので，表の1行を表すliオブジェクトを探し，その中に製品名として保存されるprdnameを探すようにする．
CPUのサイトは若干厄介で，製品名が2回登場するので，ベンチマークスコアのある箇所だけ選択的に製品名を取得するようにフィルターをかけつつデータを取得する．

```python
### obtain list elements
lists = bsObj.find_all('li')

### filter the elements above for product entry
element = [l for l in lists if 'prdname' in str(l)]

### get product and score lists
products = [e.find("span", {"class": "prdname"}).get_text() for e in element if 'mark-neww' in str(e)]
scores = [int(e.find("span", {"class": "mark-neww"}).get_text().replace(',','')) for e in element if 'mark-neww' in str(e)]
```

あとはDataframeとして格納しつつCSVファイルにローカル一時保存する．
途中，製品名などはのちの価格と照らし合わせやすいように微調整しておく．

```python
### creating pandas dataframe for saving
df = pd.DataFrame({'CPU name':products, 'Passmark score': scores})

df['Chip'] = df['CPU name'].str.replace('-', ' ')
df['Chip'] = [n.split('@')[0].strip() for n in df['Chip']]

df.to_csv(output_csv)
```
## CPUの価格調査
価格コムのスクレイピングについては正直私の記事より遥にわかりやすいサイトがありそれを丸々利用しただけなのでそちらを参照いただければと思う．

https://happy-shibusawake.com/web-scraping1/559/

価格コム上の価格データも同様にCSVとしてローカルに保存しておく．のちのグラフのため，製造メーカー情報も格納しておく．

```python
df = pd.DataFrame({
    'Product': list_product,
    'Chip'   : list_chip,
    'Vender' : list_vender,
    'Socket' : list_socket,
    'Price'  : list_price
})
df['Vender'] = df['Vender'].str.strip()
df['Vender'] = df['Vender'].str.replace('インテル', 'Intel')
df['Product'] = df['Product'].str.replace('バルク', 'BULK')
df.sort_values(['Vender', 'Socket', 'Price'], ascending=False, inplace=True, ignore_index=True)
# print(df)
df.to_csv(csv_database)
```

## CPUの価格VS性能の可視化
これで必要な情報は揃ったのであとはグラフにするだけである．
まずはローカルに保存したデータを再度読み込み．_prが価格，_pmがベンチマークデータを格納したデータフレームである．

```python
### read saved csv data
df_pm = pd.read_csv(csv_passmark)
df_pr = pd.read_csv(csv_price)
```

ここでは，取得した価格コムの製品リストに対してループをしてそのパスマークスコアが存在するかを確認しに行き，あればそのデータを新たなリスト（のちのデータフレーム化する）として保存している．

```python
# のちにDataFrameにするためのリストを用意
cpu_list   = []
price_list = []
score_list = []
vender_list= []

# 価格コム上の製品リストに対してループ
for item in df_pr['Chip']:
    # 製品の名前を取得
    cpu_name = item.split('(')[0] 
    # 上記の製品がベンチマークのデータフレーム内に見つかれば，
    # 価格，ベンチマークスコア，製造メーカーを取得して上記のリストに格納
    if cpu_name in df_pm['Chip'].tolist():
        price = df_pr[df_pr['Chip'] == item]['Price'].values[0]
        score = df_pm[df_pm['Chip'] == cpu_name]['Passmark score'].values[0]
        vender = df_pr[df_pr['Chip'] == item]['Vender'].values[0]

        cpu_list.append(cpu_name)
        price_list.append(price)
        score_list.append(score)
        vender_list.append(vender)
    # 見つからなければスキップしつつベンチマークスコアのデータがないことを忠告
    else:
        print('{0} is not in Passmark data'.format(cpu_name))
```

あとはこの結果をもとにグラフにするだけ．グラフにはインタラクティブな描画が可能なPlotlyを使う．

https://plotly.com/python-api-reference/index.html

```python
# 上記のリストをもとにデータフレームを作成
df = pd.DataFrame({
    'CPU': cpu_list, 
    'Price':price_list, 
    'Passmark score':score_list, 
    'Vender': vender_list
})

# データフレームの整形（なくてもいい）
df['Value performance'] = df['Passmark score'] / df['Price']
df.sort_values(
    ['Vender', 'Passmark score'], 
    ascending=False, 
    ignore_index=True, 
    inplace=True
)

# データフレームには以下の設定により直接plotlyで描画する機能があるらしい．
pd.options.plotting.backend = 'plotly'
fig = df.plot.scatter(
    x='Price',           # 横軸：価格
    y='Passmark score',  # 縦軸：ベンチマークスコア
    color='Vender',      # 製造メーカーごとに色分け
    hover_data='CPU'     # マウスオンで製品名を表示
)
fig.show()

# htmlファイルとしてグラフを保存
html_output_prefix = 'CPU_value_performance'
fig.write_html(
    '{0}_{1}.html'.format(html_output_prefix, update_time_pr.strftime('%Y-%m-%d'))
)
```
## GPUの価格VS性能調査
基本的な流れは同じだが，GPUの場合，同一チップを使って複数のメーカーが複数の製品を作っており，異なる価格帯のほぼ同じ性能の製品が多数存在する．そこで，グラフ描画のエラーバーを利用して価格のばらつきも描画することを考える．

```python
### read saved csv data
df_pm = pd.read_csv(csv_passmark, index_col=0)
df_pr = pd.read_csv(csv_price, index_col=0)

### collect gpu chip list without duplication
# 価格コムの製品リストからGPUチップの名前を取得して被りなくGPUチップの一覧を取得する．
gpu_chip_list = []
for chip in df_pr['Chip']:
    if chip not in gpu_chip_list:
        gpu_chip_list.append(chip)
### collect gpu price: minimum, maximum and average
# 上記で得たチップのリストに対して，該当チップを載せた最安製品と最高値製品を探し，その値を辞書式に格納しておく．
gpu_price_info = []
for chip in gpu_chip_list:
    df_gpu = df_pr[df_pr['Chip'] == chip]
    gpu_price_info.append(
            {'Min': df_gpu['Price'].min(),
             'Ave': df_gpu['Price'].mean(),
             'Max': df_gpu['Price'].max()}
    )
# この段階で，
# gpu_chip_list  : GPUのチップのリスト
# gpu_price_info : 上記のリストに応じた最安価格，平均価格，最高価格
# の情報が手に入る
```

あとはのデータをもとに各GPUチップのベンチマークスコアを参照する．
エラーのデータは，プロットの基準点からプラス側にいくら，マイナス側にいくらというデータを与えるので，平均値との差分を用意しておく．

```python
gpu_plots   = []
score_plots = []
price_min   = []
price_ave   = []
price_max   = []
venders     = []

# 価格コム上の製品リストをもとにループを回す
for chip, prices in zip(gpu_chip_list, gpu_price_info):
    ### if benchmark data is available, store data for plots
    # ベンチマークデータの中に特定のチップのデータがあれば取得してくる
    if chip in df_pm['GPU name'].to_list():
        score = df_pm[df_pm['GPU name'] == chip]['3DMark score'].values[0]
        # print(chip, score)

        gpu_plots.append(chip)                           # GPUチップ名
        score_plots.append(score)                        # ベンチマークスコア
        price_min.append(prices['Ave'] - prices['Min'])  # 平均価格と最安価格の差（エラーバー表示用） 
        price_ave.append(prices['Ave'])                  # 平均価格
        price_max.append(prices['Max'] - prices['Ave'])  # 最高価格と平均価格の差
        venders.append(chip.split()[0])                  # GPUチップの製造メーカー
```

同じようにグラフ化．

```python
### create dataframe for plot and save
df = pd.DataFrame({
    'GPU': gpu_plots,
    'Scores': score_plots,
    'Mean price': price_ave,
    'Eminus_price': price_min,
    'Eplus_price': price_max,
    'Vender' : venders
})

### save dataframe for later reference
df.sort_values(['Vender', 'Scores'], ascending=False, ignore_index=True, inplace=True)
df.to_csv(csv_output)

### plot dataframe: price versus benchmark score
fig = df.plot.scatter(
    x='Mean price',              # 横軸：平均価格
    y='Scores',                  # 縦軸：ベンチマークスコア
    color='Vender',              # GPUチップのメーカーごとに色分け
    color_discrete_map = {'Intel': '#1E90FF', 'AMD': '#FF6347', 'NVIDIA': '#228B22'}, # 色分けの定義，メーカーのイメージカラーに近づける
    hover_data='GPU',            # マウスオンでGPUのチップ名を表示
    error_x='Eplus_price',       # 最高価格をエラーバーのプラス側で表現
    error_x_minus='Eminus_price' # 最低価格をエラーバーのマイナス側で表現
)
fig.show()
fig.write_html(
    '{0}_{1}.html'.format(html_output_prefix, update_time_pr.strftime('%Y-%m-%d'))
)
```

# 例

得られたグラフとその操作例をGIFにした．左上に行くほどコスパがいい．

## CPUデータの例
![CPU_VP_sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/515814/77bb8613-d343-a07d-951f-887c3c489589.gif)

## GPUデータの例
![GPU_VP_sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/515814/d0942ced-25b6-d6c0-bf01-5d73026f0c47.gif)

# 感想
俗に言われるこのモデルはコスパ最強，と言われるモデルが左上に来る様子が可視化されてよかったのと，AMDのGPUがゲーム用途のコスパがいいと言われるのも可視化されたかなと思います．

# 参考文献
- 価格コムのスクレイピングについては丸々こちらを参考にしました．

https://happy-shibusawake.com/web-scraping1/559/

- Beautiful Soupの使い方についてはこちらを参考にしました．

https://dev.classmethod.jp/articles/parse-my-article-using-beautiful-soup/

- スクレイピングをしたCPUベンチマーク，Passmarkの参照先です．

https://www.cpubenchmark.net/high_end_cpus.html

- スクレイピングをしたGPUベンチマーク，3DMarkの参照先です．

https://benchmarks.ul.com/compare/best-gpus?amount=0&sortBy=SCORE&reverseOrder=true&types=DESKTOP&minRating=0

# ソースコード配布先
MITライセンスです．

https://github.com/takakiba/pc-parts-evaluater