忍者ブログ
統計、機械学習、AIを学んでいきたいと思います。 お役に立てば幸いです。

【Kaggle挑戦記】Titanic 攻略 #14:0.77990への後退。欠損率77%の「Cabin」が招いた過学習

前回(攻略 #13)、自己ベストの 0.78468 を記録し、ついに波に乗ったかと思われました。次なる一手として投入したのは、船の階層を示す Cabin(客室番号)からのデッキ情報。物理的な生存率の差を捉える決定打になるはずが、結果は 0.77990 への大幅なランクダウンとなりました。

1. 敗因分析:なぜ「強力なヒント」が毒になったのか?

客室番号から抽出した「デッキ(A〜G)」は、理論上はボートへの距離を示す重要な指標です。しかし、そこには機械学習特有の罠が潜んでいました。

  • 圧倒的な欠損率: Cabinデータは約77%が欠損しています。この「穴だらけ」のデータを無理にカテゴリ化して学習させたことで、モデルが数少ないサンプルに過剰に反応(過学習)してしまった可能性があります。
  • 低頻度カテゴリのノイズ: 「Tデッキ」などの極端に乗客が少ない階層が、予測において「意味のない分岐」を作り出し、未知のデータに対する判断を狂わせたと考えられます。
  • 情報の不純度: デッキ情報は Pclass(客室階級)と極めて強い相関があります。すでに Pclass で説明できている情報に、ノイズの多い Cabin 情報を混ぜたことが、モデルの焦点をぼかしてしまったようです。

2. 【実装】敗北の記録:デッキ情報を導入した全コード

結果としてスコアを下げてしまいましたが、検証の記録として、デッキ抽出を組み込んだコードを掲載します。

import pandas as pd
import numpy as np
from sklearn.ensemble import RandomForestClassifier

# 1. データの読み込み
train_data = pd.read_csv('train.csv')
test_data = pd.read_csv('test.csv')

# 2. Cabinからデッキ情報を抽出(今回の挑戦ポイント)
for df in [train_data, test_data]:
    df['Deck'] = df['Cabin'].apply(lambda x: x[0] if pd.notnull(x) else 'U')

# 3. 安定した前処理(0.78468時と同じ構成)
train_data['Embarked'] = train_data['Embarked'].fillna('S')
test_data['Embarked'] = test_data['Embarked'].fillna('S')

group_cols = ['Pclass', 'Sex']
train_data['Age'] = train_data['Age'].fillna(train_data.groupby(group_cols)['Age'].transform('median'))
test_data['Age'] = test_data['Age'].fillna(test_data.groupby(group_cols)['Age'].transform('median'))

for df in [train_data, test_data]:
    df['FamilySize'] = df['SibSp'] + df['Parch'] + 1
test_data['Fare'] = test_data['Fare'].fillna(test_data['Fare'].median())

# 4. 特徴量の選択(Deckを追加)
features = ["Pclass", "Sex", "Age", "SibSp", "Parch", "Fare", "FamilySize", "Embarked", "Deck"]

# 5. ダミー変数化
X = pd.get_dummies(train_data[features])
y = train_data["Survived"]
X_test = pd.get_dummies(test_data[features])

# 列の整合性を確保
X, X_test = X.align(X_test, join='left', axis=1, fill_value=0)

# 6. モデル学習
model = RandomForestClassifier(n_estimators=500, max_depth=5, random_state=1)
model.fit(X, y)

# 7. 予測と出力
predictions = model.predict(X_test)
pd.DataFrame({'PassengerId': test_data.PassengerId, 'Survived': predictions}).to_csv('submission_deck_failed.csv', index=False)

3. 実験結果:0.78468 → 0.77990 への転落

「垂直の階層」を加えた結果、スコアは改善するどころか、前々回の水準まで後退しました。

  • 自己ベスト(攻略 #13): Score 0.78468
  • 今回(Deck追加): Score 0.77990

4. 考察:欠損値は「埋めればいい」ものではない

エンジニア的な視点:
今回の学びは、「欠損率があまりに高いカラムは、無理に特徴量化するとモデルの汎化性能を破壊する」ということです。デッキ情報は確かに生存に影響したはずですが、データの密度が薄すぎました。Kaggleにおいては、情報の「正しさ」だけでなく、情報の「密度」と「安定性」がいかに重要かを痛感させられる結果となりました。


自己ベスト更新直後の落とし穴。しかし、この 0.77990 という数字が「Deck情報の生投入は悪手である」と教えてくれました。次は、この Deck 情報をより大胆にグルーピング(例:Cabinの有無だけにする等)してリベンジするか、あるいは再び「引き算」をして別の道を探るか……。戦略の練り直しです。

PR

【Kaggle挑戦記】Titanic 攻略 #13:自己ベスト更新 0.78468!「引き算」と「新しい視点」の勝利

前回(攻略 #12)では、精緻に作り込んだ「敬称」データがまさかの裏目に出て、スコアが 0.77751 まで下落するという苦い経験をしました。しかし、その敗北から学んだ「情報の重複を削る」という教訓、そして新たな物理的要因 Embarked(乗船港) の導入が、ついに 0.78468 という自己ベスト更新を呼び込みました!

1. 逆転の戦略:複雑さを捨て、文脈を足す

今回の成功のポイントは、単なる「足し算」ではなく、勇気を持った「引き算」にあります。スコアを下げていた Title(敬称)を一度捨て、モデルをスリム化した上で、全く新しい切り口である Embarked を追加しました。

  • 多重共線性の解消: 性別・年齢・敬称という似通った情報の衝突を解消し、モデルの迷いを取り除きました。
  • 乗船港が示す「背景」: シェルブール(C)=富裕層、サウサンプトン(S)=一般層といった、港ごとの生存傾向が、Pclassだけでは説明しきれなかった微細な生存確率を補完しました。

2. 【実装】0.78468 を叩き出した最終コード

シンプルながらも強力な、現時点での「最適解」と言えるコード全文です。Embarkedの欠損値を最頻値で埋め、ダミー変数化して投入しています。

import pandas as pd
import numpy as np
from sklearn.ensemble import RandomForestClassifier

# 1. データの読み込み
train_data = pd.read_csv('train.csv')
test_data = pd.read_csv('test.csv')

# 2. Embarked(乗船港)の欠損値補完
# 最も多い乗船港である 'S' で補完
train_data['Embarked'] = train_data['Embarked'].fillna('S')
test_data['Embarked'] = test_data['Embarked'].fillna('S')

# 3. 安定した前処理の再現(属性別Age補完 & 家族サイズ)
group_cols = ['Pclass', 'Sex']
train_data['Age'] = train_data['Age'].fillna(train_data.groupby(group_cols)['Age'].transform('median'))
test_data['Age'] = test_data['Age'].fillna(test_data.groupby(group_cols)['Age'].transform('median'))

for df in [train_data, test_data]:
    df['FamilySize'] = df['SibSp'] + df['Parch'] + 1

# テストデータの運賃欠損を中央値で補完
test_data['Fare'] = test_data['Fare'].fillna(test_data['Fare'].median())

# 4. 学習に使用する特徴量の定義
# 迷走した Title はあえて外し、Embarked を新規採用
features = ["Pclass", "Sex", "Age", "SibSp", "Parch", "Fare", "FamilySize", "Embarked"]

# 5. カテゴリ変数をダミー変数に変換
X = pd.get_dummies(train_data[features])
y = train_data["Survived"]
X_test = pd.get_dummies(test_data[features])

# 学習とテストの列を一致させる
X, X_test = X.align(X_test, join='left', axis=1, fill_value=0)

# 6. モデル学習(500本の決定木、最大深さ5)
model = RandomForestClassifier(n_estimators=500, max_depth=5, random_state=1)
model.fit(X, y)

# 7. 予測の実行と提出用CSV作成
predictions = model.predict(X_test)
output = pd.DataFrame({'PassengerId': test_data.PassengerId, 'Survived': predictions})
output.to_csv('submission_final_best.csv', index=False)

print("Score 0.78468 達成の予測ファイルを保存しました。")

3. 実験結果:0.77751 → 0.78468 へ跳ねる!

特徴量を1つ入れ替え、整理しただけで、スコアは劇的に改善しました。

  • 前回(Title追加): Score 0.77751
  • 今回(Title削除+Embarked追加): Score 0.78468 (New Record!)

4. 考察:エンジニアリングの本質は「整理整頓」にあり

エンジニア的な視点:
今回の結果が教えてくれたのは、「良いデータ」を足すことと同じくらい、「不要な相関を削る」ことが重要であるという事実です。一時は 0.77 台まで落ち込み絶望しましたが、そこで立ち止まらずに「なぜ落ちたのか?」を考え、別の軸(Embarked)に切り替えたことが勝機となりました。0.785の壁も見えてきました。次はこの安定した土台の上に、さらなるエッセンスを加えていきます。


自己ベスト更新の余韻に浸りつつも、Kaggleの道はまだ続きます。今回の勝利で「情報の質と整理」の重要性が証明されました。次は、今回あえて削った `Title` を「ノイズにならない形」で再統合するか、あるいは `Cabin`(客室)の深淵に踏み込むか……。戦略を練り直します!

【Kaggle挑戦記】Titanic 攻略 #12:0.77751の呪縛。精緻なクレンジングでも動かぬ壁

前回(攻略 #11)、家族サイズ(FamilySize)の導入で 0.78229 という自己ベストを叩き出した勢いのまま、今回は「最強の特徴量」と名高い Title(敬称) を投入しました。しかし、返ってきた結果は 0.77751 への後退。さらに、表記ゆれを疑い徹底したクレンジングを施すも、スコアは1ミリも動きませんでした。

1. 仮説と検証:なぜ「完璧なはずのコード」でスコアが動かないのか

抽出ミスや空白の混入を疑い、strip() や徹底した表記統合を行いました。論理的には、これでデータは綺麗になり、モデルはより正確に「Master(子供)」や「Rare(特権階級)」を識別できるはずでした。しかし、結果は非情な現状維持です。

  • 仮説1:情報の重複(多重共線性)
    既に「Sex」や「Age(精密補完済み)」、「Pclass」の中に、敬称が持つ情報の大部分が含まれてしまっている可能性。
  • 仮説2:ランダムフォレストの限界
    現在のパラメータ設定(深さ5など)では、これ以上細かい特徴量を読み取っても、汎化性能(未知のデータへの対応力)に繋がっていない可能性。

2. 【実装】クレンジングを徹底した Title 導入コード

不備のない、現時点で最も「綺麗」な状態の全コードです。結果としてスコアは改善しませんでしたが、データ処理の型としては正攻法と言えます。

import pandas as pd
import numpy as np
from sklearn.ensemble import RandomForestClassifier

# 1. データの読み込み
train_data = pd.read_csv('train.csv')
test_data = pd.read_csv('test.csv')

# 2. 敬称抽出関数の定義(徹底した空白除去)
def get_title(name):
    if '.' in name:
        return name.split(',')[1].split('.')[0].strip()
    return 'Unknown'

# 3. データのクレンジングと正規化
for df in [train_data, test_data]:
    df['Title'] = df['Name'].map(get_title)
    
    # 希少な敬称の統合
    rare_titles = ['Lady', 'Countess','Capt', 'Col','Don', 'Dr', 'Major', 'Rev', 'Sir', 'Jonkheer', 'Dona']
    df['Title'] = df['Title'].replace(rare_titles, 'Rare')
    
    # 表記ゆれの統一
    df['Title'] = df['Title'].replace(['Mlle', 'Ms'], 'Miss')
    df['Title'] = df['Title'].replace('Mme', 'Mrs')

# 4. 精密なAge補完(Pclass × Sex × Title)
group_cols = ['Pclass', 'Sex', 'Title']
train_data['Age'] = train_data['Age'].fillna(train_data.groupby(group_cols)['Age'].transform('median'))
test_data['Age'] = test_data['Age'].fillna(test_data.groupby(group_cols)['Age'].transform('median'))

# 5. 家族サイズと運賃の処理
for df in [train_data, test_data]:
    df['FamilySize'] = df['SibSp'] + df['Parch'] + 1
test_data['Fare'] = test_data['Fare'].fillna(test_data['Fare'].median())

# 6. 特徴量のダミー変数化
features = ["Pclass", "Sex", "Age", "SibSp", "Parch", "Fare", "FamilySize", "Title"]
X = pd.get_dummies(train_data[features])
y = train_data["Survived"]
X_test = pd.get_dummies(test_data[features])

# 学習とテストで列を完全に一致させる
X, X_test = X.align(X_test, join='left', axis=1, fill_value=0)

# 7. モデル学習(500本の木、深さ5)
model = RandomForestClassifier(n_estimators=500, max_depth=5, random_state=1)
model.fit(X, y)

# 8. 予測と出力
predictions = model.predict(X_test)
pd.DataFrame({'PassengerId': test_data.PassengerId, 'Survived': predictions}).to_csv('submission_cleansed.csv', index=False)

3. 実験結果:0.77751(変化なし)

クレンジング前と後で、スコアは一桁も変わりませんでした。この結果が意味するのは、「現在の私のモデルにとって、Title情報は恩恵よりもノイズ、あるいは過学習の要因になっている」という不都合な真実です。

  • 前回(FamilySize追加): Score 0.78229
  • 今回(Title追加+徹底洗浄): Score 0.77751

4. 考察:引き算の勇気が必要か

エンジニア的な視点:
「良かれと思って追加した機能が、システム全体のパフォーマンスを落とす」。これは開発現場でもよくある話です。今回の Title 導入は、一見すると情報の追加でしたが、実際にはモデルの焦点をぼかしてしまった可能性があります。0.78229 という自己ベストに戻るには、一度「Titleを捨てる」という引き算の決断、あるいは全く別の次元の特徴量(チケット番号や客室番号)を検討する必要がありそうです。


成功体験よりも、こうした停滞こそが思考を深くしてくれます。次回の攻略では、この「0.77751の壁」を突破するために、特徴量の取捨選択を根本から見直す「モデルのスリム化」に挑みます。

【Kaggle挑戦記】Titanic 攻略 #12:名前の中に答えがあった。「敬称(Title)」抽出で0.80超えへ

前回(攻略 #11)では、家族サイズを導入することで 0.78229 という自己ベストを更新しました。しかし、依然として「生存」か「死亡」かをモデルが確信しきれないグレーゾーンが存在します。今回は、これまで無視してきたテキストデータ Name(名前) から、生存率を決定づける隠れた指標「敬称」を掘り起こします。

1. なぜ「名前」ではなく「敬称」なのか?

機械学習モデルにとって「個別の氏名」はただのラベルですが、名前に含まれる Mr. / Miss. / Mrs. / Master. といった敬称(Title)は情報の宝庫です。これらを抽出することで、当時の社会における立場や家族背景をより深くモデルに反映させることができます。

  • 「女性」の中のさらなる分類: 未婚(Miss)か既婚(Mrs)かを知ることで、生存傾向の差をより細かく捉えます。
  • 子供の確実な特定: 名前に Master とあれば、年齢データが欠損していても確実に「男児」であると断定でき、補完精度が劇的に向上します。
  • 社会的地位の捕捉: Dr.(医師)や Col.(大佐)といった希少な敬称は、優先的にボートへ誘導された可能性を示唆します。

2. 【実装】敬称抽出とモデル構築の全コード

正規表現的に敬称を切り出し、主要な5グループに統合した上で、これまでの「家族サイズ」「Age補完」と組み合わせて学習させます。

import pandas as pd
import numpy as np
from sklearn.ensemble import RandomForestClassifier

# 1. データの読み込み
train_data = pd.read_csv('train.csv')
test_data = pd.read_csv('test.csv')

# 2. 敬称(Title)を抽出する関数の定義
def get_title(name):
    if '.' in name:
        return name.split(',')[1].split('.')[0].strip()
    return 'Unknown'

# 3. 敬称の抽出と統合
for df in [train_data, test_data]:
    df['Title'] = df['Name'].map(get_title)
    rare_titles = ['Lady', 'Countess','Capt', 'Col','Don', 'Dr', 'Major', 'Rev', 'Sir', 'Jonkheer', 'Dona']
    df['Title'] = df['Title'].replace(rare_titles, 'Rare')
    df['Title'] = df['Title'].replace(['Mlle', 'Ms'], 'Miss')
    df['Title'] = df['Title'].replace('Mme', 'Mrs')

# 4. Age(年齢)の精密補完:Pclass × Sex × Title 別の中央値
group_cols = ['Pclass', 'Sex', 'Title']
train_data['Age'] = train_data['Age'].fillna(train_data.groupby(group_cols)['Age'].transform('median'))
test_data['Age'] = test_data['Age'].fillna(test_data.groupby(group_cols)['Age'].transform('median'))

# 5. 既存の特徴量加工(家族サイズと運賃補完)
for df in [train_data, test_data]:
    df['FamilySize'] = df['SibSp'] + df['Parch'] + 1
test_data['Fare'] = test_data['Fare'].fillna(test_data['Fare'].median())

# 6. 特徴量の選択とダミー変数化
features = ["Pclass", "Sex", "Age", "SibSp", "Parch", "Fare", "FamilySize", "Title"]
X = pd.get_dummies(train_data[features])
y = train_data["Survived"]
X_test = pd.get_dummies(test_data[features])

# 列の不一致調整
X, X_test = X.align(X_test, join='left', axis=1, fill_value=0)

# 7. モデル学習(500本の木)
model = RandomForestClassifier(n_estimators=500, max_depth=5, random_state=1)
model.fit(X, y)

# 8. 予測と出力
predictions = model.predict(X_test)
pd.DataFrame({'PassengerId': test_data.PassengerId, 'Survived': predictions}).to_csv('submission_title.csv', index=False)

3. 実験結果:0.78229 → 0.77751 へ下落

最強のヒントを投入したはずが、結果は無情なスコアダウンとなりました。

  • 前回(FamilySize追加): Score 0.78229
  • 今回(Title追加): Score 0.77751

エンジニア的な考察:
「論理的に正しい加工が、必ずしも精度に直結しない」という機械学習の洗礼を受けました。今回の下落の原因として考えられるのは、モデルが Title という強い情報に頼りすぎてしまい、複雑な分岐を作った結果、未知のデータに対する柔軟性を失った(過学習した)可能性です。また、SexTitle には強い相関があるため、情報が重複して判断を狂わせたのかもしれません。


敗北は、次なる改善への貴重なデータです。0.78突破の喜びから一転、モデルの繊細さを痛感する結果となりました。次は、今回追加した Title を削るべきか、あるいはパラメータを再調整して「情報の衝突」を解消すべきか、慎重な見極めが必要です。


【Kaggle挑戦記】Titanic 攻略 #11:血縁データの統合。「家族サイズ」が0.78の壁を打ち破る

前回(攻略 #10)のデバッグでは、自作の「子供フラグ」がモデルにとって不純なノイズとなっていた現実を直視しました。今回はその反省を活かし、バラバラに存在していた「血縁データ」をロジカルに統合。ついに 0.78 の壁を突破する強力なヒント FamilySize(家族人数) を導入します。

1. 今回の着眼点:孤立か、連帯か

タイタニックのデータには、家族に関する項目が2つ用意されています。しかし、これらを別々に見ていては「その人がどれほどの集団で行動していたか」という実態が見えてきません。

  • SibSp: 兄弟、配偶者の数
  • Parch: 両親、子供の数

避難の際、1人で行動していたのか、あるいは守るべき家族がいたのか。この「群れ」の大きさを一つの指標として統合し、モデルに新たな視点を与えます。自分自身(+1)を足すことで、「1 = 単独客」「2以上 = 家族連れ」という、生存率を分ける明確な境界線が生まれます。

2. 【実装】FamilySize導入とモデル構築の全コード

属性別Age補完を継承しつつ、FamilySizeを計算してランダムフォレストに投入するまでの全工程です。

import pandas as pd
from sklearn.ensemble import RandomForestClassifier

# 1. データの読み込み
train_data = pd.read_csv('train.csv')
test_data = pd.read_csv('test.csv')

# 2. Age(年齢)を Pclass×Sex の中央値で補完
group_cols = ['Pclass', 'Sex']
train_data['Age'] = train_data['Age'].fillna(train_data.groupby(group_cols)['Age'].transform('median'))
test_data['Age'] = test_data['Age'].fillna(test_data.groupby(group_cols)['Age'].transform('median'))

# 3. 家族サイズ(FamilySize)の算出
train_data['FamilySize'] = train_data['SibSp'] + train_data['Parch'] + 1
test_data['FamilySize'] = test_data['SibSp'] + test_data['Parch'] + 1

# テストデータの Fare(運賃)の欠損値を中央値で補完
test_data['Fare'] = test_data['Fare'].fillna(test_data['Fare'].median())

# 4. 学習に使用する特徴量の定義
features = ["Pclass", "Sex", "Age", "SibSp", "Parch", "Fare", "FamilySize"]

# カテゴリ変数のダミー変数化
X = pd.get_dummies(train_data[features])
y = train_data["Survived"]
X_test = pd.get_dummies(test_data[features])

# 5. モデルの構築と学習(木500本、深さ5)
model = RandomForestClassifier(n_estimators=500, max_depth=5, random_state=1)
model.fit(X, y)

# 6. 予測と提出ファイルの出力
predictions = model.predict(X_test)
output = pd.DataFrame({'PassengerId': test_data.PassengerId, 'Survived': predictions})
output.to_csv('submission_family_size.csv', index=False)

print("Score 0.78229 達成用の提出ファイルを保存しました。")

3. 実験結果:0.77990 → 0.78229 へ上昇!

家族サイズを導入した結果、ついに停滞していたスコアが大きく動きました。

  • 前回(Age補完のみ): Score 0.77990
  • 今回(FamilySize追加): Score 0.78229

エンジニア的な考察:
ついに 0.78 の大台に乗りました。単なる数値だった「SibSp」と「Parch」を合計して『家族というひとつの単位』として捉え直したことで、モデルは「独身男性(死亡率高)」や「小家族(生存率高)」という構造を、より正確に切り分けられるようになったと言えます。アルゴリズムをいじるのではなく、データの「見方」を変えることが勝利の鍵であることを証明しました。

4. 次なる一手:名前の「敬称」から社会的地位を暴く

0.78229 というスコアは一つの通過点に過ぎません。まだ伸び代は十分にあります。次は、これまで手をつけていなかった巨大な情報源 Name(名前) の解析に挑みます。ここから「Mr.」「Miss.」「Master.」といった敬称を抜き出せば、年齢だけでは判別できない「既婚・未婚」や「家柄(社会的地位)」という決定的なヒントが手に入るはずです。


「質の高い燃料(特徴量)」を投入することで、ランダムフォレストは見違えるような精度を見せ始めました。この勢いのまま、次はテキストデータの解析に踏み込み、さらなる高みを目指します。