⛴️

Titanic で特徴量エンジニアリングと生存予測

に公開

Kaggle に上げろって感じですが,自分の備忘録として Zenn にも上げます.

seaborn に titanic dataset があるのでそれを使って,特徴量エンジニアリングと生存予測モデルの構築を試してみた.これはそのレポート.

import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
import category_encoders as ce

# データの準備
df_orig = sns.load_dataset('titanic')
df_orig.info()
    <class 'pandas.core.frame.DataFrame'>
    RangeIndex: 891 entries, 0 to 890
    Data columns (total 15 columns):
     #   Column       Non-Null Count  Dtype   
    ---  ------       --------------  -----   
     0   survived     891 non-null    int64   
     1   pclass       891 non-null    int64   
     2   sex          891 non-null    object  
     3   age          714 non-null    float64 
     4   sibsp        891 non-null    int64   
     5   parch        891 non-null    int64   
     6   fare         891 non-null    float64 
     7   embarked     889 non-null    object  
     8   class        891 non-null    category
     9   who          891 non-null    object  
     10  adult_male   891 non-null    bool    
     11  deck         203 non-null    category
     12  embark_town  889 non-null    object  
     13  alive        891 non-null    object  
     14  alone        891 non-null    bool    
    dtypes: bool(2), category(2), float64(2), int64(4), object(5)
    memory usage: 80.7+ KB
# 分析に必要なカラムのみに絞る
df = df_orig[['survived', 'pclass', 'sex', 'age', 'fare', 'embarked', 'class']]
df.dropna(inplace=True)

One-Hot Encoding

ここで性別 sex,乗船 embarked には順序がないので,One-Hot Encoding する.category_encoder を使ってみる.

# 1. One-Hot Encodingの実行
# 名義変数を選択
nominal_cols = ['sex', 'embarked']

# OneHotEncoderを初期化
# use_cat_names=True でカラム名が 'sex_male' のようになる
ohe = ce.OneHotEncoder(cols=nominal_cols, use_cat_names=True)

# エンコードを適用
df_encoded_nominal = ohe.fit_transform(df)

# 元の数値データと結合 (OneHotEncoderは元のカラムを削除するので結合は不要)
df_ohe = pd.concat([df_encoded_nominal, df[['age', 'fare', 'survived', 'pclass']]], axis=1)
# 重複カラムを削除
df_ohe = df_ohe.loc[:,~df_ohe.columns.duplicated()]

df_ohe.head()


実行結果 : sex, embark において確かに one-hot encoding できている

Ordinal Encoding

class は順序があるカテゴリなので, Ordinal Encoding する.

# 1. 順序マッピングの定義
# 値が小さい方が等級が低いことを示すようにマッピング
ordinal_mapping = [{
    'col': 'class',
    'mapping': {'Third': 1, 'Second': 2, 'First': 3}
}]

# OrdinalEncoderを初期化
orde = ce.OrdinalEncoder(cols=['class'], mapping=ordinal_mapping)

# エンコードを適用
df_orde = orde.fit_transform(df_ohe)

df_orde.head()


実行結果 : class で Ordinal Encoding できている.よく考えたら既に pclass でエンコードされてた

相関の可視化

object をすべて数字にしたので,seaborn を用いて相関を可視化しよう.

# 相関ヒートマップの可視化
plt.figure(figsize=(10, 8))
# 今回はpclass(数値)とclass(エンコード後)はほぼ同じ情報なので、classのみに注目
sns.heatmap(df_orde.corr(), annot=True, cmap='viridis', fmt='.2f')
plt.title('Correlation Heatmap with Ordinal Encoded Variables')
plt.show()


相関図 : 特に男女と生存率が大きい関係があることがわかる.他は class が関係しているかもしれない

さらに相関を調査する

countplot,barplot を用いて実際の分布を描画してどのようになっているか観察しよう

fig, axes = plt.subplots(
    nrows=1, # 行の数
    ncols=2, # 列の数
    figsize=(10, 4) # 図全体のサイズ (幅, 高さ)
)
sns.countplot(data=df, x='sex', hue='survived', ax=axes[0])
sns.barplot(data=df, x='class', y='survived', ax=axes[1])

plt.tight_layout()
plt.show()


barplot のひげはブートストラップ法で推定された95%信頼区間である.

どう見ても sexclass は関係しているようだ.

交互作用特徴量の作成

sns.catplot(data=df, x='sex', y='survived', col='class', kind='bar')
# 性別と階級ごとの生存の確率について調査する.

この関係を踏まえると,特に高階級の女の生存率は突出していることがわかる.そこで

1_male, 1_female, 2_male, 2_female 3_male, 3_female という特徴量を作成するとよさそうである.

非線形な特徴の明示

相関は非線形な関係は捉えられない.これについて histplot で調査する.

sns.histplot(data=df, x='age', hue='survived', kde=True, multiple='stack', binwidth=5)
# 年齢横軸,数縦軸,生死で分割
# kde Kernel Density Estimation 推定された元分布の密度関数
# 分割されたときの瓶の配置の仕方 `stack`
# binwidth = 幅 5

5歳以下で生存者の割合が突出して高くなっていること,65歳以上で生存者の割合がほとんどないことを確認できる.

そこで年齢をそのまま使うのではなく、

  • 5歳以下 子供
  • 65歳以上 老人
  • それ以外を 大人

とするカテゴリ変数に変換するとよさそうである.

複雑な関係の調査

出発地ごとに生存率が変わるか確認する.

# 客室等級(class)でグラフを分割して表示
sns.catplot(x='sex', y='survived', col='class', row='embarked', data=df, kind='bar')
plt.show()

場所ごとに特別な違いは見られない.(Q出発の class First, Second の人間はそもそも数が少ないのであまり参考にならない)

年齢と性別と生存率に関係があるのか確認する.

sns.histplot(data=df[df['sex'] == 'male'],x='age',hue='survived', multiple='stack',binwidth=5)
sns.histplot(data=df[df['sex'] == 'female'],x='age',hue='survived', multiple='stack',binwidth=5, palette=['c','r'])


手前側が女,奥にいるのが男の結果

女の子供 (10歳以下) は男とほぼ生存率が変わらないことがわかる.
一方で 10歳以上の女は男より生存率が高いことがわかる.

これを考慮した特徴量

  • 5歳未満の女
  • 5歳以上10歳未満の女
  • 10歳以上の女
  • 5歳未満の男
  • 5歳以上の男

さらにこれらに階級 1,2,3 を組み合わせた特徴量を作成すればよさそうである.

さらに Deck と生存率に関係があるか調べる.

# まず欠損値を `U` で埋める
df['deck_imputed'] = df_orig['deck'].cat.add_categories('U').fillna('U')

sns.barplot(data=df,x='deck_imputed',y='survived')

  • B,D,E は 特に生存率が高いことがわかる. G は信頼度が低い U は特に生存率が低い

家族の人数と生存率に関係があるか調べる.

df['member_num'] = df_orig['sibsp'] + df_orig['parch'] + 1

sns.barplot(data=df,x='member_num',y='survived')


2~4 は生存率は高く, 1,5,6,7,8 は生存率が低いと思われる.

特徴量の作成と学習

特徴量はパイプラインを使って"堅牢に"作成する方がいい.ただしデータクリーニングは事前に済ませよう.パイプラインは副作用で要素数が変化することを想定していない.

インポートとデータの読み込み

import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler, OneHotEncoder, FunctionTransformer
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
# XGBoost Classifierをインポート
from xgboost import XGBClassifier
from sklearn.metrics import accuracy_score

df = sns.load_dataset('titanic')

データクリーニング

deck の NaN を U で埋める.

df['deck'] = df['deck'].cat.add_categories('U').fillna('U')
df.dropna(inplace=True)
df.info()
<class 'pandas.core.frame.DataFrame'>
Index: 712 entries, 0 to 890
Data columns (total 15 columns):
 #   Column       Non-Null Count  Dtype   
---  ------       --------------  -----   
 0   survived     712 non-null    int64   
 1   pclass       712 non-null    int64   
 2   sex          712 non-null    object  
 3   age          712 non-null    float64 
 4   sibsp        712 non-null    int64   
 5   parch        712 non-null    int64   
 6   fare         712 non-null    float64 
 7   embarked     712 non-null    object  
 8   class        712 non-null    category
 9   who          712 non-null    object  
 10  adult_male   712 non-null    bool    
 11  deck         712 non-null    category
 12  embark_town  712 non-null    object  
 13  alive        712 non-null    object  
 14  alone        712 non-null    bool    
dtypes: bool(2), category(2), float64(2), int64(4), object(5)
memory usage: 70.0+ KB

特徴量生成用の関数

  • sex age class から構成されるカスタム特徴量の作成関数を用意する.
# 特徴量とターゲットを分離
X = df.drop('survived', axis=1)
y = df['survived']

# --- カスタム特徴量生成関数 ---
def create_custom_features(dataf):
    """
    年齢、性別、階級から交互作用特徴量を作成する。
    パイプラインで使いやすいように、新しい特徴量の列だけを返す。
    """
    # 入力はコピーして使うのが安全
    df_copy = dataf.copy()
    
    # 年齢と性別のグループ分け
    conditions = [
        (df_copy['sex'] == 'female') & (df_copy['age'] < 5),
        (df_copy['sex'] == 'female') & (df_copy['age'] < 10) & (df_copy['age'] >= 5),
        (df_copy['sex'] == 'female') & (df_copy['age'] >= 10),
        (df_copy['sex'] == 'male') & (df_copy['age'] < 5),
        (df_copy['sex'] == 'male') & (df_copy['age'] >= 5)
    ]
    choices = [
        'Female_Child(<10)',
        'Female_MChild(<10,>=5)',
        'Female_Adult(>=10)',
        'Male_Child(<5)',
        'Male_Adult(>=5)'
    ]
    df_copy['age_sex_group'] = np.select(conditions, choices, default='Other')
    
    # 最終的な交互作用特徴量
    df_copy['age_sex_class_group'] = df_copy['age_sex_group'] + '_Class' + df_copy['pclass'].astype(str)
    
    # 新しく作成した列だけを2D配列/DataFrameの形で返す
    return df_copy[['age_sex_class_group']]
  • 家族の人数に依存する特徴量も作ってみる.
def create_member_features(dataf):
    dataf['member_num'] = dataf['sibsp'] + dataf['parch'] + 1
    return dataf[['member_num']]

前処理パイプラインの定義

前処理をパイプラインで統一的に処理する.

# --- 前処理パイプラインの定義 ---

# 1. 数値特徴量用のパイプライン (今回は'fare'のみ)
#    - 標準化を行う
numeric_features = ['fare']
numeric_transformer = Pipeline(steps=[
    ('scaler', StandardScaler())
])

# 2. カテゴリカル特徴量用のパイプライン ('embarked', 'deck' はない方が性能がいいらしい・・・)
#    - One-Hot Encodingを行う
categorical_features = ['embarked']
categorical_transformer = Pipeline(steps=[
    ('onehot', OneHotEncoder(handle_unknown='ignore'))
])

# 3. カスタム特徴量生成用のパイプライン
#    - 'age', 'sex', 'pclass' を入力として受け取る
#    - FunctionTransformerでカスタム関数を適用
#    - その結果('age_sex_class_group')をOne-Hot Encodingする
age_sex_class_cols = ['age', 'sex', 'pclass']
age_sex_class_cols_transformer = Pipeline(steps=[
    ('custom_creator', FunctionTransformer(create_custom_features)),
    ('custom_onehot', OneHotEncoder(handle_unknown='ignore', sparse_output=False))
])

# 4. メンバー数を計算するやつ
member_cols = ['sibsp', 'parch']
member_transformer = Pipeline(steps=[
    ('member', FunctionTransformer(create_member_features))
])

# --- ColumnTransformerで上記のパイプラインを統合 ---
preprocessor = ColumnTransformer(
    transformers=[
        ('num', numeric_transformer, numeric_features),
        ('cat', categorical_transformer, categorical_features),
        ('age_sex_class', age_sex_class_cols_transformer, age_sex_class_cols),
        # ('member', member_transformer, member_cols)
        # 精度 1%くらいしか上がらないし,ハイパーパラメータ最適化すると過学習する
    ],
    remainder='drop' # ここで出力されなかったカラムは削除される
)

機械学習との結合

前処理パイプラインを機械学習とつなぐ.

# --- 前処理とモデルを連結した最終的なパイプライン ---
model_pipeline = Pipeline(steps=[
    ('preprocessor', preprocessor),
    ('classifier', XGBClassifier(
        objective='binary:logistic', # 目的関数: 2値分類
        eval_metric='logloss',       # 評価指標: logloss (警告表示を抑制)
        use_label_encoder=False,     # LabelEncoderの使用を非推奨にする (警告表示を抑制)
        random_state=42              # 結果の再現性をための乱数シード
    ))
])

# --- データの分割とモデルの学習・評価 ---
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# パイプライン全体を学習させる (前処理とモデル学習が一度に行われる)
model_pipeline.fit(X_train, y_train)

# 評価
y_pred = model_pipeline.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)

print(f"パイプライン全体の処理が完了しました。")
print(f"テストデータに対する精度: {accuracy:.4f}")
/home/tt/python/kaggle/v/lib/python3.13/site-packages/xgboost/training.py:199: UserWarning: [01:42:23] WARNING: /workspace/src/learner.cc:790: 
Parameters: { "use_label_encoder" } are not used.

  bst.update(dtrain, iteration=i, fobj=obj)


パイプライン全体の処理が完了しました。
テストデータに対する精度: 0.8042

色々実験したが

  • deck はない方が性能が上がるらしい.
  • member は知っていると 1%くらい性能があがるが,ハイパーパラメータチューニングすると過学習してうまくいかなくなる
  • U について順序がわかればもう少し性能があがるのかな

という感想

ハイパーパラメータチューニング

パイプラインを使ってハイパーパラメータのチューニングも簡単にできる.

from sklearn.model_selection import GridSearchCV

# チューニングしたいパラメータを指定 (パイプラインのステップ名__パラメータ名)
param_grid = {
    'classifier__n_estimators': [100, 200, 300],
    'classifier__learning_rate': [0.01, 0.05, 0.1],
    'classifier__max_depth': [3, 5, 7]
}

# GridSearchCVの実行
grid_search = GridSearchCV(model_pipeline, param_grid, cv=5, n_jobs=-1, verbose=1)
grid_search.fit(X_train, y_train)

print(f"最適なパラメータ: {grid_search.best_params_}")
print(f"最適なスコア: {grid_search.best_score_:.4f}")

# 最適なモデルで評価
best_model = grid_search.best_estimator_
y_pred_tuned = best_model.predict(X_test)
accuracy_tuned = accuracy_score(y_test, y_pred_tuned)
print(f"チューニング後のテストデータに対する精度: {accuracy_tuned:.4f}")
Fitting 5 folds for each of 27 candidates, totalling 135 fits
bst.update(dtrain, iteration=i, fobj=obj)

最適なパラメータ: {'classifier__learning_rate': 0.1, 'classifier__max_depth': 7, 'classifier__n_estimators': 200}
最適なスコア: 0.8085
チューニング後のテストデータに対する精度: 0.8112

AI の考察と提案

結局,

  1. なぜ deck がないほうが性能が上がるのか?
  2. なぜ member があるとハイパーパラメータチューニングすると過学習するのか?

という点が謎である.これを AI に聞いた.

考察1:なぜ deck はない方が性能が上がったのか?

deckは直感的には「船のどの位置にいたか」という強力な情報のはずです。それなのに性能が低下したのは、主に以下の2つの理由が考えられます。

1. 情報の「量」と「質」のアンバランス(最も可能性が高い理由)

  • 情報の希薄化: deckカラムは77%以上が欠損しており、それをすべて'U'というカテゴリで埋めました。これはつまり、One-Hot Encoding後、deck_Uという特徴量が作成され、データ全体の約8割でこの特徴量が1になることを意味します。モデルにとって、ほとんどの値が同じであるこの特徴量は、有益な情報をほとんど持ちません。
  • ノイズの増加: 一方で、情報が存在する残りのdeckカテゴリ(A, B, Cなど)は、それぞれがデータ全体のごく一部しか占めていません。これらの非常に**スパース(疎)**な特徴量は、モデルが汎用的なルールを学ぶにはデータが少なすぎ、訓練データに特有のノイズを拾ってしまう原因になります。
  • 結論: 結果として、deckカラムを追加したことは、**「一つの役に立たない特徴量 (deck_U) と、多数のノイズになりやすい特徴量 (deck_A, deck_B...) を追加した」**ことになり、モデル全体の汎化性能を低下させてしまったと考えられます。

2. pclass との情報重複(多重共線性)

  • deckの位置は、pclass(客室等級)と非常に強く相関しています(例: 上層のA, B, Cデッキはほとんどが1等客室)。
  • モデルは既にpclassから「社会経済的地位」や「船内での大まかな位置」という重要な情報を学習しています。そこにdeckという非常によく似た情報を追加したことで、モデルに冗長な情報を与えてしまった可能性があります。
  • deckが持っていた有益な情報は、ほぼ全てpclassによってカバーされており、残りの部分はノイズだったと解釈できます。

【次のアクション案】
もしdeckを再度試すなら、One-Hot Encodingではなく、順序特徴量として「デッキの高さ」を表現する(例: G=1, F=2, ... A=8, U=0)などの工夫をすれば、次元数を増やさずに位置情報だけを伝えられるかもしれません。


考察2:なぜ FamilySize は性能を上げるが、チューニングで過学習するのか?

これは非常に興味深い現象で、特徴量の「強さ」とモデルの「複雑さ」の関係を示しています。

1. なぜベースの性能が上がったのか?

  • これは、FamilySize(またはFamilySize_Group)が、SibSpParchを個別に見るよりも、生存予測においてより本質的で強力な情報であることを明確に示しています。
  • 「一人であること」「2〜4人の小家族であること」「5人以上の大家族であること」では、生存戦略や救助の優先順位が異なった、という仮説が裏付けられています。モデルはこの強力なシグナルを捉え、性能が向上しました。

2. なぜハイパーパラメータチューニングで過学習したのか?

  • 強力な特徴量への過信: ハイパーパラメータチューニング(特にGridSearchなど)は、訓練データに対するスコアが最も高くなるパラメータを探します。FamilySizeという非常に強力な特徴量が存在すると、モデルはそれに過度に依存し始めます。
  • モデルの複雑化: 最適化の結果、モデルの表現力を上げるパラメータ(例: max_depthを深くする、min_child_weightを小さくする)が選ばれがちになります。これにより、モデルはFamilySizeと他の特徴量の非常に細かい交互作用まで学習しようとします。
  • ニッチなルールの生成: 例えば、「FamilySize_Groupが'Small_Family'で、かつpclassが2で、かつembarkedがS」のような、訓練データには存在するが、テストデータには存在しないかもしれないニッチなパターンを過剰に学習してしまいます。これがまさに過学習です。強力な特徴量があるほど、モデルはその特徴量に「しがみつき」、訓練データのノイズまで覚えようとしてしまうのです。

【次のアクション案】
この状況を打開するには、モデルの複雑さを意識的に抑制する方向でチューニング戦略を見直す必要があります。

  1. 正則化パラメータを導入する: reg_alpha (L1正則化) や reg_lambda (L2正則化) をチューニングの探索範囲に加えます。これらはモデルの重みにペナルティを課し、過学習を抑制します。
  2. モデルの複雑さを直接制限する: max_depth(木の深さ)の上限を意図的に低く設定します(例: [3, 4, 5, 6])。
  3. 学習プロセスにランダム性を加える: subsample(各木で使うデータの割合)やcolsample_bytree(各木で使う特徴量の割合)を1未満(例: [0.7, 0.8, 0.9])に設定し、毎回少し違うデータ・特徴量で木を作ることで、汎化性能を高めます。

今回の実験は、「特徴量を追加すれば必ず性能が上がるわけではない」こと、そして「強力な特徴量とモデルの複雑さはトレードオフの関係にある」ことを示す、非常に価値のある学びと言えるでしょう。


まあ,今回はこれくらいでいいだろう.XGBoost 等の決定木の数学について気になる人は以下を閲覧してください.

https://zenn.dev/2201/scraps/e0055d7c06db6a

Discussion