中古マンション価格予測コンペ 2023秋の部に参加し、public 3位、private 1位を穫ることができました!
定期的に開催される内容のため、ハイパーパラメータなどの詳細は伏せますが、振り返りと主な解法の共有をします。
どんなコンペ?
trainデータとして2022年第3四半期までの中古マンションの取引データを与えられ、testデータである2022年第4四半期~2023年第1四半期の取引価格を予測するのが課題です。
個人的に家探しをしていて不動産価格に興味があったため、今回参加することを決めました。
一見シンプルな課題ですが、案外スコアが伸びにくく苦しめられました。
また、近年のマンション価格上昇を実感させられました…。
スコアを上げるために有効だったこと
Early Sharing PrizeのNotebookを元に始めました。
(Early Sharingはコンペ初期段階から共有いただくことで参加するハードルがぐっと下がるので、非常に感謝しています。)
優れた特徴量が追加されており、またLightGBMのハイパーパラメータがかなりチューニングされており、取り組み当初から高いスコアで始められました。
Geopyから緯度・経度の取得とクラスタリング
2022年夏の部 1位の解法を参考に実施。
各地区名をキーワードに、Geopyのgeocoders.Nominatimに登録されているロケーションを検索し、その地区の緯度・経度としました。
例外処理など省略していますが、このようなコードになります。
from geopy.geocoders import Nominatim
geolocator = Nominatim(user_agent="hoge",timeout=10)
name_list = df["都道府県名"].str.cat(df["市区町村名"].str.cat(df['地区名'], sep = ","), sep = ",").unique().tolist()
lat_dict = {}
lon_dict = {}
for name in tqdm(name_list):
time.sleep(1)
location = geolocator.geocode(name)
if location is not None:
lat_dict[name] = location.latitude
lon_dict[name] = location.longitude
df["緯度"] = df["都道府県名"].str.cat(df["市区町村名"].str.cat(df['地区名'], sep = ","), sep = ",").map(lat_dict)
df["経度"] = df["都道府県名"].str.cat(df["市区町村名"].str.cat(df['地区名'], sep = ","), sep = ",").map(lon_dict)
(高頻度アクセスを避けるため、sleepを入れています。地区全てを検索する場合、数時間以上かかってしまう点に注意が必要です)
さらに、kNNを使って適当な数のクラスタに分け、そのクラスタ番号も割り振って特徴量にしました。
クラスタ数はエルボー法で目星を付け、Local CVのスコアを参考にして最適化しました。
特徴量の追加
2023夏の部 3位の解法を参考に、面積ー築年数を追加しました。
追加した特徴量の中では最も効果がありました。
また、集約特徴量の追加も検討しました。
各データに対し、「同地区における直近の取引の平均面積単価」を特徴量として追加してみました。
これは、マンションクラスタの方々が各物件を評価する際、周辺の似た物件の過去の坪単価実績を参考にしていたことに着想を得たものです。
ただし、データの切り方で効果があったりなかったり、という程度の微々たるものでした。
面積あたりの価格を予測
前述の通り、マンション価格は絶対価格よりは面積単価(坪単価)で評価されます。
2022春の部 3位の解法でも実施されていますが、面積単価を目的変数としてモデルを作り、アンサンブルに加えることが有効でした。
最終的に、価格を直接予測するモデルセットと面積単価を予測するモデルセット、それぞれ10モデルずつをCVで作成し、アンサンブルしました。
予測結果を定数倍する
実は、これが一番LBに効果がありました…。
本コンペは2022-3Qまでの取引がtrain、2022-4Qと2023-1Qの取引がtestとして与えられました。
重要な特徴量である「取引年」に対して外挿範囲を予測しなければいけない点に注意が必要となります。
trainデータから算出した、対数平均面積価格の経時変化をプロットしました。
(四半期ごとに集約してプロット)
2000年代の激しい下落にも目が行きますが、とにかく2020年以降の伸びが著しいです。
さっさと1次取得しておくべきだった
時系列モデルではないので、
2022年以前のデータを学習して作られたモデルによる予測値には、2023年の価格上昇分はバイアスとして与えてあげなければならない、という気持ちから実施しました。
publicLBとprivateLBの差が小さいことは過去のコンペで報告されていたので、publicLBを信用し、スコアが最良となるように繰り返しsubmitして補正係数を調整しました。
ちなみに、今回は+1.9%くらいが最適値でした。
うまくいかなかったこと・やり残したこと
新しいデータの重み付け
2023年の取引に対する予測結果が評価に使われるので、取引タイミングが新しいデータほどweightを高くするべきだ、と考えました。
2021年以降の取引のWeightを高くしたり、指数重み付けをしたりと試しましたが、自分のケースでは効果を見いだせませんでした。
ただし、重み付けのメソッドは数多くある中、十分試せないまま不完全燃焼に終わってしまいました。反省点です。
Catboost
過去の上位解法ではCatboostもよく使われていたのですが・・・。
アンサンブルに加えたいと思って導入しましたが、LightGBMに及びませんでした。
遺伝的プログラミングによる特徴量生成
一度やってみたかったのでちょっと試してみた、という程度です。
手法自体は非常に強力なのですが、如何せん計算時間がかかり使いこなせませんでした。これは自分の問題なので非常に悔しいです。
u++さんの記事で説明されているように、ロジスティック回帰など計算時間の短いモデルを使って遺伝的プログラミングを実施し、得た特徴量をLightGBMの学習に加える、という手順が有効と思われます。
ただ、LightGBM以外のモデルを使う場合、ラベルエンコーディングや欠損値補完をどう対応するかは、今回のような多様性の高いテーブルに対してはちょっと考えるが必要あるなと思います。
さいごに:Weights & Biasesが便利
基本的な使い方は説明するまでもないですが、特にLightGBMの場合、
import wandb
from wandb.lightgbm import wandb_callback, log_summary
os.environ["WANDB_MODE"] = "online"
os.environ['WANDB_API_KEY'] = 'YOUR_API_KEY'
#中略
wandb.init(project="Nishika-exp")
model = lgbm.train(params=params,
#中略
callbacks=[wandb_callback()]
log_summary(model, save_model_checkpoint=False)
wandb.finish()
を加えるだけでサクッと導入できます。(参考:チュートリアル)
対数表示・拡大表示のおかげでLossの減り方を比較しやすいです。
1回の学習で2~3時間かかっていたのですが、リアルタイムで挙動を詳しく比較できたおかげでハズレの実験をさっさと切る判断ができ、効率的に試行錯誤を重ねることができました。
どうでもいいですがスマホからも確認できるので、「ながら実験」が捗ります。
log_summaryによりFeature Importanceの記録もできます。
本記事では、中古マンション価格予測コンペの振り返りと、効率的な取り組み方を紹介しました。
純粋なテーブルコンペの経験は初めてに近い状態だったのですが、(前処理の余地やサイズ等の意味で)取り扱いやすいデータセットで、かつ様々な切り口から実験を考えることができたので、良い経験になり、楽しかったです。
また、本記事が今後のコンペに挑戦する方の参考になればとても嬉しいです。
最後までお読みいただき、ありがとうございました。