【Nishika】中古マンション価格予測コンペでLightGBM + Optunaによる高精度モデルを構築してみた

# はじめに

不動産価格は、経済や都市計画の影響を強く受けるため、予測の難易度が高い分野です。今回、Nishikaが主催する「中古マンション価格予測コンペ（2024年秋）」に参加し、実務にも近いデータを用いて分析と予測モデルの構築を行いました。
本記事では、データの構造把握、前処理、特徴量エンジニアリング、LightGBMによるモデル構築、パラメータ調整、最終的な考察までを1本にまとめています。


# 解決したい課題

中古マンションの価格は、立地や面積、築年数など多様な要素に左右されます。
そのため、予測には次のような複合的な課題があります：
- 数値・カテゴリ・テキスト型の混在するデータの前処理設計
- 価格変動に寄与する本質的な変数の特定
- モデルの過学習を防ぎつつ、精度を高めるアルゴリズム選定


# 実行環境

- OS：Windows11
- 開発環境：Google Colab
- 言語：Python 3.11.12
- ライブラリ：Pandas、NumPy、Matplotlib、Seaborn、Scikit-learn、LightGBM、Optuna


# 分析するデータ
分析に使用するのは、Nishikaが提供する中古マンションの取引データです。
以下のような特徴があります：
- サンプル数：約847,189件
- 目的変数：取引価格（総額）_log
- 説明変数：面積（㎡）、間取り、築年数、駅距離、駅名、市区町村名など

データにはカテゴリ変数、数値、異常値、欠損などが混在しており、建模前の整備が必要です。


# 分析の流れ
- 環境構築とライブラリインストール
- データの読み込みと構造の確認
- 特徴量選定とデータ整理
- 特徴量エンジニアリング
- 欠損・異常値の補正
- データ加工後の確認
- Optunaでのパラメータ調整
- LightGBMによる回帰モデルの構築
- モデル可視化
- 交差検証による汎化性能向上 
- スコア/評価(MAE)

#  環境構築とライブラリインストール
本分析ではGoogle Colabを使用し、開発環境のクラウド化によって再現性とチームでの共有性を高めました。
Google Driveをマウントすることで、外部ファイルや保存済みモデルへのアクセスを可能にし、実務での資産管理にも近い形を再現しています。
また、optunaとjapanize-matplotlibを導入することで、チューニングと可視化を支援し、分析効率を向上させました。

### 実行したコード
```Python
from google.colab import drive    
drive.mount('/content/drive')
!pip install optuna japanize-matplotlib > /dev/null    
YOUR_PATH = "/content/drive/MyDrive/cpt-house-2024au"
cd {YOUR_PATH}
```
```Python
import re 
import japanize_matplotlib
import lightgbm as lgb
import matplotlib.pyplot as plt
import numpy as np
import optuna
import pandas as pd
import seaborn as sns
sns.set(font="IPAexGothic")

from glob import glob
from itertools import product
from sklearn.metrics import mean_absolute_error
from sklearn.model_selection import KFold
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import FunctionTransformer, LabelEncoder
```

# データの読み込みと構造の確認
本コンペでは、訓練データが複数ファイルに分割されており、それらを統合して分析対象とする必要があります。再現性と柔軟なデータ管理を実現するために、globモジュールを使って訓練データを一括で読み込み、結合しています。また、テストデータは別ファイルとして読み込み、分析・予測の過程で明確に役割を分けています。初期段階では、データ構造や欠損値の有無を確認し、今後の前処理・モデリング方針の基礎とします。

### 実行したコード
```Python
BASE_PATH = './data/' 

paths = glob(BASE_PATH + 'input/train/*')    
train_dfs = []
for path in paths:
    train_df = pd.read_csv(path)
    train_dfs.append(train_df)
train_df = pd.concat(train_dfs)
train_df.reset_index(drop=True, inplace=True)
test_df = pd.read_csv(BASE_PATH + 'input/test.csv')
```
```Python
train_df.head()
```
### 実行結果
![スクリーンショット 2025-06-07 202322.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4102026/eba4eeda-4422-4c00-89b3-897356e7ec3b.png)
### 実行したコード
```Python
test_df.head()
```
### 実行結果
![スクリーンショット 2025-06-07 202430.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4102026/2195b0dd-d03b-4499-8f58-e95edb484ad2.png)
### 実行したコード
```Python
train_df.isnull().sum() 
```
### 実行結果
```Python
ID	0
種類	0
地域	847189
市区町村コード	0
都道府県名	0
市区町村名	0
地区名	661
最寄駅：名称	2877
最寄駅：距離（分）	23303
間取り	33559
面積（㎡）	0
土地の形状	847189
間口	847189
延床面積（㎡）	847189
建築年	24863
建物の構造	25752
用途	101766
今後の利用目的	370502
前面道路：方位	847189
前面道路：種類	847189
前面道路：幅員（ｍ）	847189
都市計画	21737
建ぺい率（％）	26724
容積率（％）	26724
取引時点	0
改装	87499
取引の事情等	827336
取引価格（総額）_log	0

dtype: int64
```

# 特徴量選定とデータ整理

欠損が多い列（非欠損数が1以下の列や『取引の事情等』など）を事前に除外したうえで、取引価格の地域性と時系列的な変動を捉えるため、各取引時点より前のデータに限定して都道府県ごとの平均取引価格（log）を算出し、その値を用いたターゲットエンコーディング特徴量（都道府県名_te）を作成することで、訓練データは検証データの過去のデータを参照するようにしました

### 実行したコード
```Python
sub_df = pd.read_csv(BASE_PATH + 'input/sample_submission.csv')  

ID = 'ID' 
TARGET = '取引価格（総額）_log'
```
```Python
rm_cols = [] 
df = pd.concat([train_df, test_df])
rm_cols += ['取引の事情等']   
rm_cols += df.columns[df.notna().sum() <= 1].tolist()    
rm_cols
```
### 実行結果
```Python
['取引の事情等', '地域', '土地の形状', '間口', '延床面積（㎡）', '前面道路：方位', '前面道路：種類', '前面道路：幅員（ｍ）']
 ```
### 実行したコード
```Python
test_df[TARGET] = np.nan 
train_df.drop(rm_cols, axis=1, inplace=True) 
test_df.drop(rm_cols, axis=1, inplace=True) 
df = pd.concat([train_df, test_df]) 
df.sort_values('取引時点', inplace=True)
df.reset_index(drop=True, inplace=True) 
df.shape
```
### 実行結果
```Python
(866579, 20)
```
### 実行したコード
```Python
df['取引時点'].unique()
```
### 実行結果
```Python
array(['2005年第3四半期', '2005年第4四半期', '2006年第1四半期', '2006年第2四半期',
       '2006年第3四半期', '2006年第4四半期', '2007年第1四半期', '2007年第2四半期',
       '2007年第3四半期', '2007年第4四半期', '2008年第1四半期', '2008年第2四半期',
       '2008年第3四半期', '2008年第4四半期', '2009年第1四半期', '2009年第2四半期',
       '2009年第3四半期', '2009年第4四半期', '2010年第1四半期', '2010年第2四半期',
       '2010年第3四半期', '2010年第4四半期', '2011年第1四半期', '2011年第2四半期',
       '2011年第3四半期', '2011年第4四半期', '2012年第1四半期', '2012年第2四半期',
       '2012年第3四半期', '2012年第4四半期', '2013年第1四半期', '2013年第2四半期',
       '2013年第3四半期', '2013年第4四半期', '2014年第1四半期', '2014年第2四半期',
       '2014年第3四半期', '2014年第4四半期', '2015年第1四半期', '2015年第2四半期',
       '2015年第3四半期', '2015年第4四半期', '2016年第1四半期', '2016年第2四半期',
       '2016年第3四半期', '2016年第4四半期', '2017年第1四半期', '2017年第2四半期',
       '2017年第3四半期', '2017年第4四半期', '2018年第1四半期', '2018年第2四半期',
       '2018年第3四半期', '2018年第4四半期', '2019年第1四半期', '2019年第2四半期',
       '2019年第3四半期', '2019年第4四半期', '2020年第1四半期', '2020年第2四半期',
       '2020年第3四半期', '2020年第4四半期', '2021年第1四半期', '2021年第2四半期',
       '2021年第3四半期', '2021年第4四半期', '2022年第1四半期', '2022年第2四半期',
       '2022年第3四半期', '2022年第4四半期', '2023年第1四半期', '2023年第2四半期',
       '2023年第3四半期', '2023年第4四半期', '2024年第1四半期'], dtype=object)
```
### 実行したコード
```Python
val_min_idx = min(df[df['取引時点'].str.contains('2023年第2四半期|2023年第3四半期', regex=True)].index)    
test_min_idx = min(df[df['取引時点'].str.contains('2023年第4四半期|2024年第1四半期', regex=True)].index)    
val_min_idx, test_min_idx
```
### 実行結果
```Python
(820591, 847189)
```
### 実行したコード
```Python
set(df.iloc[val_min_idx:test_min_idx, :]['取引時点'].values)
```
### 実行結果
```Python
{'2023年第2四半期', '2023年第3四半期'}
```
### 実行したコード
```Python
enc_dic = {} 
for i, e in enumerate(sorted(list(set(df['取引時点'].values)))):
    enc_dic[e] = i
df['取引時点_enc'] = df['取引時点'].map(enc_dic)

te_dic = {} 
time_col = '取引時点_enc'
group_col = '都道府県名'

for i in set(df[time_col].values):
    tmp_df = df[df[time_col] < i]
    te_dic[i] = tmp_df.groupby(group_col)[TARGET].agg('mean').to_dict()
te_dic[50]
```
### 実行結果
```Python
{'三重県': 7.099989772836439,
 '京都府': 7.155935848555253,
 '佐賀県': 7.009937258263509,
 '兵庫県': 7.157742870483196,
 '北海道': 6.965967793677859,
 '千葉県': 7.155825324964995,
 '和歌山県': 7.022690210815773,
 '埼玉県': 7.1697442713966355,
 '大分県': 6.911537826573079,
 '大阪府': 7.158352973821201,
 '奈良県': 7.056884610234688,
 '宮城県': 7.0395442538048805,
 '宮崎県': 6.958678653711235,
 '富山県': 7.0469200713870155,
 '山口県': 7.045342730349803,
 '山形県': 7.087990602814902,
 '山梨県': 6.848839888686708,
 '岐阜県': 7.078095913486453,
 '岡山県': 7.03809073492747,
 '岩手県': 6.925002679060599,
 '島根県': 7.165376214700312,
 '広島県': 7.093449021655064,
 '徳島県': 6.912051981629745,
 '愛媛県': 6.966433011596662,
 '愛知県': 7.107301969238601,
 '新潟県': 6.913835156235044,
 '東京都': 7.377929204641064,
 '栃木県': 6.950636958071011,
 '沖縄県': 7.160046604428336,
 '滋賀県': 7.16602695816839,
 '熊本県': 6.970182251843263,
 '石川県': 6.929363348877185,
 '神奈川県': 7.2581182672098175,
 '福井県': 6.979575815840195,
 '福岡県': 6.992264905042226,
 '福島県': 6.9743805791401785,
 '秋田県': 6.94131374711264,
 '群馬県': 6.928069391036607,
 '茨城県': 7.075528163782812,
 '長崎県': 7.150784996001117,
 '長野県': 7.063121700154463,
 '青森県': 6.9556107539806185,
 '静岡県': 7.0170037911973155,
 '香川県': 6.903679020975967,
 '高知県': 6.98595825905006,
 '鳥取県': 6.940192902720598,
 '鹿児島県': 7.095567581694406}
```
### 実行したコード
```Python
def calc_te(row):
    if row[time_col] in te_dic and row[group_col] in te_dic[row[time_col]]:
        return te_dic[row[time_col]][row[group_col]]
    else:
        return 0

df[group_col+'_te'] = df.apply(calc_te, axis=1)
df.head(2)
```
### 実行結果
![スクリーンショット 2025-06-07 220231.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4102026/399ab808-9419-411f-b034-c38188dd358d.png)


# 特徴量エンジニアリング
特徴量選定とデータ整理を実務に即した形で進めました。まず、学習に不要な列を除去しています。具体的には、「取引の事情等」のような実務では利用できないリーク情報と、欠損値がほとんどの列（「地域」「土地の形状」「間口」など）を削除しました。次に、訓練データとテストデータを結合し、取引時点の昇順で並べ替えたうえでインデックスを振り直しています。これにより、時系列の情報を損なわずにデータを扱えるようにしています。

時系列を考慮したモデル検証のため、2023年第2四半期と第3四半期をバリデーション期間、2023年第4四半期以降をテスト期間としてインデックスで分割しました。これらは取引時点の文字列をフィルターし、実際の分割位置を明確に特定しています。

さらに、取引時点を数値に変換し、都道府県別に過去の取引価格の平均を計算してターゲットエンコーディングを実施しました。これは、ある時点の取引価格の傾向を地域ごとに捉え、取引時点ごとに動的に特徴量として付加する手法です。この処理により、都道府県名ごとの時系列に沿った価格の平均が新たな特徴量「都道府県名_te」として追加され、モデルの学習に利用しています。


### 実行したコード
```Python
def convert_year(year_str):    
    if isinstance(year_str, str):
        if '戦前' in year_str:
            return 1945
        year_str = year_str.replace('年', '')
        if year_str.isdigit():
            return int(year_str)
    return np.nan  
```
```Python
def normalize_moyori(moyori):
    if moyori == moyori:
        if moyori in ('30分?60分',"30分～60分"):
            moyori = 45
        elif moyori in ('1H?1H30','1H～1H30'):
            moyori = 75
        elif moyori in ('1H30?2H','1H30～2H'):
            moyori = 105
        elif moyori in ('2H?','2H～'):
            moyori = 120
        moyori = int(float(moyori))
    return moyori
```
```Python
def normalize_area(area): 
    if pd.isna(area):
        return area  
    if isinstance(area, (int, float)): 
        return area
    area_str = str(area) 
    area_num = re.sub(r'[^\d]', '', area_str) 
    if area_num == '': 
        return pd.NA
    return int(area_num)
```
```Python
lastest_year = max(df['取引時点'].map(lambda x: int(x[:4])))
df['取引時点_何年前'] = df['取引時点'].apply(lambda x: lastest_year - int(x[:4]))
df.drop(['取引時点'], axis=1, inplace=True)
df['建築年'] = df['建築年'].apply(lambda x: convert_year(x))
df['面積（㎡）'] = df['面積（㎡）'].apply(lambda x: normalize_area(x)) 
df['最寄駅：距離（分）'] = df['最寄駅：距離（分）'].apply(lambda x: normalize_moyori(x)) 
```
```Python
def select_categorical(df: pd.DataFrame):
    categorical_columns = df.select_dtypes(include=['object', 'category']).columns
    return df[categorical_columns].copy()

def select_numerical(df: pd.DataFrame):
    numerical_columns = df.select_dtypes(include=['int', 'float']).columns
    return df[numerical_columns].copy()

def encode_labels(df: pd.DataFrame):
    df = df.copy()  
    df = df.astype(str) 
    df = df.fillna("Missing") 

    for col in df.columns:
        le = LabelEncoder()
        df[col] = le.fit_transform(df[col])

    return df

def combinate_yoseki(df: pd.DataFrame):
    df["面積（㎡）容積率（％）_combi"] = df["面積（㎡）"] * df["容積率（％）"]
    return df[["面積（㎡）容積率（％）_combi"]].copy()
```
```Python
df['築年数_階層'] = pd.cut(df['築年数'], bins=[-1, 5, 10, np.inf], labels=['0-5年', '6-10年', '11年以上'])

df['駅距離_面積_比率'] = df['最寄駅：距離（分）'] / (df['面積（㎡）'] + 1e-5)
df['駅距離_面積_積'] = df['最寄駅：距離（分）'] * df['面積（㎡）']

grp = df.groupby('市区町村コード')['取引価格（総額）_log']
df['地域別_価格平均'] = df['市区町村コード'].map(grp.mean())
df['地域別_価格中央値'] = df['市区町村コード'].map(grp.median())
df['地域別_価格分散'] = df['市区町村コード'].map(grp.var())

df['取引年月'] = pd.to_datetime(df['取引時点_enc'], errors='coerce') 
df['取引月'] = df['取引年月'].dt.month
df['取引年'] = df['取引年月'].dt.year
```
### 実行したコード
```Python
num_df = FunctionTransformer(select_numerical).fit_transform(df)   
num_df.head(2)
```
### 実行結果
![スクリーンショット 2025-06-07 220514.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4102026/13f4402d-2a1a-4f2a-b972-87d055cdcce8.png)

### 実行したコード
```Python
encoder = make_pipeline(FunctionTransformer(select_categorical),FunctionTransformer(encode_labels),)
le_df = encoder.fit_transform(df) 
le_df.head(2)
```
### 実行結果
![スクリーンショット 2025-06-07 210852.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4102026/1af64760-5a2e-4a32-bf30-2f693635a18a.png)
### 実行したコード
```Python
encoder = make_pipeline(FunctionTransformer(select_numerical),FunctionTransformer(combinate_yoseki),)
num_comb_df = encoder.fit_transform(df)/100 
num_comb_df.head(2)
```
### 実行結果
![スクリーンショット 2025-06-07 211043.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4102026/df1b43a8-ee60-4d92-9314-eda0f703dbfc.png)
### 実行したコード
```Python
agg_dfs = []

def get_agg_df(df, group_col):
    agg_cols = ["最寄駅：距離（分）", "面積（㎡）"]
    cols = [group_col] + agg_cols
    agg_df = df[cols].copy()
    functions = ["count", "mean"]
    for col, function in product(agg_cols, functions):
        col_name = f"{col}_{function}"
        agg_df[col_name] = agg_df.groupby(group_col)[col].transform(function)
    return agg_df.drop(cols, axis = 1)

group_col = '市区町村名'
agg_df = get_agg_df(df, group_col)   
agg_df.head(2)
```
### 実行結果
![スクリーンショット 2025-06-07 211203.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4102026/da907142-dc59-4974-bcab-97a602963e90.png)

### 実行したコード
```Python
feat_df = pd.concat([num_df,le_df,num_comb_df, agg_df], axis=1)   
print(feat_df.shape)
```
### 実行結果
```Python
(866579, 35)
```
### 実行したコード
```Python
feat_df.head()
```
### 実行結果
![スクリーンショット 2025-06-07 220622.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4102026/190a24ed-a8a4-4e12-9c58-8827df022169.png)


# 欠損・異常値の補正

欠損値・異常値の補正では、まず各特徴量の欠損数を確認しました。築年数については、市区町村名と取引時点でグループ化し中央値で補完し、それでも欠損が残る場合は市区町村単位の中央値、さらに全体の中央値で段階的に埋めています。

同様に、「最寄駅：距離（分）」「建ぺい率（％）」「容積率（％）」「駅距離_面積_比率」「駅距離_面積_積」についても築年数と市区町村名の組み合わせでグループ化し中央値で欠損補完を行い、補完しきれない部分は全体の中央値で埋めています。

また、面積と容積率の積を新たに計算し特徴量として追加し、「最寄駅：距離（分）_mean」や「地域別_価格分散」もそれぞれ中央値で欠損補完を施しました。これにより、補完後のデータフレームでは、目的変数の一部を除き、ほぼ全ての特徴量で欠損値が解消されています。

### 実行したコード
```Python
feat_df.isnull().sum()
```
### 実行結果
```Python
ID	0
市区町村コード	0
最寄駅：距離（分）	23323
面積（㎡）	0
建ぺい率（％）	27072
容積率（％）	27072
取引価格（総額）_log	19390
取引時点_enc	0
都道府県名_te	0
取引時点_何年前	0
築年数	25399
駅距離_面積_比率	23323
駅距離_面積_積	23323
地域別_価格平均	0
地域別_価格中央値	0
地域別_価格分散	9
取引月	0
取引年	0
種類	0
都道府県名	0
市区町村名	0
地区名	0
最寄駅：名称	0
間取り	0
建物の構造	0
用途	0
今後の利用目的	0
都市計画	0
改装	0
築年数_階層	0
面積（㎡）容積率（％）_combi	27072
最寄駅：距離（分）_count	0
最寄駅：距離（分）_mean	285
面積（㎡）_count	0
面積（㎡）_mean	0
```
### 実行したコード
```Python
feat_df['築年数'] = feat_df.groupby(['市区町村名', '取引時点_enc'])['築年数']\
    .transform(lambda x: x.fillna(x.median()))

feat_df['築年数'] = feat_df['築年数'].fillna(feat_df.groupby('市区町村名')['築年数'].transform('median'))
feat_df['築年数'] = feat_df['築年数'].fillna(feat_df['築年数'].median())

group_cols = ['築年数', '市区町村名']
for col in ['最寄駅：距離（分）', '建ぺい率（％）', '容積率（％）','駅距離_面積_比率','駅距離_面積_積']:
    feat_df[col] = feat_df.groupby(group_cols)[col].transform(lambda x: x.fillna(x.median()))
    feat_df[col] = feat_df[col].fillna(feat_df[col].median())  

feat_df['面積（㎡）容積率（％）_combi'] = (feat_df['面積（㎡）'] * feat_df['容積率（％）'])
feat_df['最寄駅：距離（分）_mean'] = feat_df['最寄駅：距離（分）_mean'].fillna(feat_df['最寄駅：距離（分）_mean'].median())
feat_df['地域別_価格分散'] = feat_df['地域別_価格分散'].fillna(feat_df['地域別_価格分散'].median())

feat_df.isnull().sum()
```
### 実行結果
```Python
ID	0
市区町村コード	0
最寄駅：距離（分）	0
面積（㎡）	0
建ぺい率（％）	0
容積率（％）	0
取引価格（総額）_log	19390
取引時点_enc	0
都道府県名_te	0
取引時点_何年前	0
築年数	0
駅距離_面積_比率	0
駅距離_面積_積	0
地域別_価格平均	0
地域別_価格中央値	0
地域別_価格分散	0
取引月	0
取引年	0
種類	0
都道府県名	0
市区町村名	0
地区名	0
最寄駅：名称	0
間取り	0
建物の構造	0
用途	0
今後の利用目的	0
都市計画	0
改装	0
築年数_階層	0
面積（㎡）容積率（％）_combi	0
最寄駅：距離（分）_count	0
最寄駅：距離（分）_mean	0
面積（㎡）_count	0
面積（㎡）_mean	0

dtype: int64
```
# データ加工後の確認

データ加工後の確認として、まずデータフレームの全カラム名を確認したところ、35列で構成されており、欠損補完や特徴量生成が反映されていることが分かりました。
その後、学習用データ（820,591件）、検証用データ（26,598件）、テスト用データ（19,390件）に時系列で分割し、目的変数やID、除外カラムを除いた説明変数群（feat_cols）を抽出しました。

さらに、カテゴリ変数としてラベルエンコード済みの11変数と取引時点_encを合わせた計12のカテゴリ変数を設定し、学習用データの説明変数に欠損がないことを確認しました。これにより、モデル学習に向けた前処理が完了し、欠損のない安定したデータセットが用意できていることを確認できました。

### 実行したコード
```Python
feat_df.columns
```
### 実行結果
```Python
Index(['ID', '市区町村コード', '最寄駅：距離（分）', '面積（㎡）', '建ぺい率（％）', '容積率（％）',
       '取引価格（総額）_log', '取引時点_enc', '都道府県名_te', '取引時点_何年前', '築年数', '駅距離_面積_比率',
       '駅距離_面積_積', '地域別_価格平均', '地域別_価格中央値', '地域別_価格分散', '取引月', '取引年', '種類',
       '都道府県名', '市区町村名', '地区名', '最寄駅：名称', '間取り', '建物の構造', '用途', '今後の利用目的',
       '都市計画', '改装', '築年数_階層', '面積（㎡）容積率（％）_combi', '最寄駅：距離（分）_count',
       '最寄駅：距離（分）_mean', '面積（㎡）_count', '面積（㎡）_mean'],
      dtype='object')
```
### 実行したコード
```Python
train_df = feat_df.iloc[:val_min_idx, :]
val_df = feat_df.iloc[val_min_idx:test_min_idx, :]
test_df = feat_df.iloc[test_min_idx:, :]
print(train_df.shape, val_df.shape, test_df.shape)
```
### 実行結果
```Python
(820591, 35) (26598, 35) (19390, 35)
```
### 実行したコード
```Python
feat_cols = [col for col in train_df.columns if col not in rm_cols+[ID, TARGET]]  

cat_cols = list(le_df.columns) + ['取引時点_enc'] 
cat_cols
```
### 実行結果
```Python
['種類','都道府県名','市区町村名','地区名','最寄駅：名称','間取り','建物の構造','用途','今後の利用目的','都市計画','改装','築年数_階層','取引時点_enc']
```
### 実行したコード
```Python
train_x = train_df[feat_cols]
train_y = train_df[TARGET]
val_x = val_df[feat_cols]
val_y = val_df[TARGET]
test_x = test_df[feat_cols]
test_y = test_df[TARGET]

train_x.isnull().sum()
```
### 実行結果
```Python
市区町村コード	0
最寄駅：距離（分）	0
面積（㎡）	0
建ぺい率（％）	0
容積率（％）	0
取引時点_enc	0
都道府県名_te	0
取引時点_何年前	0
築年数	0
駅距離_面積_比率	0
駅距離_面積_積	0
地域別_価格平均	0
地域別_価格中央値	0
地域別_価格分散	0
取引月	0
取引年	0
種類	0
都道府県名	0
市区町村名	0
地区名	0
最寄駅：名称	0
間取り	0
建物の構造	0
用途	0
今後の利用目的	0
都市計画	0
改装	0
築年数_階層	0
面積（㎡）容積率（％）_combi	0
最寄駅：距離（分）_count	0
最寄駅：距離（分）_mean	0
面積（㎡）_count	0
面積（㎡）_mean	0
```

# Optunaでのパラメータ調整
Optunaという自動調整ツールを使って、LightGBMモデルのパラメータを調整しました。5回に分けてデータを分割し、それぞれでモデルの性能（誤差の小ささ）を確認しながら、最適なパラメータを探しています。

今回の結果として、最適なパラメータは、リーフ数：71、木の最大深さ：12、特徴量の使用割合：59.6%、データの使用割合：94.6%、1つの葉に入る最小データ数：11、学習率：0.0529、L1正則化係数：0.00000051、L2正則化係数：0.0359 となりました。


### 実行したコード
```Python
def objective(trial):
    params = {
        'objective': 'regression',
        'metric': 'mae',
        'boosting': 'gbdt',
        'verbosity': -1,
        'random_state': 42,
        'subsample_freq': 1,
        'num_leaves': trial.suggest_int('num_leaves', 20, 100),
        'max_depth': trial.suggest_int('max_depth', 3, 15),
        'feature_fraction': trial.suggest_float('feature_fraction', 0.5, 1.0),
        'bagging_fraction': trial.suggest_float('bagging_fraction', 0.5, 1.0),
        'min_data_in_leaf': trial.suggest_int('min_data_in_leaf', 1, 20),
        'learning_rate': trial.suggest_float('learning_rate', 0.01, 0.3, log=True),
        'lambda_l1': trial.suggest_float('lambda_l1', 1e-8, 10.0, log=True),
        'lambda_l2': trial.suggest_float('lambda_l2', 1e-8, 10.0, log=True),
    }

    kf = KFold(n_splits=5, shuffle=True, random_state=123)
    maes = []

    for tr_idx, va_idx in kf.split(train_x):
        tr_x, va_x = train_x.iloc[tr_idx], train_x.iloc[va_idx]
        tr_y, va_y = train_y.iloc[tr_idx], train_y.iloc[va_idx]

        lgb_train = lgb.Dataset(tr_x, label=tr_y, categorical_feature=cat_cols)
        lgb_val = lgb.Dataset(va_x, label=va_y, categorical_feature=cat_cols)

        model = lgb.train(
            params,
            lgb_train,
            num_boost_round=100000,
            valid_sets=lgb_val,
            callbacks=[lgb.early_stopping(200), lgb.log_evaluation(200)]
        )
        preds = model.predict(va_x, num_iteration=model.best_iteration,pred_early_stop=True)
        maes.append(mean_absolute_error(va_y, preds))

    return np.mean(maes)

study = optuna.create_study(direction='minimize')
study.optimize(objective, n_trials=10)

print("Best trial:")
print(study.best_trial.params)
```
### 実行結果
```Python
Best trial:
{'num_leaves': 71, 'max_depth': 12, 'feature_fraction': 0.5960182351584156, 'bagging_fraction': 0.9455564242030488, 'min_data_in_leaf': 11, 'learning_rate': 0.05290116954646511, 'lambda_l1': 5.105505312723187e-07, 'lambda_l2': 0.03587600922948159}
```

# LightGBMによる回帰モデルの構築

LightGBMを使って回帰モデルを5分割交差検証で学習しました。先ほどOptunaで見つけた最適パラメータを使い、学習は最大10000回まで実施しますが、性能が上がらなくなったら早めに停止（早期終了）する設定にしています。

また、LightGBMで学習したモデルの特徴量重要度を「gain」を指標にして表示し、図を画像ファイルに保存した上で画面にも表示しました。これにより、どの特徴量が予測に強く影響しているかが一目でわかるようになっています。

### 実行したコード
```Python
best_params = study.best_trial.params
best_params.update({
    'objective': 'regression',
    'metric': 'mae',
    'verbosity': -1,
    'random_state': 42,
    'boosting': 'gbdt',
    'subsample_freq': 1,
})

models = []
kf = KFold(n_splits=5, shuffle=True, random_state=123)
for train_idx, val_idx in kf.split(train_x):
    tr_x, va_x = train_x.iloc[train_idx], train_x.iloc[val_idx]
    tr_y, va_y = train_y.iloc[train_idx], train_y.iloc[val_idx]

    train_data = lgb.Dataset(tr_x, label=tr_y, categorical_feature=cat_cols)
    val_data = lgb.Dataset(va_x, label=va_y, categorical_feature=cat_cols)

    model = lgb.train(
        best_params,
        train_data,
        num_boost_round=10000,
        valid_sets=[train_data, val_data],
        valid_names=['train', 'valid'],
        callbacks=[lgb.early_stopping(200), lgb.log_evaluation(200)]
    )
    models.append(model)

print(f"Fold 0 Best iteration: {models[0].best_iteration}")
```
```Python
lgb.plot_importance(model, figsize=(12,8), max_num_features=50, importance_type='gain')
plt.tight_layout()
plt.savefig(BASE_PATH + 'output/feature_importance.png')
plt.show()
plt.close()
```
### 実行結果
![最新.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4102026/471dd280-95f8-4352-93b0-76f0c858eda0.png)


# 交差検証による汎化性能向上 
交差検証で学習した複数のモデルの予測結果を平均することで、1つのモデルに頼るよりも予測のばらつきを抑え、より安定した結果を得られます。この方法は汎化性能（未知データに対する予測精度）の向上に役立ちます。

### 実行したコード
```Python
test_preds = []

for i, model in enumerate(models):
    fold_test_pred = model.predict(test_x, num_iteration=model.best_iteration)
    test_preds.append(fold_test_pred) 

final_test_pred = np.mean(test_preds, axis=0)

test_df[TARGET] = final_test_pred
```


# スコア/評価(MAE)
テストデータに対する予測結果と実際の値からMAEとRMSEを計算し、モデルの予測精度を評価しました。さらに、提出用のCSVファイルを作成し保存しています。

今回の結果として、MAEは、0.0755　になりました。

### 実行したコード
```Python
val_preds = np.zeros(len(train_x))

for fold, (train_idx, val_idx) in enumerate(kf.split(train_x)):
    va_x = train_x.iloc[val_idx]
    va_y = train_y.iloc[val_idx]
    model = models[fold]

    val_pred = model.predict(va_x, num_iteration=model.best_iteration)
    val_preds[val_idx] = val_pred

mae = mean_absolute_error(train_y, val_preds)

print(f"KFold Validation MAE: {mae:.4f}")
```
### 実行結果
```Python
KFold Validation MAE: 0.0755
```


# 傾向
- 面積が広いほど価格は高いという基本的な相関が見られる
- 駅からの距離が遠くなると価格は下がる傾向がある
- 築年数は価格にマイナスの影響を与えるが、築古物件でも一定価格を保っているものもある
- LightGBMモデルは面積・駅距離・築年数を高重要度と判断している
- KFoldでの予測平均により、予測のばらつきを抑えて汎化性能を高めている


# 課題
- 面積や駅距離などの分布が歪な特徴量に対して、ログ変換などのスケーリング処理をしていない
- 築年数の外れ値対応が不十分なため、古すぎる物件が価格予測に悪影響を与えている可能性がある
- カテゴリ変数の処理方針が不明確。OneHotやターゲットエンコーディング等の比較検討が必要
- 空間情報（駅名、エリア名、緯度経度など）を活用しておらず、立地の価値を取り込めていない
- モデル解釈（SHAPなど）を通じて、予測の説明可能性を高める分析が未実施


# 考察
- 価格変動の主因は立地・面積・築年数であるが、これらの変数はスケール・分布が大きく異なり、モデルに直接与えると学習に偏りが生じやすい
- 築年数や距離などの特徴量は非線形な影響を持っており、ログ変換・クリッピングなどの加工によってモデル性能が向上する余地がある
- 特徴量重要度の分析から、地理情報・面積・築年数などのドメイン知識に基づいた設計が、予測性能に直接的に寄与することが確認できた
- カテゴリ変数への処理（エンコーディング）は基本的な方法に留まっており、高度な手法（例えばターゲットエンコーディング）を導入することで精度向上が見込める
- クロスバリデーションによる安定した予測結果から、モデルの再現性は高く、今後の改善は特徴量設計と前処理の工夫に依存する可能性が高い


# まとめ
- 課題理解と構造把握：不動産価格の決定要因を丁寧に分解し、対象データの特性（欠損・異常・時系列性など）を踏まえてアプローチを設計
- 特徴量エンジニアリング：自治体・駅・築年数などの「意味ある変数」を中心に加工し、特徴量選定においては汎化性と時系列の因果性を意識
- モデル構築とチューニング：LightGBMとOptunaを用いて回帰モデルを最適化し、交差検証とMAE評価で過学習の回避と精度のバランスを意識
- 再現性と実務性：Google ColbとDriveの併用により、チーム開発や継続的分析にも耐える実装としました


# 得られた知見
- 地域・時系列・物件属性のバランスが取れた特徴量設計が、予測精度の鍵を握る
- 欠損処理と特徴量の削除は、盲目的にせず、情報損失の少ないルール設計が重要
- 時系列データでは「未来情報漏洩の防止」が、汎化性能のボトルネックになり得る


# 今後の展望
- SHAP値やPermutation Importanceを用いたモデル解釈性の強化
- カスタム損失関数やCatBoostなど、多様なアルゴリズムとの比較検証
- モデル予測結果を地図・ヒートマップで可視化し、ビジネス側への説明力を高める応用展開


# さいごに
- プログラミングスクールの課題の一環としてこの分析に取り組みましたが、実務データに限りなく近い構造と、実務でも応用可能な手法を通じて、改めて「データ設計と目的変数設計の重要性」を痛感しました。再現可能かつ発展可能なこの分析が、どこかで誰かの参考になれば幸いです