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%信頼区間である.
どう見ても sex と class は関係しているようだ.
交互作用特徴量の作成
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
特徴量生成用の関数
-
sexageclassから構成されるカスタム特徴量の作成関数を用意する.
# 特徴量とターゲットを分離
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 の考察と提案
結局,
- なぜ
deckがないほうが性能が上がるのか? - なぜ
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)が、SibSpとParchを個別に見るよりも、生存予測においてより本質的で強力な情報であることを明確に示しています。 - 「一人であること」「2〜4人の小家族であること」「5人以上の大家族であること」では、生存戦略や救助の優先順位が異なった、という仮説が裏付けられています。モデルはこの強力なシグナルを捉え、性能が向上しました。
2. なぜハイパーパラメータチューニングで過学習したのか?
-
強力な特徴量への過信: ハイパーパラメータチューニング(特にGridSearchなど)は、訓練データに対するスコアが最も高くなるパラメータを探します。
FamilySizeという非常に強力な特徴量が存在すると、モデルはそれに過度に依存し始めます。 -
モデルの複雑化: 最適化の結果、モデルの表現力を上げるパラメータ(例:
max_depthを深くする、min_child_weightを小さくする)が選ばれがちになります。これにより、モデルはFamilySizeと他の特徴量の非常に細かい交互作用まで学習しようとします。 -
ニッチなルールの生成: 例えば、「
FamilySize_Groupが'Small_Family'で、かつpclassが2で、かつembarkedがS」のような、訓練データには存在するが、テストデータには存在しないかもしれないニッチなパターンを過剰に学習してしまいます。これがまさに過学習です。強力な特徴量があるほど、モデルはその特徴量に「しがみつき」、訓練データのノイズまで覚えようとしてしまうのです。
【次のアクション案】
この状況を打開するには、モデルの複雑さを意識的に抑制する方向でチューニング戦略を見直す必要があります。
-
正則化パラメータを導入する:
reg_alpha(L1正則化) やreg_lambda(L2正則化) をチューニングの探索範囲に加えます。これらはモデルの重みにペナルティを課し、過学習を抑制します。 -
モデルの複雑さを直接制限する:
max_depth(木の深さ)の上限を意図的に低く設定します(例:[3, 4, 5, 6])。 -
学習プロセスにランダム性を加える:
subsample(各木で使うデータの割合)やcolsample_bytree(各木で使う特徴量の割合)を1未満(例:[0.7, 0.8, 0.9])に設定し、毎回少し違うデータ・特徴量で木を作ることで、汎化性能を高めます。
今回の実験は、「特徴量を追加すれば必ず性能が上がるわけではない」こと、そして「強力な特徴量とモデルの複雑さはトレードオフの関係にある」ことを示す、非常に価値のある学びと言えるでしょう。
まあ,今回はこれくらいでいいだろう.XGBoost 等の決定木の数学について気になる人は以下を閲覧してください.
Discussion