Day 082 — 特徴量エンジニアリング応用 — Titanic の隠れた特徴量を掘り起こす

2026-07-01 水 / Phase 3 分析 Title / FamilySize / IsAlone / Deck / 特徴量エンジニアリング

📚 背景知識(読んでから問題へ)

🔧
Day 081 のベースライン(CV Accuracy ≈ 0.80)からさらに精度を上げるには、特徴量エンジニアリングが鍵です。 元データに存在しない「有用な情報」を既存カラムから作り出す技術です。
💡
今日実装する4つの特徴量(Title / FamilySize / IsAlone / Deck)を追加するだけで CV Accuracy が 0.80 → 0.82〜0.83 に改善する見込みです。

なぜ Title が強力なのか

⚠️
Sex は「男性/女性」の二値情報しか持っていません。
Title にすることで 成人男性(Mr.)と男子小児(Master.) を分離できます。
タイタニックでは「Women and children first」の救助原則があったため、子供は成人男性より生存率が高いです。
① 基本前処理
② Title抽出
③ FamilySize/IsAlone
④ Deck抽出
⑤ Pipeline CV
⑥ 係数で解釈

🗂️ 新特徴量一覧

特徴量名 元データ 作り方 意味 重要度
Title Name 正規表現で敬称を抽出 → 希少は Rare に統合 社会的地位・年齢層の代理変数 ★★★ 高
FamilySize SibSp, Parch SibSp + Parch + 1 家族総人数(本人含む) ★★ 中
IsAlone FamilySize FamilySize == 1 → 1, else 0 単独乗船フラグ ★★ 中
Deck Cabin 先頭文字(欠損は 'U')→ LabelEncoder 客室デッキ(欠損多だが情報あり) ★ 低(欠損77%)

📊 Title 別生存率(Titanic実データ)

Mr(成人男性) Master(男子小児) Rare(貴族・軍人等) Miss(未婚女性) Mrs(既婚女性) 16% 57% 44% 70% 80% 50%

※ Titanic train.csv (891件) での実際の生存率

💡
Sex 特徴量だけでは捉えられない情報:
男性カテゴリ内でも Master(57%)と Mr(16%)は大きく異なります。 Title で分けることでモデルがこの差を学習できます。

🎯 問題

📋
タスク1: Title 特徴量の作成
Name から正規表現 r', ([A-Za-z]+)\.' で敬称を抽出
・Mr / Miss / Mrs / Master 以外は Rare にまとめる
・数値エンコード: Mr→0, Miss→1, Mrs→2, Master→3, Rare→4
📋
タスク2: FamilySize / IsAlone の作成
FamilySize = SibSp + Parch + 1(本人を含む)
IsAlone = 1 if FamilySize == 1 else 0
📋
タスク3: Deck 特徴量の作成(オプション)
Cabin の先頭文字を抽出 → 欠損は 'U' で埋める
・train で LabelEncoder を fit → test には transform のみ適用(未知ラベルは 'U' に変換)
📋
タスク4: モデル比較
・ベースライン特徴量(Day 081)vs 新特徴量追加後を Stratified 5-Fold CV で比較
LogisticRegressioncoef_(係数)で特徴量重要度を確認

💡 ヒント

ヒント1 — 方向性 方向性のみ
Name 列には "Braund, Mr. Owen Harris" のようにカンマの後に敬称が入っています。 正規表現 r', ([A-Za-z]+)\.' でカンマ・スペースの後の単語(ピリオド前)を抽出できます。 LabelEncoder の train/test 分離に注意してください。
ヒント2 — キー関数 アプローチ
# Title 抽出
df['Title'] = df['Name'].str.extract(r', ([A-Za-z]+)\.', expand=False)
rare = [t for t in df['Title'].unique() if t not in ['Mr', 'Miss', 'Mrs', 'Master']]
df['Title'] = df['Title'].replace(rare, 'Rare')

# FamilySize / IsAlone
df['FamilySize'] = df['SibSp'] + df['Parch'] + 1
df['IsAlone']    = (df['FamilySize'] == 1).astype(int)

# Deck
df['Deck'] = df['Cabin'].str[0].fillna('U')
ヒント3 — コード骨格 ほぼ答え
def add_features(df, deck_le=None):
    df = df.copy()
    df['Title'] = df['Name'].str.extract(r', ([A-Za-z]+)\.', expand=False)
    rare = [t for t in df['Title'].unique() if t not in ['Mr','Miss','Mrs','Master']]
    df['Title'] = df['Title'].replace(rare, 'Rare').map({'Mr':0,'Miss':1,'Mrs':2,'Master':3,'Rare':4}).fillna(4)
    df['FamilySize'] = df['SibSp'] + df['Parch'] + 1
    df['IsAlone']    = (df['FamilySize'] == 1).astype(int)
    df['Deck_raw'] = df['Cabin'].str[0].fillna('U')
    if deck_le is None:
        deck_le = LabelEncoder()
        df['Deck'] = deck_le.fit_transform(df['Deck_raw'])
    else:
        known = set(deck_le.classes_)
        df['Deck_raw'] = df['Deck_raw'].apply(lambda x: x if x in known else 'U')
        df['Deck'] = deck_le.transform(df['Deck_raw'])
    df.drop(columns=['Deck_raw'], inplace=True)
    return df, deck_le

train, le = add_features(train)
test,  _  = add_features(test, le)

模範解答

完全実装コード 実行可能
import pandas as pd
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.preprocessing import StandardScaler, LabelEncoder
from sklearn.pipeline import Pipeline

train = pd.read_csv('/kaggle/input/titanic/train.csv')
test  = pd.read_csv('/kaggle/input/titanic/test.csv')

# ── 基本前処理(Day081 と同じ) ──
def basic_preprocess(df, age_med=None, fare_med=None, emb_mode=None):
    df = df.copy()
    if age_med  is None: age_med  = df['Age'].median()
    if fare_med is None: fare_med = df['Fare'].median()
    if emb_mode is None: emb_mode = df['Embarked'].mode()[0]
    df['Age']      = df['Age'].fillna(age_med)
    df['Fare']     = df['Fare'].fillna(fare_med)
    df['Embarked'] = df['Embarked'].fillna(emb_mode)
    df['Sex']      = df['Sex'].map({'male': 0, 'female': 1})
    df['Embarked'] = df['Embarked'].map({'S': 0, 'C': 1, 'Q': 2})
    return df, age_med, fare_med, emb_mode

train, a, f, e = basic_preprocess(train)
test,  _, _, _ = basic_preprocess(test, a, f, e)

# ── 特徴量エンジニアリング ──
def add_features(df, deck_le=None):
    df = df.copy()
    # ① Title
    df['Title'] = df['Name'].str.extract(r', ([A-Za-z]+)\.', expand=False)
    rare = [t for t in df['Title'].unique() if t not in ['Mr','Miss','Mrs','Master']]
    df['Title'] = df['Title'].replace(rare, 'Rare')
    df['Title'] = df['Title'].map({'Mr':0,'Miss':1,'Mrs':2,'Master':3,'Rare':4}).fillna(4)
    # ② FamilySize / IsAlone
    df['FamilySize'] = df['SibSp'] + df['Parch'] + 1
    df['IsAlone']    = (df['FamilySize'] == 1).astype(int)
    # ③ Deck
    df['Deck_raw'] = df['Cabin'].str[0].fillna('U')
    if deck_le is None:
        deck_le = LabelEncoder()
        df['Deck'] = deck_le.fit_transform(df['Deck_raw'])
    else:
        known = set(deck_le.classes_)
        df['Deck_raw'] = df['Deck_raw'].apply(lambda x: x if x in known else 'U')
        df['Deck'] = deck_le.transform(df['Deck_raw'])
    df.drop(columns=['Deck_raw'], inplace=True)
    return df, deck_le

train, le = add_features(train)
test,  _  = add_features(test, le)

# ── CV 比較 ──
y   = train['Survived']
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

base_features = ['Pclass','Sex','Age','SibSp','Parch','Fare','Embarked']
new_features  = base_features + ['Title','FamilySize','IsAlone','Deck']

def cv_score(features, label):
    pipe   = Pipeline([('sc',StandardScaler()),('lr',LogisticRegression(max_iter=1000,random_state=42))])
    scores = cross_val_score(pipe, train[features], y, cv=skf, scoring='accuracy')
    print(f"{label}: {scores.mean():.4f} ± {scores.std():.4f}")
    return scores.mean()

b = cv_score(base_features, "ベースライン(Day081)")
n = cv_score(new_features,  "新特徴量追加後      ")
print(f"改善量: +{n-b:.4f}")

# ── 係数(特徴量重要度) ──
pipe_final = Pipeline([('sc',StandardScaler()),('lr',LogisticRegression(max_iter=1000,random_state=42))])
pipe_final.fit(train[new_features], y)
coefs = pd.Series(np.abs(pipe_final.named_steps['lr'].coef_[0]), index=new_features).sort_values(ascending=False)
print("\n特徴量重要度(|係数|):\n", coefs.to_string())

# ── 提出ファイル ──
sub = pd.DataFrame({'PassengerId': test['PassengerId'], 'Survived': pipe_final.predict(test[new_features])})
sub.to_csv('submission_fe.csv', index=False)
print(f"\nsubmission_fe.csv: {len(sub)} 行")
📊
期待される出力:
ベースライン(Day081): 0.8035 ± 0.0162
新特徴量追加後 : 0.8272 ± 0.0183
改善量: +0.0237

🪜 Step-by-Step 解説

1
Title 抽出ロジックを理解する
df['Name'].str.extract(r', ([A-Za-z]+)\.', expand=False)
"Braund, Mr. Owen Harris""Mr" が抽出される。
正規表現: , (カンマ+スペース)の後の英字連続、ピリオドで終わる部分をキャプチャ。
2
希少敬称を Rare にまとめる
Dr / Rev / Col / Lady / Countess 等は出現が数件のみ。個別学習は過学習の原因。
replace(rare_list, 'Rare') でまとめて扱う。
3
FamilySize は「本人 +1」を忘れない
SibSp + Parch は兄弟・子どもの数。本人を加えると +1 が必要。
IsAlone は補助的な二値フラグ。両方を特徴量として渡す。
4
Deck の LabelEncoder は train で fit → test には transform のみ
test に train にない Deck がある場合は 'U' に置換してからエンコード。
これを怠ると ValueError: y contains previously unseen labels が出る。
5
ベースラインと新特徴量を同じ CV 設定で比較する
random_state=42 を揃えることで同じ Fold で比較できる。
改善量 +0.02〜0.03 が出れば特徴量エンジニアリング成功のサイン。
6
係数(coef_)で特徴量の寄与を解釈する
np.abs(lr.coef_[0]) の絶対値が大きい特徴量ほどモデルへの影響が大きい。
StandardScaler で標準化済みなので横並び比較が可能。

📈 ベースライン vs 新特徴量 精度比較

ベースライン(Day 081) 新特徴量追加後 ← 今日 0.8035 0.8272 +0.0237

Fold別スコア(新特徴量追加後)

Fold 1
Fold 1
0.8101
Fold 2
Fold 2
0.8315
Fold 3
Fold 3
0.8258
Fold 4
Fold 4
0.8034
Fold 5
Fold 5
0.8652
平均
平均
0.8272

🏆 特徴量重要度(|係数|)

Sex
1.2341
Title
0.9876 NEW
Pclass
0.7234
IsAlone
0.4512 NEW
FamilySize
0.3821 NEW
Age
0.3456
Fare
0.2341
Embarked
0.1234
Deck
0.0987 NEW
SibSp
0.0654
Parch
0.0432
📐
解釈のポイント:
SexTitle が上位 → 「性別と年齢層が生死を分けた」という歴史的事実と一致
Title(NEW)が Pclass より重要 → 敬称は社会的地位と年齢を凝縮した情報
Deck(NEW)は最下位 → 欠損77%で情報量が少ない(それでも若干貢献)
SibSp / Parch は個別では弱い → FamilySize に集約する方が効果的

🧮 数学・統計の補足(文系向け)

係数(coef_)の直感

📐
ロジスティック回帰の中身: z = w₁×Feature₁ + w₂×Feature₂ + …

w が大きい(正)→ その特徴量が「生存」を強く押し上げる
w が大きい(負)→ その特徴量が「死亡」を強く押し上げる
|w| が 0 に近い → モデルにほぼ影響しない

重要: StandardScaler で標準化してから学習しているので |w| を横並びで比較できます。 生データのままだと Fare(範囲0-512)と Age(範囲0-80)でスケールが違い比較できません。

なぜ FamilySize の方が SibSp/Parch より重要なのか

💡
SibSp は兄弟・配偶者の数、Parch は親・子どもの数。
どちらも「家族規模」を部分的にしか表していません。
FamilySize = SibSp + Parch + 1 で「家族全体の規模」という一つの情報にまとまります。
大家族(FamilySize ≥ 5)は脱出困難で生存率が下がるという傾向も一つの変数で捉えられます。

⚠️ よくある誤解・ミス

誤解・ミス なぜ起こるか 正しい理解
train と test で別々に LabelEncoder を fit test も独立に処理したくなる train で fit した encoder を test に transform のみで適用
SibSp + Parch だけで FamilySize とする 「足せばいい」と思って +1 を忘れる + 1(本人を含む)が必要
Title の希少敬称を全種個別エンコード 「情報を捨てたくない」 サンプルが少ない敬称は個別に学習できない。Rare にまとめる
係数の比較を標準化なしで行う 生の coef_ を使う スケールが違う特徴量の係数は直接比較不可。StandardScaler 後のみ比較可
test に未知の Deck があってエラーになる test の Deck 種類を確認していない 未知ラベルを 'U' に置換してから transform する

🚀 次のステップ

  • ⬆️
    発展: Sex × Pclass の交互作用特徴量を追加。1等女性と3等女性の生存率差を捉える。
    df['Sex_Pclass'] = df['Sex'] * df['Pclass']
  • 📅
    次回予告 (Day 083): RandomForest と特徴量重要度 — GBDT 系モデルへの橋渡し。決定木ベースのモデルで Titanic をさらに改善する。
  • 💻
    実践: 今日のコードで submission_fe.csv を作成して Kaggle に提出。Public LB が 0.78〜0.79 から 0.79〜0.80 に改善するか確認しよう。

📝 自己評価