備忘録とか日常とか

学んだこととかを書きます。

中古車購入に向けてのデータ分析をしてみたかった【python, scraping】

中古車を対象としたなんちゃってデータ分析をしてみた。以下の本を読んでなんか作ってみたくなったので。n番煎じなのは気にしない。

本のレビューもそのうち書くかもしれない。

動機

若者の車離れが叫ばれて(?)久しいが、自分としては昔からマイカーは憧れの象徴なので車が欲しい。 当然お金はないので中古車を買うことにはなるが、中古車は様々な要素から価格が決まってくる。

  • 年式、走行距離

  • 修復歴あり or なし

  • カーナビなどオプションの有無

値段に強く影響しそうなのはこの辺な気がする。車の素人が購入を判断するための材料が欲しいので、色々データをいじってみる次第である。 まずはwebサイトから各種情報をとってくるところから始める。

スクレイピング

ざっくり流れ

requestsでHTTP接続し、 lxml.htmlでパースする。今回はログインなどはせず、ステートレスなクロールを行うので特に複雑な処理は発生しない。取得したデータはpymongoでMongoDBに保存する。 MongoDB初めて使ったけど便利だなこれ。

カーセンサーのページを対象とする。カーセンサーは特定の条件で検索すると、その条件に合致した中古車の一覧ページが生成され、その一覧から気になった車をクリックすると その車の詳細ページを閲覧できる。なので「一覧ページを取得 -> 詳細ページのhtml取得 -> スクレイピング」という手順となる。

ソースコード

コードは以下。

github.com

動作環境は

  • MongoDB v4.0.1
  • pymongo 3.7.1
  • scikit-learn 0.20.0
  • statsmodel 0.9.0

ぐらいあれば動くと思う。

car_scrape.pyスクレイピング実行してMongoDBにデータ保存するスクリプト。実行時にコマンドライン引数で車種を指定する。とりあえずPRIUS, VEZEL, FITに対応しているが、URLを追加すれば他の車種も取得可能。 車一台につき固有のIDを割り当てて車両情報と一緒にDBに保存し、次にスクリプトを実行するときはIDが未登録の車両のみをDBに追加することができる。新たに追加された中古車を登録するのに便利な設計(ただし 車両価格更新には対応できない...)。オプション引数に--reset_dbを指定すると、MongoDBのcollectionを削除して一からスクレイピングを行う。

中身はえらいごちゃごちゃしてしまったが、CSS selectorとスクレイプのとこが冗長になってしまっただけなので、処理自体は単純。scrape_list_page()で一覧ページを読み込み、その中でscrape_detail_page()を順次呼び出して 詳細ページのhtmlを取得する。また今回は車種だけ指定し、その他の条件(グレード等)は特に指定せずにスクレイピングする形をとった。

visualize.ipynbでは取得したデータの可視化を行い、analyze.ipynbで簡単な分析ごっこをしてみた。後述。

可視化してみる

カーセンサーに掲載されている中で数が最も多い普通車がプリウスっぽかったので、プリウスについて調べた。

年式 - 本体価格

まずは年式と本体価格の関係を散布図で見てみる。 f:id:may46onez:20181106080957p:plain

横軸のmodel_yearは年式、縦軸のbase_priceは車両本体価格(単位:万円)となっている。赤い線とプロットは各年代の中央値を示している。まあ当然っちゃ当然だが、年式が新しいほうが価格が高い。そしてパッと見、分布の重心は価格の低い方に位置しており、高価な車両はまばらに分布していることがわかる。これはカスタムすればするだけ車は高くなるので、正規分布より若干ロングテール気味になるからであると思われる。

上の散布図を見ると、2015年から2016年で価格が大きく上がっているように見える。確認のために本体価格のヒストグラムをプロットしてみる。 f:id:may46onez:20181107080511p:plain

これを見ると明らかに山が二つあり、年式の違いで価格に大きな隔たりがあることが確認できる。もうちょい詳しく見たいのでプロットする年式を絞ってみる。 f:id:may46onez:20181107081302p:plain

やはり2015年と2016年の分布に隔たりがある。中央値だと50~60万円ほど変わってくるか。実はプリウスは2015年12月にフルモデルチェンジされており、その影響で旧モデルとの価格差が顕著になっている。 ちなみにその前のフルモデルチェンジは2009年5月であり、2008 ~ 2009年でも価格が見受けられる(ただし価格差は直近のモデルチェンジよりは控えめ)。要は旧モデルは露骨に安くなることがわかったので、特にこだわりがなければモデルチェンジ後の車両なら比較的安く手に入れられる。逆にモデルチェンジが発表された車種はちょっと購入を遅らせてみてもよいのかもしれない。

走行距離 - 本体価格

次に走行距離を見てみる。ネットで中古車購入の手引き的なページを見ると、

走行距離〇万km(3万, 5万とか)を超えると値段がかくっと下がる

といった記述が散見される。4.9万kmと5.1万kmでは心理的なハードルが違ってくるかららしい。まあ何となくそんな気もしなくはない。

確かめるために、横軸に走行距離distance(単位:万km)、縦軸に本体価格base_priceをとり、年式ごとにプロットする。

f:id:may46onez:20181111214842p:plain

色が赤に近づくほど年式が新しくなる。年式は新しいほど価格が上がり、走行距離が伸びると価格が下がるといったことが読み取れる。まあ当たり前だけど。

色が重なりすぎて見づらいので2015年以降でプロット f:id:may46onez:20181111215728p:plain

最新の年式は試走等の目的で使われた後中古に流れたいわゆる新古車が多く、ほぼ0kmのものが多い。カスタム内容を選ばなければ意外と安く最新モデルが手に入りそうだ。 そして前述の通り16年と15年には価格の隔たりがある。16年は6万kmを超えたあたりから価格の下落が大きくなっているような気がする。

ついでに2011年 ~ 2015年もプロットする。

f:id:may46onez:20181111221915p:plain

この辺の年式は結構値段が似通ってるので、分布ががっつり重なってくる。11年は若干安いくらいか。こうしてみると、どうせ値段同じくらいなんだし14年の車買ったほうがお得なんじゃないかと思えてくる (他の条件を無視しているので実際はわからん)。 そして肝心の、「〇万km超えると安くなる説」はこの図からはほぼ読み取れない。実際全く同じ色、グレード、カスタムの車種を比べると気持ち〇万km超えのほうが安くなるのかもしれないが、 中古市場において全く同条件の車両が出回ることはほぼあり得ないので、走行距離の〇万kmはほぼ意識しなくてよいと結論付けることにした。そこにこだわるくらいなら別のとここだわったほうが良い。

分析あれこれ

なんかデータ分析っぽいことがしてみたかったので、やってみた。

特徴選択

車両の価格に影響しそうな情報として、オプションがどの程度ついているかという情報を取得した。

f:id:may46onez:20181127224312p:plain
サンプルのオプション例

走行距離、年式に加えて上記のオプション情報を使用して、車両価格を推定する。これらは全て二値のダミー変数として扱う。 カーナビは種類が複数あり(CD, DVD, HDD, メモリナビ)、面倒なのでHDD、メモリナビは1, それ以外は0とする。同じ理由でヘッドランプもディスチャージドランプとLEDランプはまとめて1とする。オーディオはカーナビと 一体となっている車が多いので使用せず、他にも3列シートとかウォークスルーとか車種に依存しそうな情報は抜きにした。あとは禁煙車などを加味して、結果的に全部で22項目を使用することになった。 走行距離、年式は平均0, 分散1になるように正規化する。

まずはどの特徴が価格に大きく寄与しているのかを調べる。 特徴選択の方法は種々あって、重回帰分析でよく利用されるのは変数増減法とからしい。scikit-learnにもいくつか用意されている(指定した評価指標で上位k個を選択するSelectKBest, 変数減少法のRFE, モデルから算出される 重要度や係数を使用するSelectFromModel)。

今回はscikit-learnのRandomForestRegressorを使用し、feature_importance_を 算出して重要度が大きい順に特徴を選択してみる(それぞれの特徴が相対的に見てどれくらい重要度が高いかをみたかったので、ここではSelectFromModelは使用しなかった)。 RandomForestはブートストラップサンプルに対して決定木を学習させるアンサンブル手法で、out-of-bag誤り率を算出することで特徴の重要度を図ることができる 手法である。このサイトとかはじパタとか読めばわかりやすいと思う。

以下analyze.ipynbのコードの一部を抜粋
念の為データをtest と trainに分けて、trainデータのみで特徴選択をしている。

rf = RandomForestRegressor(n_estimators=200, random_state=50)
rf.fit(x, y)

fi = rf.feature_importances_
result = []
for i, label in zip(fi, x_data_df.columns):
    result.append((i, label))
    
result.sort(reverse=True)
v = []
for i, label in result:
    print("{0:15}  {1:0.6}".format(label, i))
    v.append(label)

出力は以下

model_year       0.836925
distance         0.0789052
lowdown          0.0168161
aero             0.00976041
repare           0.00806706
cruise           0.00589656
auto_brake       0.00579045
record_book      0.0051595
sheat_heater     0.00469167
camera           0.00400534
ETC              0.00370045
anti_theft       0.00357377
navi             0.00314469
AS_sensor        0.00273345
ESC              0.00260764
parking_assist   0.00197162
cold_area        0.00177578
smartkey         0.00158297
alumi_wheel      0.00135012
keyless          0.000664804
ABS              0.000528251
sheat_air        0.000165102
around_camera    0.000112198
liftup           7.20092e-05

重要度降順結果。年式、走行距離に続いてローダウン、フルエアロ、修復歴、クルーズコントロール、衝突被害軽減ブレーキ・・・と続いていくことがわかる。 ローダウンとフルエアロあたりが装備されている車は前オーナーが車好きである可能性が高く、そういう車はほかにもいっぱいオプションがついてたりするので価格が高くなる傾向にあるのかもしれない(未確認)。 その次が修復歴なので、やっぱり事故車は価格にかなり影響するのだなあと実感。

次に、適当にモデルを構築してみる。重要度上位10個の特徴を使用した場合と全て使用した場合について、三つのモデル(線形回帰、RandomForest, SVR)でcross validationしてみた。

以下コード抜粋

# cross validationで各モデルを学習・評価
# 評価指標には adjusted r2
# linear regression, random forest, svrを使用

x2 = x_data_df.loc[:, v[:10]].values
linear_reg = linear_model.LinearRegression()
rf = RandomForestRegressor(n_estimators=200, random_state=50)
svr = SVR(gamma='scale')
num_y = y.shape[0] // 10

lr_scores_sub = cross_val_score(linear_reg, x2, y, cv=10, scoring='r2')
rf_scores_sub = cross_val_score(rf, x2, y, cv=10, scoring='r2')
svr_scores_sub = cross_val_score(svr, x2, y, cv=10, scoring='r2')
lr_scores_all = cross_val_score(linear_reg, x, y, cv=10, scoring='r2')
rf_scores_all = cross_val_score(rf, x, y, cv=10, scoring='r2')
svr_scores_all = cross_val_score(svr, x, y, cv=10, scoring='r2')

lr_adr2_sub = adjusted_r2(lr_scores_sub, num_y, x2.shape[1])
rf_adr2_sub = adjusted_r2(rf_scores_sub, num_y, x2.shape[1])
svr_adr2_sub = adjusted_r2(svr_scores_sub, num_y, x2.shape[1])
lr_adr2_all = adjusted_r2(lr_scores_all, num_y, x.shape[1])
rf_adr2_all = adjusted_r2(rf_scores_all, num_y, x.shape[1])
svr_adr2_all = adjusted_r2(svr_scores_all, num_y, x.shape[1])

for a in [lr_adr2_sub, rf_adr2_sub, svr_adr2_sub, lr_adr2_all, rf_adr2_all, svr_adr2_all]:
    print('mean:{0:.5}    std:{1:.5}'.format(np.mean(a), np.std(a)))

相変わらず見づらいクソコードである。結果は以下

mean:0.87579    std:0.0078976
mean:0.90187    std:0.0049071
mean:0.89518    std:0.0054207
mean:0.87769    std:0.0073689
mean:0.90777    std:0.0063641
mean:0.89033    std:0.0057512

10 foldのcross validationで、平均と標準偏差を算出した。上三つが特徴量10個、下三つが全特徴使用。上から順に線形回帰、RandomForest、SVR、・・・の結果となる。

ここでは特徴の数が多いほうが精度が少し上がっているように見える。線形回帰においては多重共線性のある特徴が含まれていると精度が下がるはずなので、

  1. 評価指標に問題があった
  2. 特徴同士の相関が低かった

が原因かと思った。変数が増えるとモデルの近似がうまくいってなくてもR2は勝手に増加するそうで、それを調整したのがadjusted R2なのだが、なにか使い方に問題があったのかも知れない… 。 まあお遊びなのでそこまで厳密にする必要はないのだけれども。

特徴同士の相関が低い、とは思えない。なぜならスマートキーがついてるのにキーレスじゃないとかありえないし、衝突被害軽減ブレーキがついてたら障害物センサーもついてるんじゃないの?と思うから。 というか大半がダミー変数の場合の回帰ってうまくいくのだろうか。本職の人はどう判断するのか気になります。

あと特徴選択するなら各fold毎に重要度を求めてやらないと意味ないのだが、ここはめんどくさくてさぼってしまった。これをちゃんとしたら案外うまくいくのかもしれない。

statsmodelによる分析

statsmodelなるライブラリを使うと回帰分析のsummaryがすぐ見れちゃうとのことなので使ってみた。 先ほど選択した特徴量を入力として全データに対して回帰分析してみる。

以下コード抜粋

x_sm_sub = sm.add_constant(x_data_df.loc[:, v[:10]])
model = sm.OLS(y, x_sm_sub) 
results = model.fit()
print(results.summary())

結果

                            OLS Regression Results                            
==============================================================================
Dep. Variable:                      y   R-squared:                       0.878
Model:                            OLS   Adj. R-squared:                  0.878
Method:                 Least Squares   F-statistic:                     5791.
Date:                Mon, 26 Nov 2018   Prob (F-statistic):               0.00
Time:                        23:46:59   Log-Likelihood:                -36688.
No. Observations:                8070   AIC:                         7.340e+04
Df Residuals:                    8059   BIC:                         7.348e+04
Df Model:                          10                                         
Covariance Type:            nonrobust                                         
================================================================================
                   coef    std err          t      P>|t|      [0.025      0.975]
--------------------------------------------------------------------------------
const          121.1046      0.553    219.193      0.000     120.022     122.188
model_year      40.0663      0.351    114.053      0.000      39.378      40.755
distance       -17.3005      0.326    -53.136      0.000     -17.939     -16.662
lowdown         22.0338      1.130     19.507      0.000      19.820      24.248
aero            16.5364      1.067     15.504      0.000      14.446      18.627
repare         -14.6507      0.832    -17.604      0.000     -16.282     -13.019
cruise          10.7129      0.690     15.536      0.000       9.361      12.065
auto_brake      14.4923      0.990     14.633      0.000      12.551      16.434
record_book     -4.2196      0.534     -7.905      0.000      -5.266      -3.173
sheat_heater    24.9746      1.023     24.423      0.000      22.970      26.979
camera           1.2611      0.593      2.126      0.034       0.098       2.424
==============================================================================
Omnibus:                     1373.771   Durbin-Watson:                   1.990
Prob(Omnibus):                  0.000   Jarque-Bera (JB):             4903.483
Skew:                           0.835   Prob(JB):                         0.00
Kurtosis:                       6.434   Cond. No.                         7.58
==============================================================================

Warnings:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.

いっぱい数字が出てきて焦る。coefとかはわかるとして、横に続くのはt値、p値、95%信頼区間か。上段はR2, adjusted R2, F値、対数尤度なんかも出してくれている。この辺をすべて理解して使いこなすには もう少し統計の勉強が必要である。statsmodelsの公式ドキュメントはこちら

これを見ると、まあ年式が一番値段に影響してて、次いでヒートシーター、ローダウン、衝突被害軽減ブレーキ、クルーズコントロールか。走行距離と修復歴にはしっかり負の相関が確認できる。 修復歴があるのとないのとでは平均で14.6万円程変わってくるが、これを高いとみるか安いとみるか。修復歴ありの車は骨格やシャーシにダメージがあるほどの事故があった車であり、そういった車はドアの開閉や 直進性など何かと問題が出てくることが多い(某知恵袋から得た知識)。軽微な事故車もあるらしいが、素人には到底見分けはつかないので十数万円ケチって命を乗せる車を買うくらいならおとなしく年式を下げたほうが良い。 まあ調べたらどのサイトでも同じことを言ってるけど、具体的な数字が出せたのはちょっとうれしい。

ただこの結果の中で、定期点検記録簿の有無に負の相関がある理由はわからない。ダミー変数だらけで、車両価格を近似するのに説明変数(というか情報量)が足りなかったとか?迷宮入りだ… 。

まとめ

  • フルモデルチェンジ後の旧モデルは価格が大幅に下落
    プリウスの場合は中央値で約50万円ほど変わる
  • 「〇万km超えたら値段が下がる!」は嘘
  • 修復歴ありとなしでは平均で約15万円の価格差
    修復歴ありはやめといたほうがいい

こうしてみると案外当たり前のことしか発見できてなかったりする。。でも色んな記事を見てると、データ分析をしても新たな知見が得られないことは往々にしてあることらしく、だからこそ本職のデータアナリストの方々は 最初の目的(何を明らかにしたいのか)をしっかり決めるんだろうなあ、と素人ながらに思った。

大変だけど面白そうな仕事で憧れます。おわり。