Target Encodingで精度向上させた例(Leave One Out)

Target Encoding(Leave One Out)を使って分類器の精度向上させました。**[Kaggle Titanic](https://www.kaggle.com/c/titanic/overview)で0.79665のScore**で約2500位/55000人程度なので、そこそこいいスコアかと思います。
Target Encodingを勉強したときは、どんなときに使うんだろうとぼんやりと考えていましたが、意外とすぐに使う機会ができました。**Greedy Target Statisticsはリークしたので、Leave one-out Target Statisticsを使いました**。
※Target Encodingは[記事「カテゴリ変数系特徴量の前処理(scikit-learnとcategory_encoders)」](https://qiita.com/FukuharaYohei/items/06cc7b1c5eb124db6cdf)に書いています。

# 前提: Target Encoding
手前味噌ですが、[私の記事](https://qiita.com/FukuharaYohei/items/06cc7b1c5eb124db6cdf#target-encoding)に簡単に書いていて、以下の記事にはもっと詳しい内容があります。

https://blog.amedama.jp/entry/target-mean-encoding-types

# チャレンジ内容: Titanic
[Kaggle Titanic](https://www.kaggle.com/c/titanic/overview)でTarget Encoding を使いました。タイタニック号乗客の生死予測です。

## 特徴量
### 特徴量詳細
[与えられた特徴量](https://www.kaggle.com/c/titanic/data?select=train.csv)には、以下のものがあります。

|列|変数|内容|Value情報など|
|:-:|:-:|:-:|:--|
|1|PassengerId|乗客IDでユニークキー||
|2|Survival|生死(目的変数)|0 = No(死亡), 1 = Yes(生存)。テストデータには存在しない列|
|3|Pclass|席等級|1 = 1st(Upper), 2 = 2nd(Middle), 3 = 3rd(Lower)|
|4|Name|姓名|
|5|Sex|性|male/female|
|6|Age|Age in years||
|7|Sibsp|同乗した姉妹兄弟と配偶者数||
|8|Parch|同乗した両親子どもの数||
|9|Ticket|チケット番号||
|10|Fare|料金|親子で2席買ったら合計料金になるっぽい|
|11|Cabin|キャビン番号|欠損値多い|
|12|Embarked|乗船港|C = Cherbourg, Q = Queenstown, S = Southampton|

### 新たな特徴量とTarget Encoding
そもそも、以下の kernel に触発されて自分なりに特徴量エンジニアリングをしてみた結果、Target Encodingに行き着きました。

https://www.kaggle.com/pliptor/how-am-i-doing-with-my-score#can-we-use-the-knn-algorithm-to-solve-this-problem

家族や友人などの**グループは生死を道連れにする**、という仮説を元にグループの特徴量を作り、グループに対してTarget Encodingを使います。グループ作成の基準は以下の2点。

1. 同じ Ticket(婚約者でまだ姓が違う場合や友人など)
1. 姓と料金が同じ(姓だけだと偶然の場合も多いので)

※もっといいグルーピングの基準があるかもしれませんが、時間をかけて精査していません。

## プログラム
全般的に、結構雑な処理をしています。プログラム全体はこちらのカーネル。

https://www.kaggle.com/fukuharayohei/getting-started-with-titanic?scriptVersionId=69530843

### 1. パッケージとファイル読込
最初はパッケージとファイル読込です。

```python
import pandas as pd
import matplotlib.pyplot as plt
import category_encoders as ce

from sklearn.preprocessing import OrdinalEncoder
from sklearn.ensemble import RandomForestClassifier

train_csv = pd.read_csv("/kaggle/input/titanic/train.csv")
test_csv = pd.read_csv("/kaggle/input/titanic/test.csv")
```

訓練データとテストデータの内容(`info`関数結果)を参考に載せておきます。

```:train_csv()
RangeIndex: 891 entries, 0 to 890
Data columns (total 12 columns):
 #   Column       Non-Null Count  Dtype  
---  ------       --------------  -----  
 0   PassengerId  891 non-null    int64  
 1   Survived     891 non-null    int64  
 2   Pclass       891 non-null    int64  
 3   Name         891 non-null    object 
 4   Sex          891 non-null    object 
 5   Age          714 non-null    float64
 6   SibSp        891 non-null    int64  
 7   Parch        891 non-null    int64  
 8   Ticket       891 non-null    object 
 9   Fare         891 non-null    float64
 10  Cabin        204 non-null    object 
 11  Embarked     889 non-null    object 
dtypes: float64(2), int64(5), object(5)
memory usage: 83.7+ KB
```

```:test_csv.info()
RangeIndex: 418 entries, 0 to 417
Data columns (total 11 columns):
 #   Column       Non-Null Count  Dtype  
---  ------       --------------  -----  
 0   PassengerId  418 non-null    int64  
 1   Pclass       418 non-null    int64  
 2   Name         418 non-null    object 
 3   Sex          418 non-null    object 
 4   Age          332 non-null    float64
 5   SibSp        418 non-null    int64  
 6   Parch        418 non-null    int64  
 7   Ticket       418 non-null    object 
 8   Fare         417 non-null    float64
 9   Cabin        91 non-null     object 
 10  Embarked     418 non-null    object 
dtypes: float64(2), int64(4), object(5)
memory usage: 36.0+ KB
```

### 2. 特徴量生成
#### 2.1. グループキーとなる特徴量生成
訓練データとテストデータを結合して、以下の2処理を行います。本当は訓練データとテストデータの結合って嫌いなのですが、今回は無視しています。

1. `Name`から姓の抽出
1. 姓と`Fare`の結合(次で処理しやすくするため)

```python
both = pd.concat([train_csv, test_csv], ignore_index=True)

# Last Name作成
both['Last_Name'] = both['Name'].apply(lambda x: str.split(x, ",")[0])

# FareがNullのレコードは文字列"nan"となる
both['Name_Fare'] = both['Last_Name'] + both['Fare'].astype('str')
```

#### 2.2. グルーピング
同じ`Ticket`でグルーピング→同じ「姓」と`Fare`でグルーピングを再帰処理で繰り返します。今回、一番コーディングが面倒だった処理です(筆者のスキル不足が原因)。新しく`Group`列に数値を連番で振っていきます。

```python
# 姓とFareでグルーピング
def process_name(i, name_fare):
    tickets = both.loc[(both['Name_Fare'] == name_fare) & (both['Group'].isnull()),'Ticket'].unique().tolist()
    both.loc[(both['Name_Fare'] == name_fare) & (both['Group'].isnull()), 'Group'] = i
    for ticket in tickets:
        process_ticket(i, ticket)

# チケットでグルーピング        
def process_ticket(i, ticket):
    name_fares = both.loc[(both['Ticket'] == ticket) & (both['Group'].isnull()),'Name_Fare'].unique().tolist()
    both.loc[(both['Ticket'] == ticket) & (both['Group'].isnull()), 'Group'] = i
    for name_fare in name_fares:
        process_name(i, name_fare)

both['Group'] = None

# チケットでグルーピング(再帰処理で姓とFare、チケットでのグルーピングを繰り返す)
[process_ticket(i, ticket) for i, ticket in enumerate(both['Ticket'].unique().tolist())]
```

### 2.3. 訓練とテストに分離
再び、訓練とテストデータセットに分離します。

```python
train = both[:891].copy()
test = both[891:].copy()
```

### 2.4. グループのカウント
[Count Encoder](https://contrib.scikit-learn.org/category_encoders/count.html)を使ってグループ内人数のカウントを実施。`Group_Count`という列に値を入れます。
テストデータセットには、自分自身のデータ数をカウントとして+1します。これは、例えば訓練データに「山田グループ」があり2人いたとして、テストデータに同じく「山田グループ」に所属する人がいたら(何人いても)テストデータを3と設定しています。訓練データとテストデータをまとめてカウントエンコーディングすればいいような気もしますが、そうすると訓練内容にも大きく影響与えそうなのでやっていません。

```python
count_encoder = ce.CountEncoder(cols=['Group'], handle_unknown=0, return_df=True)
train['Group_Count'] = count_encoder.fit_transform(train['Group'])
test['Group_Count'] = count_encoder.transform(test['Group']).astype('int') + 1  #test自身のデータを1追加(本当はもっとあるかもしれないが雑に計算)
```

### 2.5. Target Encoding
Target Encoding として[Leave One Out](https://contrib.scikit-learn.org/category_encoders/count.html)を使っています。自分自身を抜いて目的変数`Survived`の平均の特徴量を`Group_Target`に入れます。

```python
te = ce.LeaveOneOutEncoder(cols=['Group'])
train['Group_Target'] = te.fit_transform(train['Group'], train['Survived'])
test['Group_Target'] = te.transform(test['Group'])
```

### 2.6. Target Encoding 微調整
訓練データに1人のみいたグループの場合、その1人の生死(`Survived`の値)をそのままテストデータの`Group_Target`に入れます。
また、訓練データに1人もいなかったグループの場合は、平均生存率である0.38を入れておきます。

```python
for index, row in test.query('Group_Count == 2').iterrows():
    test.at[index, 'Group_Target'] = train[train['Group']==row['Group']]['Survived']
test.loc[test['Group_Count'] == 0, 'Group_Target'] = 0.38
```

### 2.7. 他カテゴリ変数のEncoding
`Pclass`(席等級)と`Sex`(性)をEncodingします。

```python
oe = OrdinalEncoder()
train.loc[:,['PclassEncoded', 'SexEncoded']] = oe.fit_transform(train[['Pclass', 'Sex']])
test.loc[:,['PclassEncoded', 'SexEncoded']] = oe.transform(test[['Pclass', 'Sex']])
```

## 3. グルーピングの確認
グルーピング結果をグラフで確認します。

```python
_, axes = plt.subplots(nrows=1, ncols=3, figsize=(15, 4))
train['Group_Count'].value_counts().plot.pie(ax=axes[0], title='Group Count Pie Chart', autopct="%1.1f%%")
train['Group_Count'].plot.hist(ax=axes[1], title='Group Count Histgram')
train.query('Group_Count > 2')['Group_Target'].plot.hist(ax=axes[2], title='Survival Rate Histgram')
plt.show()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134273/0d5cba42-54be-5bc8-6da3-c7fcc1d5bd8e.png)
グラフの内容は以下通りです。

- 左(円グラフ): グループ内人数(`Group_Count`)の割合
- 中央(ヒストグラム): グループ内人数(`Group_Count`)のヒストグラム
- 右(ヒストグラム): 3人以上のグループの生存率ヒストグラム

訓練データの半分弱がグループ(3人以上)です。右のグラフ(3人以上のグループの生存率ヒストグラム)では、ある程度左右に偏ってくれています。
2人グループを含めなかった理由は、2人グループはLeave One Outなので必ず0(死亡)か1(生存)になるからです。
グループ内人数で個別に確認しています。

```python
_, ax = plt.subplots(figsize=(12, 10))
train.query('Group_Count > ２').Group_Target.hist(ax=ax, by=train['Group_Count'], range=(0, 1))
plt.show()
```

3 or 4人グループでは少しバラけています。一方で5人以上のグループでは、道連れ死パターンが多いですね。グループ人数情報と併せて決定木系の分類器であれば、判断してくれるのでは、と期待します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134273/dad3f622-0a71-da51-0726-bdd95343d2f4.png)

## 4. 訓練実施
特徴量で使いやすいものだけピックアップして、使いやすいランダムフォレストで訓練します。Scoreは0.853でした。
※Greedy Target Statisticsを使ったときはリークがひどく、0.98くらいでした(ウル覚え)・・・

```python
features = ["Group_Count", "Group_Target", 'PclassEncoded', 'SexEncoded', "SibSp", "Parch"]
X = train[features]
y = train["Survived"].astype('int')

tmp_sr = test['PassengerId']
X_test = test[features]

model = RandomForestClassifier(n_estimators=100, max_depth=5, random_state=1)
model.fit(X, y)
print(model.score(X, y))
```

特徴量の重要性を確認。

```python
importances = pd.DataFrame({'Importance': model.feature_importances_}, index=X_test.columns)
importances.sort_values('Importance', ascending=False).head(10).sort_values('Importance', ascending=True).plot.barh(grid=True)
```

`Group_Trarget`はいい感じの高さになってくれています(2人以上のグループは多くないのでこんなもの)。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134273/bc2674bd-0341-d399-2154-5cf7cec760be.png)


## 5. 予測
モデルを使って予測して、CSVファイルに出力します。


```python
predictions = model.predict(X_test)

output = pd.DataFrame({'PassengerId': tmp_sr, 'Survived': predictions})
output.to_csv('my_submission.csv', index=False)
```