1. はじめに
12月5日現在、Signateというサイトで開催されている不動産価格予測コンペティションに参加しています。コンペ序盤に取り組んだ内容を、かなり初心者向けに噛み砕いて紹介したいと思います。
↓参加しているコンペ
https://user.competition.signate.jp/ja/competition/detail/?competition=2b0105bc0c674f258e39cb2c7711e36f&leaderboard=public

そもそも「データ分析コンペ」って何?
ざっくり今回の例で言うと、「AIを使った家賃当てゲーム」 です。
主催者から「この部屋の条件(広さ、場所、築年数など)」が渡されるので、その部屋の「家賃」をAIに予測させます。
予測が正解(実際の家賃)に近ければ近いほど高得点。世界中のデータサイエンティストたちが、1円単位の精度を競い合う、データのスポーツです。
今回の私の挑戦
コンペ序盤の私のテーマは、「外部データ(地価データなど)を使わず、手持ちのデータだけでどこまで戦えるか?」 でした。
ネットで調べた地価データをAIに渡せば、簡単に精度は上がるかもしれません。でも、それでは面白くないですよね。
「今あるデータから、いかにして隠された情報を絞り出すか?」
そんな縛りプレイで挑んだ、泥臭い試行錯誤の記録です。
2. AIは意外と「融通が利かない」
AI(機械学習モデル)は計算は速いですが、常識を知らないんですよね。
例えば、データをそのまま渡しても、こんな風に困ってしまうんです。
- 人間: 「港区の築浅マンションだよ」→「それは高そうだ......!」
- AI: 「『ミナトク』という文字データですね。それが高いかどうかは知りません」
これでは良い予測はできません。
そこで私たち人間の出番です。AIが理解できるようにデータを翻訳し、ヒントを与えてあげる作業。これを 「特徴量エンジニアリング」 と呼びます。
3. やったこと①:AIに「常識」を教える
まずは基本的な「データの翻訳」から始めました。
「築年数」の罠
データには「1990年築」と書いてありますが、AIにとってはただの数字です。
しかも、不動産の世界では「築1ヶ月」と「築11ヶ月」では価値が違いますよね。
そこで、単に年を引き算するだけでなく、「月単位」で築年数を計算し直してAIに渡しました。
これだけで、AIは「お、この物件は築浅だ!」と細かく判断できるようになります。
「住所」の分解
住所データは「東京都港区六本木...」のような長い文字列です。
これをそのまま渡すと、AIは「トウキョウトミナトクロッポンギ...」という一つの長い記号として扱ってしまいます。
そこで、「都道府県」「市区町村」に分解して渡しました。
「ここは港区」「ここは世田谷区」と区別させることで、エリアごとの特徴を掴ませる作戦です。
4. やったこと②:AIに「相場観」を教える(これが最強!)
ここからが本番です。今回一番効果があったのが 「Target Encoding(ターゲットエンコーディング)」 という手法です。
「港区」=「高い」をどう教える?
AIに「港区」というラベルを渡しても、AIは「港区」と「練馬区」のどちらが高いか分かりません。
そこで、思い切ってこう教えることにしました。
- Before: 「この物件は 港区 です」
- After: 「この物件は 平均家賃20万円のエリア です」
つまり、「地名」を「その場所の平均家賃」に置き換えてAIに渡したのです。
これをやった瞬間、AIの予測精度が劇的に向上しました。
AIからすれば**「あ、ここは高いエリアの物件なんですね!了解!」**と、一発で相場観を理解できたわけです。
※注:ただし、これをやるには「カンニング(リーク)」を防ぐための慎重な計算が必要です(自分の家賃を平均計算に含めてはいけない、など)。
5. やったこと③:AIをスパルタ教育する
データの準備ができたら、学習に移ります。
今回は LightGBM(ライト・ジービーエム) という、この界隈では定番の「優秀な学習マシン」を使いました。
3人のAIに相談する(Seed Averaging)
AIも人間と同じで、学習のさせ方によって「得意・不得意」が出ます。
あるAIは「広さ」を重視しすぎるかもしれないし、別のAIは「築年数」にこだわりすぎるかもしれない。
そこで、性格の違う3つのAI(乱数シードを変えたモデル) を作り、最後に3人の答えを平均しました。
「3人寄れば文殊の知恵」っていいますよね、そういうことですw
6. 【技術編】 どう実装したのか?
ここからは、実際にコードを書くエンジニアやデータサイエンティストの方向けに、具体的な実装のポイントを解説します。
「なぜそうするのか?」という背景にあるロジックを重視して説明します。
1. Target Encodingの実装(リーク防止の極意)
Target Encodingは強力ですが、実装を間違えると「カンニング(リーク)」が発生し、手元のスコアだけ良くて本番で大失敗することになります。
なぜ単純な平均ではダメなのか?
例えば、物件A(家賃15万円)のTarget Encodingを行うとします。
もし、物件A自身を含めてそのエリアの平均家賃を計算してしまうと、「物件Aの家賃(答え)」が計算結果に混ざってしまいます。
これを特徴量としてモデルに渡すということは、「答えの一部」を教えているのと同じです。これがリークです。
正しいやり方:K-Fold Cross Validation内での計算
これを防ぐために、「自分以外のデータ」を使って平均を計算する必要があります。
具体的には、K-Foldのループの中で以下のように処理します。
- 学習データを5分割する(Fold 1〜5)。
- Fold 1 の特徴量を作る際は、Fold 2〜5 (自分以外)のデータを使って平均を計算する。
- これを全Foldで繰り返す。
def target_encoding(X_train, y_train, X_val, X_test, cols, smoothing=10):
# 学習データ全体平均(スムージング用)
global_mean = y_train.mean()
for col in cols:
# 学習データでの集計
# ここでは簡易的に書いていますが、実際はCVループ内で
# 「自分以外のFold」を使って計算したmappingを適用します。
agg = pd.DataFrame({'target': y_train, 'key': X_train[col]})
stats = agg.groupby('key')['target'].agg(['count', 'mean'])
# Smoothing: データ数が少ないカテゴリの平均値を全体平均に寄せる
# countが少ないと分母が小さくなり、smooth_meanはglobal_meanに近づく
stats['smooth_mean'] = (stats['count'] * stats['mean'] + smoothing * global_mean) / (stats['count'] + smoothing)
mapping = stats['smooth_mean'].to_dict()
# マッピング適用(未知のカテゴリは全体平均で埋める)
X_train[f'{col}_te'] = X_train[col].map(mapping).fillna(global_mean)
X_val[f'{col}_te'] = X_val[col].map(mapping).fillna(global_mean)
X_test[f'{col}_te'] = X_test[col].map(mapping).fillna(global_mean)
return X_train, X_val, X_test
2. LightGBM & Optuna(パラメータの意味)
モデルはLightGBMの gbdt を使用しました。
ハイパーパラメータはOptunaで探索しましたが、闇雲に探すのではなく、各パラメータの意味を理解して範囲を指定することが重要です。
-
num_leaves(葉の数):- モデルの「表現力(複雑さ)」を決めます。
- 値が大きいほど複雑な条件を学習できますが、過学習しやすくなります。今回は20〜300の範囲で探索しました。
-
learning_rate(学習率):- 学習の「慎重さ」です。
- 値が小さいほど丁寧に学習しますが、時間がかかります。
num_leavesとのバランスが重要です。
-
feature_fraction:- 1回の学習で使う特徴量の割合です。
- 1.0未満にすることで、毎回違う特徴量を見て学習するため、多様性が生まれ過学習を防げます。
3. Seed Averaging(3人の賢者)
「Seed Averaging」とは、乱数シード(random_state)だけを変えて複数のモデルを作り、その予測値を平均する手法です。
なぜシードで結果が変わるのか?
LightGBMなどの決定木モデルは、学習データの読み込み順や、特徴量のサンプリングに乱数を使います。
そのため、シードが違うと**「微妙に違うモデル」**が出来上がります。
- シードAのモデル:「築年数」を重視する性格
- シードBのモデル:「広さ」を重視する性格
これらを平均することで、個々のモデルの偏り(バイアス)を相殺し、大外ししない安定した予測が可能になります。
株式投資の「分散投資」と同じ考え方です。
seeds = [42, 2024, 9999]
final_preds = np.zeros(len(X_test))
for seed in seeds:
# シードを変えてモデル学習・予測
# 毎回違う「性格」のモデルが生まれる
preds = train_and_predict(..., seed=seed)
# 結果を足し合わせていく
final_preds += preds / len(seeds)
4. 外れ値除去の閾値
学習データの分布を確認し、以下の基準で外れ値を除去しました。
ただし、テストデータの分布が未知である以上、やりすぎは禁物です。
-
家賃 (
money_room): 上位0.1%を除去(極端な高級物件)。 -
面積 (
unit_area): 2000m²以上を除去(マンションの一室としては異常値)。
7. 失敗談
もちろん全てが上手くいったわけではありませんでした。
「考えすぎ」は良くない
「もっと情報を増やせば賢くなるはず!」と思い、AIに大量の追加テキスト情報(物件の備考欄など)を読ませてみました。
さらに、別の種類のAI(XGBoostなど)も混ぜてアンサンブル学習もやってみました。
結果... スコア悪化。
AIが情報を詰め込みすぎて混乱し、**「考えすぎ(過学習)」**の状態になってしまったのです。
結局、余計なものを削ぎ落とし、シンプルな構成に戻した方が良い結果が出ました。
「Simple is Best」 はデータ分析でも真理でした。
7. まとめ
最終的に、外部データを使わずに、初期の状態から 予測のズレ(RMSE)を約140万円分も縮める ことができました。
このコンペは1月までやってるので、今後は外部データをいれつつ、さらなる予測精度向上を目指していきたいです。