Kaggle 自動車価格予測に挑戦

# はじめに
これまで約半年間機械学習を勉強してきた腕試しとしてKaggleのコンペに挑戦してきました。([大学生の卒業可否予測](https://qiita.com/gucchi_JPN/private/25cdcddb73a604cbeab2), [Benz社の実験時間予測](https://qiita.com/gucchi_JPN/items/4f03b7b37c3ae2336e26))

今回は3つ目の課題として執筆時(24年08月19日)に進行中であった[中古車の販売価格予測](https://www.kaggle.com/competitions/autoam-car-price-prediction/overview)に挑戦しました。データの欠損もなく、特徴量もわかりやすいものが多いため初学者向きと考え、取り組みました。
これからPythonや機械学習を始めようと思っている方、学習をすでに始めており、力試しをしてみたいと思っている方にわかりやすい記事を目指します！
ご意見やご質問頂けたら嬉しいです。

# 挑戦した課題
- [Kaggle car price](https://www.kaggle.com/competitions/autoam-car-price-prediction/overview)

### 内容
- 自動車の車種・色・排気量・販売年・走行距離・車の状態といった基本的な情報から中古車の販売価格を予測する([回帰問題](https://qiita.com/potato_revolution/items/ff24becc2b8406efd4a4))
- モデルの精度はMAEで競う


# 実行環境
- プロセッサ：12th Gen Intel(R) Core(TM) i5-1240P
- 開発環境：Google Colaboratory
- 言語：Python
- 使用したライブラリ：Pandas, Numpy, Matplotlib, scikit-learn

# 分析するデータ
- データ数 : trainデータ → 1642, testデータ → 411
    - データ数が非常に少ない
- 特徴量の数 : 9 (カテゴリ : 6, 数値 : 3)
- 欠損値 : なし

# 分析の流れ
1. データの読み込み
1. データのチェック
1. データの前処理
1. 特徴量エンジニアリング
1. モデルの選定
1. 予測

### 1. データの読み込み
#### 実行したコード
```python: kaggle_carprice.ipynb
# 必要なモジュールのインポート
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import warnings
from sklearn.model_selection import train_test_split

warnings.filterwarnings("ignore")
```

- 各モジュールのURLはこちら
[pandas](https://pandas.pydata.org/), [numpy](https://numpy.org/ja/), [matplolib](https://matplotlib.org/), [seaborn](https://seaborn.pydata.org/), [PyCaret](https://pycaret.org/)

```python: kaggle_carprice.ipynb
# データの読み込み
train = pd.read_csv('/content/train.csv')
test = pd.read_csv('/content/test.csv')
```

### 2. データのチェック
#### 実行したコード
```python: kaggle_carprice.ipynb
# 学習データの最初の5行を表示
train.head(5)

# 学習データの最初の5行を表示
test.head(5)
```

![train_head.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3838907/5b1bd86a-5cba-80b3-6ce4-702f0154d9c9.png)

- どのような特徴量があるかなどおおまかに確認

```python: kaggle_carprice.ipynb
# 数値データの平均値などを確認
train.describe()
```

![train_describe.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3838907/2142e7f3-ec44-7de0-360d-effd2527ebd8.png)

- 販売年は比較的新しい年に偏り、排気量は2L付近が多そう、とわかる

```python: kaggle_carprice.ipynb
# priceのばらつきをグラフでも確認し、外れ値と言える値をクリップする
train.loc[train['price'] > 50000, 'price'] = 50000
train.loc[train['price'] < 1000, 'price'] = 1000
```

![id_vs_price.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3838907/a8eb8bc6-1c1b-c812-a96c-e0aee0a143a7.png)

- 今回は50000以上の3点と値が0に近い1点をクリップする

```python: kaggle_carprice.ipynb
# 欠損値やデータタイプの確認
train.info()
test.info()
```

![train_info.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3838907/fcfb15c2-a6d2-26e6-a64d-900677ed5aed.png)

### 3. データの前処理
#### 実行したコード

```python: kaggle_carprice.ipynb
# priceの正規分布への当てはまりを調査

# yをログスケールに変換
log_y = np.log1p(train['price'])

# グラフの可視化
fig, axs = plt.subplots(2, 2, figsize=(12, 10))

# yのヒストグラムとQQプロット
sns.histplot(train['price'], kde=True, ax=axs[0, 0])
axs[0, 0].set_title('Histogram of y')
probplot(train['price'], dist="norm", plot=axs[0, 1])
axs[0, 1].set_title('QQ Plot of y')

# log_yのヒストグラムとQQプロット
sns.histplot(log_y, kde=True, ax=axs[1, 0])
axs[1, 0].set_title('Histogram of log_y')
probplot(log_y, dist="norm", plot=axs[1, 1])
axs[1, 1].set_title('QQ Plot of log_y')

plt.tight_layout()
plt.show()

# ターゲット変数をログスケールに変換
train['price'] = log_y
```

![QQ_y.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3838907/83b2d5cd-892f-500f-2a5d-1ecbb67bfd5d.png)

- priceをログに変換した方が正規分布によくフィットする
    - ターゲット変数をログスケールに変換

```python: kaggle_carprice.ipynb
# runningは数値の表記がkm, mileが混在しているためkmで統一する

# trainデータのrunningについて値の空白でsplitし、１番目に入っている値をfloat型にする。
# その後２番目に入っている文字列がmilesの場合はsplitした値の1番目の値に1.61をかける
train['running_first'] = train['running'].str.split().str[0].astype(float)
train.loc[train['running_second_word'] == 'miles', 'running_first'] *= 1.61

# testデータについても同様の処理を行う
test['running_first'] = test['running'].str.split().str[0].astype(float)
test.loc[test['running_second_word'] == 'miles', 'running_first'] *= 1.61

# train, testデータからrunning, running_second_wordを削除
train = train.drop(['running', 'running_second_word'], axis=1)
test = test.drop(['running', 'running_second_word'], axis=1)
```

- もともと文字列だったデータを数値型に変更し、km, milesを統一


```python: kaggle_carprice.ipynb
print(train['model'].value_counts())
print(test['model'].value_counts())
```
![model_valucount.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3838907/b2279b56-90b1-2f6b-53ab-86cd7a035cde.png)

- 各特徴量に含まれるカテゴリの個数を確認(※例として特徴量「model」に対するコードと結果を記載)


```python: kaggle_carprice.ipynb
plt.figure(figsize=(20, 10))
sns.violinplot(x='model', y='price', data=train)
plt.xticks(rotation=90)
plt.xlabel('Model')
plt.ylabel('Price')
plt.title('Price Distribution by Model')
plt.grid(True)
plt.show()
```
![model_violinplot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3838907/54f1fa0c-4a36-e98d-b6d2-1789a8381aea.png)

- データのカテゴリごとのばらつきを[バイオリンプロット](https://corvus-window.com/all_violinplot/)で確認(※例として特徴量「model」に対するコードと結果を記載)
### 4. 特徴量エンジニアリング
#### 実行したコード
```python: kaggle_carprice.ipynb
# ターゲットエンコーディングを行う関数を定義
def target_encoding(train_df, test_df, target_column, categorical_columns, n_splits=10):
    train_encoded = train_df.copy()
    test_encoded = test_df.copy()
    kf = KFold(n_splits=n_splits, shuffle=True, random_state=42)

    for col in categorical_columns:
        train_encoded[f'{col}_mean_encoded'] = np.nan
        for train_idx, val_idx in kf.split(train_df):
            train_fold, val_fold = train_df.iloc[train_idx], train_df.iloc[val_idx]
            mean_encoded = train_fold.groupby(col)[target_column].mean()
            train_encoded.loc[val_idx, f'{col}_mean_encoded'] = val_fold[col].map(mean_encoded)

        overall_mean_encoded = train_df.groupby(col)[target_column].mean()
        test_encoded[f'{col}_mean_encoded'] = test_df[col].map(overall_mean_encoded)

        # Filling missing values that may have arisen due to unseen categories in test set
        train_encoded[f'{col}_mean_encoded'].fillna(train_df[target_column].mean(), inplace=True)
        test_encoded[f'{col}_mean_encoded'].fillna(train_df[target_column].mean(), inplace=True)

    return train_encoded, test_encoded
```
- trainデータに関してはデータの[リーク](https://www.codexa.net/target_encoding/)が無いようにFoldを行いながら実行しています

```python: kaggle_carprice.ipynb
# ターゲットエンコーディングするカテゴリを指定する
categorical_columns = ['model', 'motor_type', 'color', 'type', 'status']
target_column = 'price'

# ターゲットエンコーディングを実行
train_encoded, test_encoded = target_encoding(train, test, target_column, categorical_columns)

#もとのカテゴリのdrop
train_encoded = train_encoded.drop(categorical_columns, axis=1)
test_encoded = test_encoded.drop(categorical_columns, axis=1)
```

```python: kaggle_carprice.ipynb
# 学習用にターゲット変数とそれ以外で分割
X = train_encoded.drop('price', axis=1)
y = train_encoded['price']
```

```python: kaggle_carprice.ipynb
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
test_encoded_scaled = scaler.transform(test_encoded)
```
- データを標準化する

### 5. モデルの選定・予測
#### 実行したコード
- 今回は[勾配ブースティング法](https://qiita.com/fuyu_quant/items/ea256c8d8fc552311ed6)を選択

```python: kaggle_carprice.ipynb
# params @Mikhail Naumov
params = {'booster': 'gbtree',
          'max_depth': 3,
          'max_leaves': 769,
          'learning_rate': 0.04538451353216046,
          'n_estimators': 1171,
          'min_child_weight': 13,
          'subsample': 0.6578720167306904,
          'reg_alpha': 0.4622943878867952,
          'reg_lambda': 0.6211309481623339,
          'colsample_bylevel': 0.7985625445322192,
          'colsample_bytree': 0.9634723040072963,
          'colsample_bynode': 0.49814271378837316,
          'random_state': 42,
          'objective': 'reg:absoluteerror',
          'n_jobs': -1,
         }

xgb_model = xgb.XGBRegressor(**params)
```

- 初期パラメータとしてMikhail Naumovのもの使用

```python: kaggle_carprice.ipynb
# パラメータの範囲を設定
param_dist = {
    'max_depth': [3, 5, 7, 9],
    'learning_rate': [0.01, 0.03, 0.05, 0.1, 0.2],
    'n_estimators': [100, 500, 1000, 1500, 2000],
    'subsample': [0.6, 0.7, 0.8, 0.9, 1.0],
    'min_child_weight': [1, 3, 5, 7, 10, 15],
    'reg_alpha': [0.1, 0.3, 0.5, 1],
    'reg_lambda': [0.5, 0.7, 0.9, 1.0, 1.2],
    'colsample_bylevel': [0.6, 0.8, 1.0],
    'colsample_bytree': [0.6, 0.8, 1.0],
    'colsample_bynode': [0.6, 0.8, 1.0],
    'gamma': [0, 0.1, 0.2, 0.3, 0.4]
}
```

- ランダムサーチを行う範囲を指定

```python: kaggle_carprice.ipynb
# 5分割の交差検証
kf = KFold(n_splits=5, shuffle=True, random_state=42)

# ランダムサーチ
random_search = RandomizedSearchCV(
    estimator=xgb_model,
    param_distributions=param_dist,
    n_iter=100,  # 試行回数
    scoring='neg_mean_absolute_error',  # MAEで最適化
    cv=kf,
    verbose=2,
    random_state=42,
    n_jobs=-1  # すべてのコアを使用
)

# 学習
random_search.fit(X_scaled, y)
```
- 現実的な試行回数の中でよいパラメータを見つけるために初期値が重要

```python: kaggle_carprice.ipynb
# 最適なパラメータとスコアを表示
print("Best parameters found: ", random_search.best_params_)
print("Lowest MAE found: ", -random_search.best_score_)

# 最適なモデルを取得
best_xgb_model = random_search.best_estimator_
```

```python: kaggle_carprice.ipynb
# 最適なモデルを用いて予測
y_pred = best_xgb_model.predict(test_encoded_scaled)

#ログスケールされた値をもとに戻す
y_pred = np.expm1(y_pred)
```

#### Extra
- 特徴量エンジニアリングとしてダミー変数化を行ったものに対しても勾配ブースティング法を用いた予測を実施
    - 初期パラメータやランダムサーチの探索範囲は同様

```python: kaggle_carprice.ipynb
# 数値データのカラム名を取得
numerical_cols = X.select_dtypes(include=['float', 'int']).columns
print(numerical_cols)
```
- testデータに対しても同様

```python: kaggle_carprice.ipynb
# StandardScalerをインスタンス化
scaler = StandardScaler()

# trainデータの標準化
X[numerical_cols] = scaler.fit_transform(X[numerical_cols])

# testデータの標準化（trainデータでfitしたscalerを使用）
test[numerical_cols] = scaler.transform(test[numerical_cols])
```
- 数値データは標準化しておく
    - testデータはtrainデータに対してfitしたscalerを使用することに注意

```python: kaggle_carprice.ipynb
# ダミー変数化
X_dummies = pd.get_dummies(X, dtype=int)
test_dummies = pd.get_dummies(test, dtype=int)

# X_dummiesとtest_dummiesで生成されたカテゴリについて外部結合を行う
X_dummies, test_dummies = X_dummies.align(test_dummies, join='outer', axis=1, fill_value=0)
```
- [ダミー変数化](https://best-biostatistics.com/correlation_regression/dummy.html)を実施
- 今回trainデータ、testデータのみに含まれるカテゴリが存在するため[外部結合](https://zenn.dev/maicchan/articles/330603bcf018b4)を行う

- X_dummiesとyに対して[5. モデルの選定・予測](###5-モデルの選定・予測)で行ったのと同様に勾配ブースティングによる予測を行う


#### 6. 予測
- 2つの方法の特徴量エンジニアリングを用いて予測した値をアンサンブルして最終的な予測結果とする

```python: kaggle_carprice.ipynb
y_final = 0.3 * y_pred + 0.7 * y_pred_dummies
```
- 最もスコアの良かった重みを採用

# 結果
進行中の課題のためPublicスコアのみを記載
- MAE : 1792.8605
- 25 / 245位 (2024年08月19日時点)


# さいごに
課題を通じて感じたこと
- 外れ値の処理、データの前処理、特徴量エンジニアリグ、ハイパーパラメータの探索など機械学習の基本的のアプローチの組み合わせでよいスコアが出せる
- 学習モデルの初期パラメータの設定といった工夫がスコアの改善につながる

今回の課題はデータ数が少なさなど障壁もありましたが、基本に立ち返ったアプローチである程度よいモデルを作ることができました。基本の大切さを実感したとともに、少しのアプローチの違いが大きな精度の差を生むことも体感できました。これからも多くの方の技を参考にしつつ、レベルアップしていきたいです。