中古車購入に向けてのデータ分析をしてみたかった【python, scraping】
中古車を対象としたなんちゃってデータ分析をしてみた。以下の本を読んでなんか作ってみたくなったので。n番煎じなのは気にしない。
Pythonクローリング&スクレイピング -データ収集・解析のための実践開発ガイド-
- 作者: 加藤耕太
- 出版社/メーカー: 技術評論社
- 発売日: 2016/12/16
- メディア: 大型本
- この商品を含むブログ (3件) を見る
動機
若者の車離れが叫ばれて(?)久しいが、自分としては昔からマイカーは憧れの象徴なので車が欲しい。 当然お金はないので中古車を買うことにはなるが、中古車は様々な要素から価格が決まってくる。
年式、走行距離
修復歴あり or なし
カーナビなどオプションの有無
値段に強く影響しそうなのはこの辺な気がする。車の素人が購入を判断するための材料が欲しいので、色々データをいじってみる次第である。 まずはwebサイトから各種情報をとってくるところから始める。
スクレイピング
ざっくり流れ
requests
でHTTP接続し、 lxml.html
でパースする。今回はログインなどはせず、ステートレスなクロールを行うので特に複雑な処理は発生しない。取得したデータはpymongo
でMongoDBに保存する。
MongoDB初めて使ったけど便利だなこれ。
カーセンサーのページを対象とする。カーセンサーは特定の条件で検索すると、その条件に合致した中古車の一覧ページが生成され、その一覧から気になった車をクリックすると その車の詳細ページを閲覧できる。なので「一覧ページを取得 -> 詳細ページのhtml取得 -> スクレイピング」という手順となる。
ソースコード
コードは以下。
動作環境は
- 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
で簡単な分析ごっこをしてみた。後述。
可視化してみる
カーセンサーに掲載されている中で数が最も多い普通車がプリウスっぽかったので、プリウスについて調べた。
年式 - 本体価格
まずは年式と本体価格の関係を散布図で見てみる。
横軸のmodel_year
は年式、縦軸のbase_price
は車両本体価格(単位:万円)となっている。赤い線とプロットは各年代の中央値を示している。まあ当然っちゃ当然だが、年式が新しいほうが価格が高い。そしてパッと見、分布の重心は価格の低い方に位置しており、高価な車両はまばらに分布していることがわかる。これはカスタムすればするだけ車は高くなるので、正規分布より若干ロングテール気味になるからであると思われる。
上の散布図を見ると、2015年から2016年で価格が大きく上がっているように見える。確認のために本体価格のヒストグラムをプロットしてみる。
これを見ると明らかに山が二つあり、年式の違いで価格に大きな隔たりがあることが確認できる。もうちょい詳しく見たいのでプロットする年式を絞ってみる。
やはり2015年と2016年の分布に隔たりがある。中央値だと50~60万円ほど変わってくるか。実はプリウスは2015年12月にフルモデルチェンジされており、その影響で旧モデルとの価格差が顕著になっている。 ちなみにその前のフルモデルチェンジは2009年5月であり、2008 ~ 2009年でも価格が見受けられる(ただし価格差は直近のモデルチェンジよりは控えめ)。要は旧モデルは露骨に安くなることがわかったので、特にこだわりがなければモデルチェンジ後の車両なら比較的安く手に入れられる。逆にモデルチェンジが発表された車種はちょっと購入を遅らせてみてもよいのかもしれない。
走行距離 - 本体価格
次に走行距離を見てみる。ネットで中古車購入の手引き的なページを見ると、
走行距離〇万km(3万, 5万とか)を超えると値段がかくっと下がる
といった記述が散見される。4.9万kmと5.1万kmでは心理的なハードルが違ってくるかららしい。まあ何となくそんな気もしなくはない。
確かめるために、横軸に走行距離distance
(単位:万km)、縦軸に本体価格base_price
をとり、年式ごとにプロットする。
色が赤に近づくほど年式が新しくなる。年式は新しいほど価格が上がり、走行距離が伸びると価格が下がるといったことが読み取れる。まあ当たり前だけど。
色が重なりすぎて見づらいので2015年以降でプロット
最新の年式は試走等の目的で使われた後中古に流れたいわゆる新古車が多く、ほぼ0kmのものが多い。カスタム内容を選ばなければ意外と安く最新モデルが手に入りそうだ。 そして前述の通り16年と15年には価格の隔たりがある。16年は6万kmを超えたあたりから価格の下落が大きくなっているような気がする。
ついでに2011年 ~ 2015年もプロットする。
この辺の年式は結構値段が似通ってるので、分布ががっつり重なってくる。11年は若干安いくらいか。こうしてみると、どうせ値段同じくらいなんだし14年の車買ったほうがお得なんじゃないかと思えてくる (他の条件を無視しているので実際はわからん)。 そして肝心の、「〇万km超えると安くなる説」はこの図からはほぼ読み取れない。実際全く同じ色、グレード、カスタムの車種を比べると気持ち〇万km超えのほうが安くなるのかもしれないが、 中古市場において全く同条件の車両が出回ることはほぼあり得ないので、走行距離の〇万kmはほぼ意識しなくてよいと結論付けることにした。そこにこだわるくらいなら別のとここだわったほうが良い。
分析あれこれ
なんかデータ分析っぽいことがしてみたかったので、やってみた。
特徴選択
車両の価格に影響しそうな情報として、オプションがどの程度ついているかという情報を取得した。
走行距離、年式に加えて上記のオプション情報を使用して、車両価格を推定する。これらは全て二値のダミー変数として扱う。 カーナビは種類が複数あり(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、・・・の結果となる。
ここでは特徴の数が多いほうが精度が少し上がっているように見える。線形回帰においては多重共線性のある特徴が含まれていると精度が下がるはずなので、
- 評価指標に問題があった
- 特徴同士の相関が低かった
が原因かと思った。変数が増えるとモデルの近似がうまくいってなくても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万円の価格差
修復歴ありはやめといたほうがいい
こうしてみると案外当たり前のことしか発見できてなかったりする。。でも色んな記事を見てると、データ分析をしても新たな知見が得られないことは往々にしてあることらしく、だからこそ本職のデータアナリストの方々は 最初の目的(何を明らかにしたいのか)をしっかり決めるんだろうなあ、と素人ながらに思った。
大変だけど面白そうな仕事で憧れます。おわり。