TitanicコンペでXGBoost×特徴量エンジニアリングにより78.7%を達成した話
はじめに
KaggleのTitanicコンペで、モデルの選定から特徴量の工夫まで試行錯誤しながらスコアを上げた記録です。最終的に**78.708%**を達成しました。
どの工夫が効いてどれが効かなかったか、スコアの推移とともに解説します。
XGBoostとは
XGBoost(Extreme Gradient Boosting)は、勾配ブースティングという手法を高速・高精度に実装した機械学習ライブラリです。
決定木を何本も順番に作っていき、前の木が間違えた部分を次の木が補正する繰り返しでモデルの精度を上げていきます。ランダムフォレストが複数の木を並列に作って多数決するのとは対照的です。過学習を抑えるための正則化が組み込まれており、Kaggleのコンペでも広く使われている定番モデルです。
特徴量エンジニアリングとは
元々のデータから新しい特徴量を作り出すことで、モデルの予測精度を高める手法です。
例えば「SibSp(兄弟・配偶者の数)」と「Parch(親・子供の数)」を別々に使うより、FamilySize = SibSp + Parch + 1として家族人数として統合した方がモデルが学習しやすくなります。+1は自分自身をカウントに加えるためです。データの背景にある意味を考えながら新しい変数を設計することがポイントです。
データの確認(EDA)
特徴量を選ぶ前に、各データと生存率の関係を確認しました。
生存率との関係が顕著なものは特徴量の候補になりますが、必ずしも「関係が顕著 = 採用」ではありません。関係がわかりにくいデータでも実際にモデルに入れてみると効くことがあり、逆に関係がありそうに見えても逆効果になることもあります。EDAはあくまで候補を絞るための参考で、最終的にはスコアへの影響を見て判断しています。
import matplotlib.pyplot as plt # グラフ描画ライブラリ
import seaborn as sns # 統計データの可視化ライブラリ
%matplotlib inline # notebookにグラフをインライン表示する設定
Pclass(チケットのランク)
# Pclassごとの生存者数・死亡者数を棒グラフで可視化
# hue='Survived'で生存(1)・死亡(0)を色分けして表示
sns.countplot(x='Pclass', hue='Survived', data=train)
plt.title('Pclass vs Survived')
plt.show()
チケットのランクが高いほど生存率が高い傾向がありました。1等客室の乗客は優先的に救助された可能性があります。→ 特徴量として採用
Sex(性別)
# 性別ごとの生存者数・死亡者数を棒グラフで可視化
sns.countplot(x='Sex', hue='Survived', data=train)
plt.title('Sex vs Survived')
plt.show()
女性の方が生存率が大きく高く、「女性・子供優先」の原則がデータにも表れていました。→ 特徴量として採用
Title(敬称)
# 名前から「, 」の後の「. 」の前の部分(敬称)を抽出
train['Title'] = train['Name'].map(lambda x: x.split(', ')[1].split('. ')[0])
# 少数の敬称をOthersにまとめてカテゴリを整理
train['Title'] = train['Title'].replace(
['Dr','Rev','Mlle','Major','Col','the Countess','Capt',
'Ms','Sir','Lady','Mme','Don','Jonkheer'], 'Others')
# 敬称ごとの生存率(mean)と人数(count)を集計して確認
print(train['Survived'].groupby(train['Title']).agg(['mean', 'count']))
Mrs・Missの生存率が高く、Mrの生存率が低い結果でした。性別情報と重複しますが、年齢層も含む情報として独立した特徴量として有効と判断しました。→ 特徴量として採用
SibSp・Parch(家族構成)
# SibSpとParchを横並びで可視化するため1行2列のグラフ領域を作成
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
# SibSp(兄弟・配偶者の数)ごとの平均生存率を棒グラフで表示
sns.barplot(x='SibSp', y='Survived', data=train, palette='Set1', ax=axes[0])
# Parch(親・子供の数)ごとの平均生存率を棒グラフで表示
sns.barplot(x='Parch', y='Survived', data=train, palette='Set1', ax=axes[1])
plt.show()
SibSpが大きいと生存率が低下する傾向があり、Parchはばらつきがありました。単体で使うより家族人数としてまとめた方が効果的と考え、FamilySize = SibSp + Parch + 1として統合しました。+1は自分自身をカウントに加えるためです(SibSpとParchは周囲の人数のみで自分を含まないため)。→ FamilySizeとして特徴量に採用
Fare(運賃)
# 生存・死亡ごとの運賃の分布を密度曲線(KDE)で重ねて表示
fare_s = sns.FacetGrid(train, hue='Survived', aspect=3)
fare_s.map(sns.kdeplot, 'Fare', shade=True) # shade=Trueで曲線下を塗りつぶす
fare_s.set(xlim=(0, train['Fare'].max())) # x軸の範囲を0〜最大運賃に設定
fare_s.add_legend()
plt.show()
運賃が低いほど生存率が低い傾向がありました。ただし家族で1枚のチケットを共有しているケースがあるため、FarePerPerson = Fare / FamilySizeとして1人あたりの運賃に変換しました。→ FarePerPersonとして特徴量に採用
Embarked(出港地)
# 出港地ごとの平均生存率を棒グラフで表示
sns.barplot(x='Embarked', y='Survived', data=train, palette='Set1')
plt.title('Embarked vs Survived')
plt.show()
出港地によって生存率に差がありました。C・Q・Sの3カテゴリに順序関係はないため、そのまま数値化(C=0, Q=1, S=2)すると「S > Q > C」という存在しない順序関係をモデルが学習してしまいます。そのためダミー変数化(one-hot encoding)でC・Q・Sの3列に分けて、それぞれ0か1で表現しました。→ ダミー変数化して特徴量に採用
Ageの欠損確認
# 各列の欠損値の件数を確認
print(train.isnull().sum())
# PclassごとのAge(年齢)の統計量(平均・中央値など)を確認
print(train.groupby('Pclass').describe()['Age'])
Ageは約20%が欠損していました。またPclassごとに平均年齢が異なることが確認できたため、単純な全体中央値ではなくPclass×Titleの組み合わせごとの中央値で補完しました。
ベースライン:XGBoost基本特徴量(77.272%)
まずXGBoostを選んだ理由は、ランダムフォレストより過学習を抑えやすく、Kaggleのコンペで実績のある定番モデルであるためです。基本的な前処理と特徴量でベースラインを作りました。
前処理
# 性別の数値化
# 機械学習モデルは文字列を扱えないため0・1に変換
train = train.replace({'male': 0, 'female': 1})
test = test.replace({'male': 0, 'female': 1})
# 名前から敬称(Title)を抽出して数値化
# EDAでMrs・Missの生存率が高くMrが低いことを確認したため特徴量として追加
for df in [train, test]:
df['Title'] = df['Name'].map(lambda x: x.split(', ')[1].split('. ')[0])
df['Title'] = df['Title'].replace(
['Dr','Rev','Mlle','Major','Col','the Countess','Capt',
'Ms','Sir','Lady','Mme','Don','Jonkheer','Dona'], 'Others')
df['Title'] = df['Title'].map({'Master':0,'Miss':1,'Mr':2,'Mrs':3,'Others':4})
# Ageの欠損補完(Pclass×Titleの中央値)
# Ageは約20%が欠損。Pclassや敬称により年齢分布が異なるため
# 「1等・Mrs」と「3等・Mr」を別々に中央値補完することでより実態に近い年齢を推定
for df in [train, test]:
df['Age'] = df.groupby(['Pclass', 'Title'])['Age'].transform(
lambda x: x.fillna(x.median()))
df['Age'] = df['Age'].fillna(df['Age'].median())
# EmbarkedとFareの欠損補完
train['Embarked'] = train['Embarked'].fillna(train['Embarked'].mode()[0])
test['Fare'] = test['Fare'].fillna(test['Fare'].median())
# Embarkedのダミー変数化
# C・Q・Sに順序関係はないためone-hot encodingで3列に分ける
for df in [train, test]:
embarked_dum = pd.get_dummies(df['Embarked'], dtype='uint8')
df[embarked_dum.columns] = embarked_dum
特徴量
for df in [train, test]:
df['FamilySize'] = df['SibSp'] + df['Parch'] + 1 # 家族人数
df['HasCabin'] = df['Cabin'].notna().astype(int) # 客室情報の有無(上流階級の指標)
df['FarePerPerson'] = df['Fare'] / df['FamilySize'] # 1人あたりの運賃
df['Deck'] = df['Cabin'].apply( # 客室デッキ情報(船内エリアの代理変数)
lambda x: ord(str(x)[0]) - ord('A') if pd.notna(x) else -1)
df['AgeBin'] = pd.cut(df['Age'], # 年齢の区分け(子供・若者・中年・高齢)
bins=[0,12,18,35,60,100], labels=False)
df['AgeBin'] = df['AgeBin'].fillna(2).astype(int)
モデル
from xgboost import XGBClassifier
from sklearn.model_selection import train_test_split
features = ['Pclass', 'Sex', 'Age', 'AgeBin', 'Fare', 'FarePerPerson',
'Title', 'C', 'S', 'FamilySize', 'HasCabin', 'Deck']
X = train[features].values
y = train['Survived'].values
X_test = test[features].values
X_tr, X_va, y_tr, y_va = train_test_split(X, y, test_size=0.2, random_state=0)
xgb = XGBClassifier(
n_estimators=50, max_depth=4, learning_rate=0.05,
subsample=0.8, colsample_bytree=0.7,
min_child_weight=5, gamma=0,
random_state=0, eval_metric='logloss'
)
xgb.fit(X, y)
pred = xgb.predict(X_test)
→ スコア:77.272%
改善① 過学習の壁にぶつかる
グリッドサーチとは
XGBoostにはmax_depthやn_estimatorsなど、事前に人間が設定するハイパーパラメータがあります。グリッドサーチとは、指定したパラメータの全組み合わせを総当たりで試して、最もスコアが高かった組み合わせを自動的に選ぶ手法です。
from sklearn.model_selection import GridSearchCV
params = {
'max_depth': [3, 4, 5],
'n_estimators': [50, 100],
'learning_rate': [0.01, 0.05, 0.1],
}
grid_cv = GridSearchCV(estimator=xgb, param_grid=params, cv=5, scoring='accuracy')
grid_cv.fit(X_tr, y_tr)
print('最適パラメータ:', grid_cv.best_params_)
print('CVスコア:', grid_cv.best_score_)
cv=5は学習データを5分割して交差検証(クロスバリデーション)を行うことを意味します。5回の平均スコアをCVスコアとして算出するため、1回の検証より信頼性が高いスコアが得られます。
過学習の問題
グリッドサーチでCVスコアが83%以上出ても、実際の提出スコアが76〜77%台にとどまることが何度もありました。これは過学習(学習データには強いが未知データに弱い状態)です。
以下のパラメータを調整して過学習を抑えました。
| パラメータ | 調整内容 | 理由 |
|---|---|---|
max_depth |
深い木(6以上)→浅い木(4) | 木が深いほど学習データに細かく適合しすぎるため、浅くして汎化性能を上げる |
min_child_weight |
小さい値→5 | 葉ノードに必要な最小サンプル数を増やし、少数データへの過剰適合を防ぐ |
subsample |
1.0→0.8 | 学習データの80%をランダムに使うことで、特定のデータへの依存を減らす |
colsample_bytree |
1.0→0.7 | 使用する特徴量を70%にランダム絞り込み、特定の特徴量への依存を減らす |
CVスコアと検証スコアの差が縮まったことを確認してから提出するようにしました。
→ スコア:77.272% → 77.511%
改善② 家族単位の生存率を追加(77.511% → 77.990%)
EDAで「SibSpが大きいと生存率が低下する」ことは確認できていましたが、家族人数だけでなくその家族が実際に何割生存したかを特徴量にすることで精度が上がるのではと考えました。
for df in [train, test]:
df['LastName'] = df['Name'].map(lambda x: x.split(',')[0].strip())
df['FamilyID'] = df['LastName'] + '_' + df['FamilySize'].astype(str)
# 学習データから家族単位の生存率を計算
mean_survival = train['Survived'].mean()
family_survival = train.groupby('FamilyID')['Survived'].mean()
family_count = train.groupby('FamilyID')['Survived'].count()
for df in [train, test]:
df['FamilySurvivalRate'] = df['FamilyID'].map(
lambda x: family_survival[x]
if x in family_survival and family_count[x] >= 2
else mean_survival
)
姓+家族人数で家族IDを作り、その家族の生存率を特徴量として加えました。2人以上の家族のみ信頼できるとして使用し、1人(一人旅)の場合は全体の平均生存率で補完しています。
→ スコア:77.511% → 77.990%
改善③ チケット番号の先頭文字を追加(77.990% → 78.708%)
チケット番号(例:A/5 21171、PC 17599)の先頭文字が船内の乗船エリアを示している可能性があると考え、特徴量として追加しました。
for df in [train, test]:
df['TicketPrefix'] = df['Ticket'].map(
lambda x: x[0] if x[0].isalpha() else '0')
df['TicketPrefix'] = pd.factorize(df['TicketPrefix'])[0]
アルファベットで始まる場合はその文字、数字で始まる場合は'0'として数値化しました。
→ スコア:77.990% → 78.708%(ベスト)
効かなかった工夫
試したが改善しなかったものも記録として残します。
| 試した内容 | 結果 |
|---|---|
| チケット単位の生存率(TicketSurvivalRate) | スコア低下 |
| Pclass×Sexの組み合わせフラグ | スコア低下 |
| Fareの対数変換 | 変化なし |
| Age補完をSexも加えてより細かく | スコア低下 |
| LightGBMへの切り替え | XGBoostより低い |
| XGBoost+LightGBMのアンサンブル | XGBoost単体より低い |
| スタッキング | スコア低下 |
特徴量を増やすほど良くなるわけではなく、データとモデルの相性を見ながら取捨選択することが重要だと感じました。
結果まとめ
| アプローチ | スコア |
|---|---|
| 決定木(基本特徴量) | 73.684% |
| 決定木(前処理改善) | 76.315% |
| ランダムフォレスト | 76.794% |
| ランダムフォレスト(グリッドサーチ) | 77.751% |
| XGBoost(基本特徴量) | 77.272% |
| + 過学習抑制のパラメータ調整 | 77.511% |
| + FamilySurvivalRate追加 | 77.990% |
| + TicketPrefix追加(ベスト) | 78.708% |
学んだこと
1. CVスコア(交差検証スコア)より提出スコアを信じる:
CVスコア(学習データを複数に分割して検証した平均スコア)が83%でも提出スコアが76%になることがありました。CVスコアと検証スコアの差が小さいモデルを選ぶことが大切です。
2. 特徴量の質 > 特徴量の量:
特徴量を増やしすぎると逆効果になることが何度もありました。例えば「チケット単位の生存率」は一見よさそうでしたが、家族単位の生存率と情報が重複していたためスコアが下がりました。追加する前に「この特徴量は既存の特徴量と何が違うのか」を意識することが重要です。
3. 生データより派生データが効く:
元の列をそのまま使うより、FamilySurvivalRateのように「その人の家族がどのくらい生き残ったか」という文脈情報を追加することが大きく効きました。
付録:Claudeへのプロンプト
今回の分析はClaude(Anthropic)に指示を出しながら進めました。この分析を再現するには以下の4段階のプロンプトが参考になります。
STEP1:EDAで各特徴量と生存率の関係を確認
まずデータの中身を可視化して、どの特徴量が生存率と関係しているかを確認します。
KaggleのTitanicコンペに取り組んでいます。
以下のデータについて、各特徴量と生存率(Survived)の関係を
グラフで可視化するコードを書いてください。
【確認したい特徴量】
Pclass, Sex, Name(敬称), Age, SibSp, Parch, Fare, Embarked, Cabin
【環境】
- Kaggle notebook
- データパス:/kaggle/input/competitions/titanic/train.csv
このSTEPで以下のことが確認できました。
- Pclass・Sex・Titleと生存率に強い関係がある
- Ageは約20%が欠損しており、Pclassごとに年齢分布が異なる
- SibSp・Parchは家族構成に関する列
- Fareは運賃で、低いほど生存率が低い傾向がある
- Embarkedは出港地(C・Q・Sの3カテゴリ)で生存率に差がある
STEP2:ベースラインの作成
EDAで確認した結果をそのままAIに共有し、前処理・特徴量の判断を任せます。
KaggleのTitanicコンペで生存予測の精度をできるだけ高めたいです。
XGBoostを使って、Kaggle notebookで上から順に実行できる形でコードを書いてください。
【環境】
- Kaggle notebook
- データパス:/kaggle/input/competitions/titanic/train.csv
【EDAで確認した結果】
- Pclass・Sex・Titleと生存率に強い関係がある
- Ageは約20%が欠損しており、Pclassごとに年齢分布が異なる
- SibSp・Parchは家族構成に関する列
- Fareは運賃で、低いほど生存率が低い傾向がある
- Embarkedは出港地(C・Q・Sの3カテゴリ)で生存率に差がある
上記のEDA結果を踏まえて、どのような前処理・特徴量エンジニアリングが
効果的か判断した上でコードを書いてください。
グリッドサーチによるパラメータチューニングと予測・CSV出力も含めてください。
STEP3:特徴量の追加
ベースラインのスコアを共有し、さらにスコアを上げる特徴量をAIに提案してもらいます。
現在のスコアは77.272%です。
スコアをさらに上げるために効果的な特徴量を提案・追加してください。
現在使用している特徴量:
Pclass, Sex, Age, AgeBin, Fare, FarePerPerson,
Title, C, S, FamilySize, HasCabin, Deck
提案した特徴量を追加した上でスコアへの影響を確認し、
効果があるものだけ採用する形で進めてください。
STEP4:過学習の対処
CVスコアは高いのに提出スコアが伸びない場合は、状況をそのまま共有して対処を任せます。
CVスコア(交差検証スコア)が83%出ているのに、
Kaggleへの提出スコアが76%台にとどまっています。
過学習が起きていると思うので、パラメータを調整して改善したいです。
CVスコアと検証スコアの差が小さくなるよう調整案を提案してください。
スコアの結果を都度共有しながら次の手を相談する対話形式で進めることで、試行錯誤のスピードが大幅に上がった印象です。特に「効かなかった特徴量はすぐ外す」という判断をClaudeと一緒にできたことが、無駄な試行を減らすことにつながりました。