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

【Kaggle挑戦記】Titanic 攻略 #17:0.77511への浮上。LightGBMを「制御」する手応え

前回、最強モデル LightGBM を投入するも、過学習により 0.76315 という惨敗を喫しました。今回はその反省を活かし、モデルにあえて「制約」を課すリベンジマッチ。結果は 0.77511。自己ベストには届きませんでしたが、確かな改善の兆しが見えてきました。

1. 戦略の検証:過学習の抑制は成功したか?

今回の修正の肝は、モデルに「深追いさせない」ことでした。その結果、スコアには以下のような変化が現れました。

  • 本番スコアの向上: 0.76315 → 0.77511(+0.012の改善)
  • CVスコアの適正化: 0.8418 → 0.8373(手元の数字が下がり、本番が上がった)

この「手元の数字を下げて、本番を上げる」という現象こそ、過学習が解消に向かっている決定的な証拠です。F1マシンを路地裏に合わせて減速させたことで、壁にぶつからずにコーナーを曲がれるようになったのです。

2. 【実装】「制約」を刻んだリベンジ・コード

過学習した前回(負け)の設定をコメントで残し、今回(改善)の設定を対比させました。

import pandas as pd
import numpy as np
import lightgbm as lgb
from sklearn.model_selection import GridSearchCV

# 1. 前処理(自己ベスト 0.78468 時の最強布陣を維持)
train_data = pd.read_csv('train.csv')
test_data = pd.read_csv('test.csv')

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

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'))
test_data['Fare'] = test_data['Fare'].fillna(test_data['Fare'].median())

# 2. 特徴量のダミー変数化
features = ["Pclass", "Sex", "Age", "SibSp", "Parch", "Fare", "FamilySize", "Embarked"]
X = pd.get_dummies(train_data[features], drop_first=True)
y = train_data["Survived"]
X_test = pd.get_dummies(test_data[features], drop_first=True)
X, X_test = X.align(X_test, join='left', axis=1, fill_value=0)

# 3. ライトGBM:過学習を防ぐための「抑制」チューニング
param_grid = {
    # 'num_leaves': [31],        # 前回:多すぎて細かく分けすぎていた
    'num_leaves': [7, 10, 15],   # 修正:モデルをシンプルに保つ

    'learning_rate': [0.01, 0.05],
    'n_estimators': [100, 200],

    # 'max_depth': [-1],         # 前回:無制限が過学習の主因
    'max_depth': [3, 4],         # 修正:あえて「浅い木」に限定する

    'min_child_samples': [20, 40], # 修正:1つの枝に40人以上のデータを要求(ノイズ対策)

    'random_state': [1]
}

# 4. グリッドサーチ実行
gbm = lgb.LGBMClassifier(verbosity=-1)
grid_search = GridSearchCV(gbm, param_grid, cv=5, n_jobs=-1, verbose=0)
grid_search.fit(X, y)

# 5. 結果の記録
print(f"Best Params: {grid_search.best_params_}")
print(f"Best Score (CV): {grid_search.best_score_:.4f}") # 0.8373 を記録

# 6. 予測
best_model = grid_search.best_estimator_
predictions = best_model.predict(X_test)
pd.DataFrame({'PassengerId': test_data.PassengerId, 'Survived': predictions}).to_csv('submission_lgbm_v2.csv', index=False)

3. 考察:0.8の壁を越えるために必要なこと

エンジニア的な視点:
今回の実験で、「強力なアルゴリズムを使うなら、強力なブレーキが必要である」という教訓が実証されました。0.77511 という数字は、まだ自己ベストには届きませんが、「モデルの使いこなし」という点では過去最高のレベルに達しています。

ここからさらに 0.03 スコアを伸ばし、0.8の大台に乗るには、モデルの微調整(チューニング)だけでは限界があるかもしれません。次は、データの背後に隠れた「家族の運命」や「チケット番号の繋がり」など、人間らしい洞察(特徴量エンジニアリング)を LightGBM に教え込むフェーズに来ていると感じます。


数字は嘘をつきません。改善した 0.012 は、私たちが正しい方向に進んでいる証拠。この調子で、次なる一手「特徴量の深化」へ進みます!



PR

【Kaggle挑戦記】Titanic 攻略 #16:LightGBMの洗礼。CVスコアの「罠」と過学習の恐怖

前回、最強の刺客 LightGBM を投入し、手元の交差検証(CV)で 0.8418 という驚異的なスコアを叩き出しました。「ついに0.8の大台か?」と期待に胸を膨らませて提出した結果、待っていたのは 0.76315 という非情な現実。自己ベスト(0.78468)から大きく後退する結果となりました。

1. なぜ「手元の高スコア」が「本番の惨敗」を招いたのか?

今回の敗因は、機械学習において最も警戒すべき 過学習(オーバーフィッティング) です。原因をエンジニア的に分析すると、以下の3点に集約されます。

  • モデルが「賢すぎた」: LightGBMは非常に強力なため、約890件という少ない訓練データの「偶然の偏り」まで完璧に学習してしまいました。
  • CVスコアの信憑性: CVスコア 0.84 というのは、訓練データ内での「予行演習」に過ぎません。本番のテストデータとの間に、学習しきれないギャップが存在していました。
  • パラメータの攻めすぎ: max_depth: -1(無制限)や num_leaves: 20 という設定が、少数のデータに対しては複雑すぎた可能性があります。

2. 【実装】光と影を記録した LightGBM コード

CVスコア 0.8418 を出しながらも、本番で 0.76315 に沈んだ「教訓」としてのコードです。

import pandas as pd
import numpy as np
import lightgbm as lgb
from sklearn.model_selection import GridSearchCV

# 1. データの読み込みと前処理(ベスト布陣を維持)
train_data = pd.read_csv('train.csv')
test_data = pd.read_csv('test.csv')

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

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'))
test_data['Fare'] = test_data['Fare'].fillna(test_data['Fare'].median())

# 2. 特徴量の準備(ダミー変数化)
features = ["Pclass", "Sex", "Age", "SibSp", "Parch", "Fare", "FamilySize", "Embarked"]
X = pd.get_dummies(train_data[features], drop_first=True)
y = train_data["Survived"]
X_test = pd.get_dummies(test_data[features], drop_first=True)
X, X_test = X.align(X_test, join='left', axis=1, fill_value=0)

# 3. LightGBMの設定(ここでの最適化が本番で裏目に出た)
param_grid = {
    'num_leaves': [10, 20, 31],
    'learning_rate': [0.01, 0.05, 0.1],
    'n_estimators': [100, 500],
    'max_depth': [-1, 3, 5],
    'random_state': [1]
}

gbm = lgb.LGBMClassifier(verbosity=-1)
grid_search = GridSearchCV(gbm, param_grid, cv=5, n_jobs=-1, verbose=0)
grid_search.fit(X, y)

# 結果出力(CVスコアは 0.8418 をマーク)
print(f"LGBM Best Params: {grid_search.best_params_}")
print(f"LGBM Best Score (CV): {grid_search.best_score_:.4f}")

# 予測実行
best_model = grid_search.best_estimator_
predictions = best_model.predict(X_test)
pd.DataFrame({'PassengerId': test_data.PassengerId, 'Survived': predictions}).to_csv('submission_lgbm_overfit.csv', index=False)

3. 実験結果:期待と現実のギャップ

アルゴリズムを変えたことで、数字に大きな「動き」が出ましたが、今回は悪い方へ転がりました。

  • 自己ベスト(ランダムフォレスト): Score 0.78468
  • 今回(LightGBM): Score 0.76315(CVスコアとの乖離:-0.078)

4. 考察:高すぎるCVスコアを疑え

エンジニア的な視点:
「手元で完璧なモデルが、外の世界で通用するとは限らない」。今回の結果は、AI開発における本質的な難しさを教えてくれました。CVスコアが 0.84 まで跳ね上がった時点で、「学習しすぎではないか?」と疑うべきだったのです。Titanicのような少人数データでは、LightGBMのような強力なモデルを「いかに抑え込むか(正則化)」が次の鍵となります。


スコアダウンは失敗ではなく、モデルの特性を理解するための貴重なデータです。次は、LightGBMをあえて「弱く」する(パラメータを厳しく制限する)か、あるいはランダムフォレストの安定感を見直すか。この 0.02 の差を埋めるための戦いは、さらに深化していきます。


【Kaggle挑戦記】Titanic 攻略 #15:0.78468 不変。ハイパーパラメータ最適化が証明した「モデルの限界」

前回(攻略 #14)、デッキ情報の追加でスコアを落とした反省を活かし、今回はベストスコア 0.78468 を出した最強の布陣に対し、科学的なメスを入れました。GridSearchCV(交差検証)による「木の深さ」の最適化。しかし、返ってきたスコアは驚くほど正確に前回と同じ 0.78468 でした。

1. なぜ「最適化」したのにスコアが変わらなかったのか?

勘に頼っていた max_depth=5 という設定を、交差検証によって [3, 4, 5, 6, 7, 8] の中から最も優れたものへ自動選択させました。それにも関わらずスコアが不変だった理由。そこにはエンジニアとして納得のいく理由が隠れています。

  • 「深さ5」がすでに黄金比だった: 交差検証の結果、実はこれまでの「深さ5」が、学習データとテストデータのバランスを保つ上で既に最適な値であった可能性が高いです。
  • 特徴量の表現力の限界: パラメータという「火加減」を調整してもスコアが動かないのは、材料である「特徴量」が持つ情報の限界に達していることを意味します。
  • 高い汎化性能の証明: スコアが落ちなかったということは、モデルが変に過学習せず、安定した予測能力を維持できている証拠でもあります。

2. 【実装】GridSearchCV による最適化の全記録

結果は維持でしたが、今後の試行錯誤において「確信」を持ってパラメータを設定するための必須ステップです。

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

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

# 2. 前処理(ベストスコア時の構成を維持)
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())

# 3. 特徴量のダミー変数化
features = ["Pclass", "Sex", "Age", "SibSp", "Parch", "Fare", "FamilySize", "Embarked"]
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)

# 4. パラメータの探索
param_grid = {
    'n_estimators': [500],
    'max_depth': [3, 4, 5, 6, 7, 8],
    'min_samples_leaf': [1, 3, 5],
    'random_state': [1]
}

# 5. グリッドサーチによる交差検証
grid_search = GridSearchCV(
    estimator=RandomForestClassifier(),
    param_grid=param_grid,
    cv=5,
    n_jobs=-1,
    verbose=1
)
grid_search.fit(X, y)

# 最適なモデルを抽出
best_model = grid_search.best_estimator_
predictions = best_model.predict(X_test)

# 6. 出力
output = pd.DataFrame({'PassengerId': test_data.PassengerId, 'Survived': predictions})
output.to_csv('submission_optimized.csv', index=False)

print(f"Best Params found: {grid_search.best_params_}")

3. 考察:次のフェーズは「材料そのもの」の変革

エンジニア的な視点:
今回の実験で、「今の特徴量の組み合わせ(Embarked, FamilySizeなど)を、今のランダムフォレストで回す限り、これ以上の伸び代はない」ということがはっきりしました。0.78468 は一つの完成形です。ここから 0.79、0.80 を目指すには、微調整(チューニング)ではなく、革新(イノベーション)が必要です。


スコアが変わらなかったことは、決して無駄ではありません。「迷い」が「確信」に変わった瞬間です。次回からは、これまでの安定した布陣をベースにしつつ、全く新しいアルゴリズム(XGBoostやLightGBM)を試すか、あるいは「チケット番号の重複」など、より高度な特徴量生成(Feature Engineering)の深淵へと足を踏み入れます!

【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の有無だけにする等)してリベンジするか、あるいは再び「引き算」をして別の道を探るか……。戦略の練り直しです。

【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`(客室)の深淵に踏み込むか……。戦略を練り直します!