Kaggle Titanicでとりあえず80%超えを目指す

# はじめに
機械学習の勉強を初めたばかりの初学者です。
今回は、Kaggleの入門「[Titanic - Machine Learning from Disaster](https://www.kaggle.com/c/titanic)」に挑戦してみました。
なるべく簡単に80%を超えることを目指したところ、パラメータチューニングを行わずとも、ひとまず80%を超えることができたので一旦ここまでの内容をまとめます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/597765/8af29d81-7266-31d5-d326-a04f650b79c2.png)

# 1. ソースコード

最初にソースコードです。
前提として、今回はVSCodeで処理を実行して、出力されたCSVファイルをKaggleに提出するという方法で試しました。一部、ファイル分割しています。

## 1.1. main.py
```python:main.py
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_validate, StratifiedKFold
from tools import assign_survival_rate_groups, print_survive_percentage

train = pd.read_csv('train.csv')
test = pd.read_csv('test.csv')
df = pd.concat([train, test], sort=False)

print('----------')
print('Fill value...')
print('----------')
df['Age'] = df['Age'].fillna(df.groupby(['Pclass','Sex'])['Age'].transform('mean'))
df['Fare'].fillna(np.mean(df['Fare']), inplace=True)
df['Embarked'].fillna(('S'), inplace=True)

print('----------')
print('Add Feature value...')
print('----------')
df['FamilySize'] = df['SibSp'] + df['Parch'] + 1

# Title
NAME_TITLES = [
    "Mr.", "Miss.", "Mrs.", "Master.", "Dr.", "Rev.", "Col.", "Ms.", 
    "Mlle.", "Mme.", "Capt.", "Countess.", "Major.", "Jonkheer.", "Don.", 
    "Dona.", "Sir.", "Lady."
]

for title in NAME_TITLES:
    data.loc[data.Name.str.contains(title, regex=False), 'Title'] = title

# 列 Title をMaster, Mr, Miss, Mrs の4種類に分類し、列 NewTitleに設定。
data.loc[data['Title']=='Master.', 'NewTitle'] = 'Master'
data.loc[(data['Sex']=='male')&(data['NewTitle']!='Master'), 'NewTitle'] = 'Mr'
data.loc[(data['Title']=='Mlle.')|((data['Title']=='Ms.')|(data['Title']=='Miss.')), 'NewTitle'] = 'Miss'
data.loc[(data['Sex']=='female')&(data['NewTitle']!='Miss'), 'NewTitle'] = 'Mrs'

data['Title'] = data['NewTitle']
data.drop(['NewTitle'], axis=1, inplace=True)

# 生存率で3つにグルーピング（2. 生き残り多い, 1. 生き残り少ない, 0. ほぼ全滅）
# FamilySize
df.loc[(df['FamilySize'] >= 2) & (df['FamilySize'] <= 4), 'FamilyLabel'] = 2 # 小家族
df.loc[(df['FamilySize'] >= 5) & (df['FamilySize'] <= 7) | (df['FamilySize'] == 1), 'FamilyLabel'] = 1 # 独り、中家族
df.loc[df['FamilySize']  > 7 , 'FamilyLabel'] = 0 # 大家族 8,11は全滅
print_survive_percentage('FamilySize', df)

# 同一Ticket数
df['TicketCount'] = df.groupby(['Ticket'])['PassengerId'].transform('count')
df.loc[(df['TicketCount']>=2) & (df['TicketCount']<=4), 'TicketLabel'] = 2
df.loc[(df['TicketCount']>=5) & (df['TicketCount']<=8) | (df['TicketCount']==1), 'TicketLabel'] = 1
df.loc[(df['TicketCount']>=11), 'TicketLabel'] = 0 # 9,10はいない 11は全滅
print_survive_percentage('TicketCount', df)

# 先頭文字を特徴量とする
df['TicketHead']=df['Ticket'].str.get(0)
print_survive_percentage('TicketHead', df)

# TicketHead を FamilySize などと同様に生存率でグルーピング
df = assign_survival_rate_groups(df, 'TicketHead')
print_survive_percentage('TicketHeadGroup', df)

# Cabinの先頭文字を特徴量とする(欠損値はU)
df['Cabin'] = df['Cabin'].fillna('Unknown')
df['CabinHead']=df['Cabin'].str.get(0)
print_survive_percentage('CabinHead', df)

print('----------')
print('Convert value...')
print('----------')
# NOTE: TicketLabelなど数字化しているものは、ダミー変数化しない。
df = pd.get_dummies(df, columns=
    [
        'Pclass'
        , 'Title'
        , 'Embarked'
        , 'CabinHead'
    ]
)

print('----------')
print('Delete columns...')
print('----------')
delete_columns = [
    'Name', 'PassengerId', 'SibSp', 'Parch', 'Ticket', 'Cabin', 'Sex'
    , 'FamilySize', 'TicketHead', 'TicketCount'
]
df.drop(delete_columns, axis=1, inplace=True)
train = df[:len(train)]
test = df[len(train):]

# NOTE: Survived の列のみ、相関を確認する
print(train.corr()['Survived'])

print('----------')
print('Split...')
print('----------')
y_train = train['Survived']
X_train = train.drop('Survived', axis=1)
X_test = test.drop('Survived', axis=1)

print('----------')
print('Learning...')
print('----------')
clf = RandomForestClassifier(n_estimators=100, max_depth=2, random_state=0)
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)

sub = pd.read_csv('gender_submission.csv')
sub['Survived'] = list(map(int, y_pred))
sub.to_csv('submission.csv', index=False)

print('----------')
print('Accuracy...')
print('----------')
# 層化k分割交差検証 
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
# cross_validate関数を用いてモデルの性能を評価する
cv_results = cross_validate(clf, X_train, y_train, cv=skf, scoring='accuracy', return_train_score=True)
# 各分割におけるテストスコアを取得し、平均スコアを計算
test_scores = cv_results['test_score']
mean_score = test_scores.mean()
# 各分割におけるスコアと、平均スコアを出力
for i, score in enumerate(test_scores):
    print("Fold {} Score: {:.4f}".format(i+1, score))
print("Mean Score: \n{:.4f}".format(mean_score))
```

## 1.2 tools.py

```python:tools.py
def assign_survival_rate_groups(df, column):
    # 指定されたカラムの値ごとの生存率の平均を計算
    survival_rates = df['Survived'].groupby(df[column]).mean()
    # 生存率をもとにカラムの値を3つのグループに分けるためにソート
    sorted_values = survival_rates.sort_values()
    # 3つのグループに分けるための閾値を設定
    n = len(sorted_values)
    low_threshold = sorted_values.iloc[n // 3]
    mid_threshold = sorted_values.iloc[2 * n // 3]
    # グループ分けする関数を定義
    def assign_group(value):
        if pd.isna(value):
            return 'unknown'
        survival_rate = survival_rates[value]
        if survival_rate <= low_threshold:
            return 0 #'low'
        elif survival_rate <= mid_threshold:
            return 1 #'mid'
        else:
            return 2 #'high'
    # グループを指定されたカラムに基づいて割り当てる
    new_column_name = f'{column}Group'
    df[new_column_name] = df[column].apply(assign_group)
    return df

def print_survive_percentage(target_column, df):
    print(df['Survived'].groupby(df[target_column]).mean())
```

# 2. 精度向上につながったこと

## 2.1. 参考にさせていただいた記事の紹介

最初に、いくつか先人の知恵をお借りした部分がありますので紹介します。

- [Kaggle初挑戦感想とタイタニック正答率81%の内容](https://qiita.com/FukuharaYohei/items/c87f61aee2a24466d5d4)
    - `Name`から`Title`を抽出するアプローチを参考にさせていただきました。
    - 可視化についても詳細に記載があり、今後学んでいきたい次第です。
- [KaggleチュートリアルTitanicで上位2%以内に入るノウハウ](https://qiita.com/jun40vn/items/d8a1f71fae680589e05c)
    - `Cabin`の先頭文字を特徴量とするアプローチを参考にさせていただきました。
    - 今回は試していませんが、`Age`の欠損値を予測して補完するアプローチは目からウロコでした。

## 2.2. 生存率でグルーピングする

正直、じぶんなりに頑張ったこととしては、これくらいしかないかもしれません。。。とはいえ、これをすることで80%超えを達成することができました。

やったことは至って簡単で、カラム内の各値における生存率を元に、3つのグループにわけました。
生き残りが多い場合は2、生き残りが少ない場合は1、ほぼ全滅なら0にしています。

### 2.2.1 家族の人数

同じ家族は生死を共にするという考え方に基づいて、家族の人数を特徴量として作成し、家族の人数ごとに生存率を分析しました。
`FamilySize`は乗客の`SibSp`（兄弟・姉妹・配偶者の数）と`Parch`（親・子供の数）に1（自分自身）を加えて算出しました。
結果は以下のとおりです。

- 家族の人数が2人から4人で小家族の場合はラベル2
- 家族の人数が5人から7人で中家族の場合、もしくは1人の場合はラベル1
- 家族の人数が8人以上で大家族の場合をラベル0（8人と11人の家族は全滅でした）

対象のカラムの各値における生存率を出力
```python
df['FamilySize'] = df['SibSp'] + df['Parch'] + 1

def print_survive_percentage(target_column, df):
    print(df['Survived'].groupby(df[target_column]).mean())

print_survive_percentage('FamilySize', df)
```

出力結果
```
FamilySize
1     0.303538
2     0.552795
3     0.578431
4     0.724138
5     0.200000
6     0.136364
7     0.333333
8     0.000000
11    0.000000
```

出力結果を元に以下のように振り分け。
```python
df.loc[(df['FamilySize'] >= 2) & (df['FamilySize'] <= 4), 'FamilyLabel'] = 2 # 小家族
df.loc[(df['FamilySize'] >= 5) & (df['FamilySize'] <= 7) | (df['FamilySize'] == 1), 'FamilyLabel'] = 1 # 独り、中家族
df.loc[df['FamilySize']  > 7 , 'FamilyLabel'] = 0 # 大家族 8,11は全滅
```

### 2.2.2 同一Ticketの枚数

家族でなくても友人同士で乗船している可能性があるという仮説に基づき、同一のTicketを持つ乗客数を特徴量として作成し、Ticketの枚数ごとに生存率を分析しました。
結果は以下のとおりです。

- Ticketの枚数が2枚から4枚の場合をラベル2
- Ticketの枚数が5枚から8枚の場合、もしくは1枚の場合をラベル1
- Ticketの枚数が11枚以上の場合をラベル0

正直、`FamilySize`とほとんど同じですが、スコアは向上したので残しました。やはり友人同士で乗船している方もいたっぽい。

```python
# 同一Ticket数
df['TicketCount'] = df.groupby(['Ticket'])['PassengerId'].transform('count')
print_survive_percentage('TicketCount', df)
```

出力結果
```
TicketCount
1     0.270270
2     0.513812
3     0.653465
4     0.727273
5     0.333333
6     0.210526
7     0.208333
8     0.384615
11    0.000000
```

以下のように振り分け。
```python
df.loc[(df['TicketCount']>=2) & (df['TicketCount']<=4), 'TicketLabel'] = 2
df.loc[(df['TicketCount']>=5) & (df['TicketCount']<=8) | (df['TicketCount']==1), 'TicketLabel'] = 1
df.loc[(df['TicketCount']>=11), 'TicketLabel'] = 0 # 9,10はいない 11は全滅
```

### 2.2.3 Ticketの頭文字

こちらは、前述の仮説ほど強い確証はありませんでしたが、Ticketの先頭文字には何らかの情報が含まれている可能性がある（たとえば、乗客のグループや船室の場所を示すなど）という仮説のもと実施しました。

結果、スコア向上に寄与したため採用。（深堀りはそこまでしてません、、、😅）
ちなみに、`Cabin`も実施しましたがこちらは向上しなかったため使いませんでした。`Cabin`は単純に頭文字を特徴量とする方がよかった。

```python
# 先頭文字を特徴量とする
df['TicketHead']=df['Ticket'].str.get(0)
print_survive_percentage('TicketHead', df)
```

出力
```
TicketHead
1    0.630137
2    0.464481
3    0.239203
4    0.200000
5    0.000000
6    0.166667
7    0.111111
8    0.000000
9    1.000000
A    0.068966
C    0.340426
F    0.571429
L    0.250000
P    0.646154
S    0.323077
W    0.153846
```

出力数を見て、条件分岐書くのが憂鬱になり関数作成。
```python
def assign_survival_rate_groups(df, column):
    # 指定されたカラムの値ごとの生存率の平均を計算
    survival_rates = df['Survived'].groupby(df[column]).mean()
    # 生存率をもとにカラムの値を3つのグループに分けるためにソート
    sorted_values = survival_rates.sort_values()
    # 3つのグループに分けるための閾値を設定
    n = len(sorted_values)
    low_threshold = sorted_values.iloc[n // 3]
    mid_threshold = sorted_values.iloc[2 * n // 3]
    # グループ分けする関数を定義
    def assign_group(value):
        if pd.isna(value):
            return 'unknown'
        survival_rate = survival_rates[value]
        if survival_rate <= low_threshold:
            return 0 #'low'
        elif survival_rate <= mid_threshold:
            return 1 #'mid'
        else:
            return 2 #'high'
    # グループを指定されたカラムに基づいて割り当てる
    new_column_name = f'{column}Group'
    df[new_column_name] = df[column].apply(assign_group)
    return df

df = assign_survival_rate_groups(df, 'TicketHead')
```

# 試してみて意外だったこと
- `Embarked`は残したほうがよい。
- `isAlone`は`FamilyLabel`を作ったら不要になった。
- `Cabin`の有無のみで特徴量を作成したが効果出ず。
- `assign_survival_rate_groups`で`FamilySize`なども自動で振り分けるようにしたところ、スコアは悪化。人間の手で振り分けたほうがよいこともあるらしい。
- 最初`assign_survival_rate_groups`のグルーピング後の値を0,1,2ではなく `low`, `middle`, `high` にしていて、ダミー変数で学習していたが、数値化したらスコアが向上した。最終的にこの修正で80%を超えた。特徴量が多すぎた...??
- 層化k分割交差検証が、細かい調整時の当てにならなかった。検証では向上しているのに、Kaggleでは悪化すること多々。
- モデルは`LogisticRegression`, `XGBoost`も試したが、`RandomForestClassifier`のほうが良かった。

# 最後に
`Titanic`関連の記事はネット上にたくさんあり、参考にすればすぐ80%を超えることはできますが、自分の理解が及ぶ、無理のない範囲内でのアレンジで80%を超えることを目指す。というアプローチは今回かなり良い勉強になったと思います。
また、今回は良くも悪くも`sns`や`matplotlib`を使ったデータの可視化は特にせずに乗り切ってしまいました。
これから更にスコアを伸ばすためには、データの深い理解のためにも可視化は必須のスキルだと思いますので、しっかり身につけていきたいと思います。（あとは難しそうですが、パラメータチューニングもゆくゆくは...）
以上、最後までご覧いただきありがとうございました!!

※ 追記：後ほど、パラメータチューニングは`GridSearchCV`であらゆるパターンを試してみましたが、意外にももともとのパラメータが一番良い結果となりました。ローカルでは良い結果になるけど、kaggle提出時には下がるという現象。。。