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

【Kaggle挑戦記】S4E11 #1:全特徴量を投入して見えた「名前」の怪しい貢献度

KaggleのPlayground Series S4E11「メンタルヘルス予測」に参戦しました。 まずはエンジニアとしての定石通り、複雑な加工はせず「まずは動くもの」を目指します。 今回はあえて「id以外の全特徴量をぶっ込む」という、力押しのベースライン構築からスタートです。

1. 戦略:全件投入のフルスキャン・アプローチ

「どの変数が有効か?」と悩む前に、LightGBMの処理能力を信じて全データを投入しました。 通常なら除外するはずの「Name(名前)」カラムも、あえてそのまま。 カテゴリ変数は、LightGBMのcategory型にキャストするだけで、内部的に最適化されるように実装しました。

# 特徴量は全投入(idのみ除外)
X = train.drop(['id', 'Depression'], axis=1)
y = train['Depression']

# 全object型カラムをcategory型へキャスト
cat_cols = X.select_dtypes(include=['object']).columns.tolist()
for col in cat_cols:
    X[col] = X[col].astype('category')

2. リザルト:驚愕のスコアと「ログ」の違和感

Macで実行すること数秒。生成されたsubmission.csvをSubmitした結果、驚きのスコアが返ってきました。

Public Score: 0.94008 / Private Score: 0.93868

いきなり0.94超え。しかし、エンジニアとして「中身」をデバッグせずに喜ぶわけにはいきません。 モデルが何を根拠に判断したか(Feature Importance)を確認したところ、奇妙な事実が判明しました。

--- Feature Importance (Top 5) ---
1. Name : 1308
2. City : 223
3. Age : 204
4. Financial Stress : 174
5. Profession : 159

3. 考察:なぜ「名前」の貢献度が異常に高いのか?

予測の決め手が、年齢や仕事のストレスを差し置いて「Name(名前)」になっています。 バックエンドのロジックとして考えれば、名前の文字列そのものがメンタルヘルスに影響を与えるはずがありません。

考えられる仮説はいくつかあります:

  • 過学習(Overfitting): モデルが名前をユニークなキーとして認識し、個々のデータを「暗記」してしまった。
  • データリーク: 名前の付け方や順序に、正解データへのヒントが紛れ込んでいる(システムバグに近い状態)。
  • プロキシ変数の可能性: 名前が特定の居住地域や年齢層、あるいは家系的な背景を代理してしまっている。

4. まとめ:次回のデバッグ指針

「全部入り」で投げた結果、0.94という高いベンチマークと同時に、「名前という名の巨大なノイズ(あるいは不正解)」を炙り出すことができました。

この「名前」がスコアを不当に押し上げているのか、それとも何か深い意味があるのか。 次回は「Name」をdropした状態で再学習を行い、モデルの真の実力をデバッグしてみたいと思います。




PR

【Kaggle挑戦記】S4E11 #1:メンタルヘルス予測コンペ参戦。LightGBMで「0.94」の初陣

次なるターゲットに選んだのはKaggle Playground Series S4E11。 実在のアンケート結果をベースにした「メンタルヘルス(うつ病)の予測」です。 実務的なデータ構造を相手に、まずは何も考えずLightGBMでベースラインを構築してみました。

1. コンペの概要:生体ログから「心の状態」をデバッグする

今回のミッションは、年齢、性別、仕事のストレス、睡眠時間、食事習慣といった多角的なデータから、 対象者がうつ病(Depression)の状態にあるかどうかを予測する2値分類です。

主要な特徴量エンジニア的解釈
Academic/Work Pressure システムの負荷状況(リソース逼迫度)
Sleep Duration / Dietary Habits メンテナンス(自己回復)のログ
Financial Stress 外部環境によるエラー因子

2. 実装:特徴量は「すべてぶっ込んだ」最短ルート

「どの項目が重要か?」を人間が考える前に、まずはマシンパワーに任せてみます。 今回はID以外の特徴量をすべてぶっ込み、カテゴリ変数はLightGBMのcategory型指定で一気に処理する、いわば「全件スキャン」的なアプローチをとりました。

通常なら除外するはずの「Name(名前)」のような文字列データも、モデルがどう解釈するかを見るためにあえて残しています。

import pandas as pd
import lightgbm as lgb
from sklearn.preprocessing import LabelEncoder

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

target_col = 'Depression'

# ターゲットの数値化
le = LabelEncoder()
train[target_col] = le.fit_transform(train[target_col])

# 特徴量は全投入(idのみ除外)
X = train.drop(['id', target_col], axis=1)
y = train[target_col]
X_test = test.drop(['id'], axis=1)

# カテゴリ型への一括変換
cat_cols = X.select_dtypes(include=['object']).columns.tolist()
for col in cat_cols:
    X[col] = X[col].astype('category')
    X_test[col] = X_test[col].astype('category')

# モデル構築(2値分類:Binary)
model = lgb.LGBMClassifier(objective='binary', random_state=42)
model.fit(X, y)

# 予測と提出ファイルの作成
submission = pd.DataFrame({
    'id': test['id'],
    target_col: model.predict(X_test)
})
submission.to_csv('submission.csv', index=False)

3. 結果と考察:見えてきた「データの罠」

初回のSubmit結果は以下の通りです。

Public Score: 0.94008 / Private Score: 0.93868

なかなかの高スコアですが、Feature Importance(重要度)を見るとデバッグすべき点が見つかりました。

--- Feature Importance Top 5 ---
1. Name              : 1308
2. City              : 223
3. Age               : 204
4. Financial Stress  : 174
5. Profession        : 159

「Name(名前)」が重要度のトップに君臨しています。 「全特徴量投入」の結果、モデルは名前に含まれる特定のパターン(あるいは個別のID的な性質)を、うつ病の判定材料として「暗記」してしまったようです。 これは典型的な過学習の予兆であり、システム開発で言えば「テストデータのみに通るハードコーディング」に近い状態かもしれません。

4. まとめと次回の課題

まずは「動くもの」を作り、0.94というベンチマークを得ることに成功しました。
次回は、この「Name」というノイズを除去した際にスコアがどう変化するか、そしてLog Lossを意識した確率予測のチューニングに挑みます。


【Kaggle挑戦記】House Prices #1:予測対象は「価格」。回帰モデルによる住宅見積もり

1. 新たな戦場:House Prices - Advanced Regression Techniques

Spaceship Titanicを終え、次に挑むのは「住宅価格予測」です。 アイオワ州エイムズにある住宅のスペックから、その販売価格(SalePrice)を予測します。 前回の「転送されたか否か(0/1)」の分類とは異なり、今回は具体的な数値を当てる「回帰(Regression)」問題に挑みます。

2. 「分類」と「回帰」の決定的な違い

エンジニアとして、まずは評価指標を頭に叩き込みます。出力型が根本から変わるため、デバッグの指標も切り替える必要があります。

  • ターゲット: Boolean(生存/死亡)から Float(住宅価格)へ。 「0か1か」のラベルではなく、連続的な数値を予測します。
  • 評価指標: RMSE(Root Mean Squared Error:平均平方二乗誤差)。 「何人当たったか」ではなく、予測価格と実際の価格の「ズレ(誤差)」を評価します。
  • モデル: LGBMClassifier ではなく、回帰専用の LGBMRegressor を使用します。

3. 設計思想:なぜ「最小二乗法」ではなく「決定木」なのか

回帰といえば「最小二乗法(線形回帰)」で一本の直線を引くイメージが強いですが、本攻略では現代的なLightGBM(決定木モデル)を採用します。

  • 伝統的な回帰(最小二乗法): 「面積が2倍なら価格も2倍」といった単純な比例関係に強いが、市場の複雑な歪みに弱い。
  • 現代的な回帰(LightGBM): 「築年数が古くても、リフォーム済みなら高値」といった、条件の組み合わせ(非線形な関係)を数千もの分岐でデバッグし、多角形的に価格を近似していきます。

住宅市場の「不連続な境界線(このエリアに入った瞬間に価格が跳ね上がる、など)」を捉えるには、決定木によるアプローチが極めて有効です。

4. 本アプローチの設計図

■ 今回使う特徴量(数値データに限定)

79個のカラムのうち、初回は「数値データ(int/float)」のみを抽出して投入します。 敷地面積(LotArea)、築年(YearBuilt)、1階の広さ(1stFlrSF)、バスルームの数(FullBath)などが含まれます。文字列(立地など)は一旦除外します。

■ 指標:二乗誤差の最小化

本コンペのゴールは、二乗誤差(の平均)を最小化することです。 誤差(予測 - 実測)を二乗することで、大きな予測ミスに対して指数関数的に厳しいペナルティを課し、モデルに「大外れを出すな」と教育します。 ※最終スコアは「対数をとった後のRMSE」になりますが、まずは生の数値で誤差を削ります。

5. 【初陣】回帰ベースライン・ソースコード

import pandas as pd
import numpy as np
import lightgbm as lgb

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

# 2. ターゲットの設定
y_train = train['SalePrice']
# 数値データのみを抽出し、IDとターゲットを除外
X_train = train.select_dtypes(include=[np.number]).drop(['Id', 'SalePrice'], axis=1)
X_test = test.select_dtypes(include=[np.number]).drop(['Id'], axis=1)

# 3. 欠損値の補完
# 回帰問題の初期デバッグとして中央値(median)で埋める
X_train = X_train.fillna(X_train.median())
X_test = X_test.fillna(X_test.median())

# 4. 回帰モデルの構築
# 二乗誤差の最小化(regression)を目的関数に設定
model = lgb.LGBMRegressor(
    objective='regression',
    n_estimators=1000, 
    learning_rate=0.05,
    random_state=1
)

# 5. 学習
model.fit(X_train, y_train)

# 6. 予測(出力は具体的なドル建て価格の配列)
predictions = model.predict(X_test)

# 7. 提出用ファイルの作成
output = pd.DataFrame({'Id': test['Id'], 'SalePrice': predictions})
output.to_csv('submission_v1_baseline.csv', index=False)

print("✅ LightGBM Regressor baseline trained.")
print(f"Sample Predictions: {predictions[:5]}")

6. 実行結果のデバッグ:スコア 0.14679 の意味

Macのターミナルに出力された結果を解読します。

[LightGBM] [Info] Start training from score 180921.195890
Sample Predictions: [124527.61, 154677.30, 183652.70, ...]
Score: 0.14679

この 0.14679 というスコアは、ざっくり言うと「平均して14〜15%程度の見積もり誤差がある」状態を指します。

  • 0.14〜0.15(現在): 数値データのみの「とりあえず動くシステム」。
  • 0.12付近: 文字列データ(カテゴリ変数)を適切に処理した「実用レベル」。
  • 0.10以下: 上位ランカー。高度な特徴量生成とアンサンブルが必要な「プロの仕事」。

文字列データを一切使わず、数値のみでこの数値が出たのは、かなり幸先の良いスタートと言えます。

7. まとめ:次なる一歩

数値データだけで土俵に立ちましたが、まだ以下の「伸び代」が残っています。

  1. 無視された文字列データ: 立地(Neighborhood)などの、価格に直結する重要な情報がまだ「コメントアウト」されている状態です。
  2. 価格の歪み: 100万ドルの家と10万ドルの家では、同じ1万ドルのミスでも意味が違います。これを比率で評価させる「対数変換」の導入が必要です。

一本の直線を引くのではなく、数千の分岐で見積もりを研ぎ澄ます。15%の誤差を削る、新たな戦いの始まり。


【Kaggle挑戦記】Spaceship Titanic 攻略 #14:GroupSizeのカテゴリ化。重要度への反映と精度のトレードオフ

1. 実験:人数の「意味」をモデルに教える

前回の分析で判明した「4人グループの異常な転送率(64%)」をモデルに直接認識させるため、GroupSizeを Solo / Small / Large の3カテゴリに分類しました。 連続的な数値としてではなく、独立した属性として扱うことで、モデルの「気付き」を促す狙いです。

2. 【実装】GroupCategory導入版・フルソースコード

分析に基づき、最も転送率が高かった2〜4人を「Small」と定義。これを特徴量として追加したコードです。

import pandas as pd
import numpy as np
import lightgbm as lgb

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

# グループサイズ計算用
all_df = pd.concat([train, test], axis=0)
all_groups = all_df['PassengerId'].apply(lambda x: x.split('_')[0]).value_counts()

# 2. 特徴量エンジニアリング
spend_cols = ["RoomService", "FoodCourt", "ShoppingMall", "Spa", "VRDeck"]

def get_group_category(size):
    if size == 1:
        return 'Solo'
    elif 2 <= size <= 4:
        return 'Small' # 転送率が極めて高い層
    else:
        return 'Large' # 大家族層

for df in [train, test]:
    # --- A. 支出の論理補完 ---
    df[spend_cols] = df[spend_cols].fillna(0)
    total_spend = df[spend_cols].sum(axis=1)
    df.loc[(df['CryoSleep'].isnull()) & (total_spend > 0), 'CryoSleep'] = False
    df.loc[(df['CryoSleep'].isnull()) & (total_spend == 0), 'CryoSleep'] = True
    df['Age'] = df['Age'].fillna(df['Age'].median())

    # --- B. Cabinの物理分解 ---
    df['Cabin'] = df['Cabin'].fillna('U/U/U')
    df['Cabin_Deck'] = df['Cabin'].apply(lambda x: x.split('/')[0])
    df['Cabin_Side'] = df['Cabin'].apply(lambda x: x.split('/')[-1])

    # --- C. GroupSizeのカテゴリ化 ---
    group_id = df['PassengerId'].apply(lambda x: x.split('_')[0])
    df['GroupSize'] = group_id.map(all_groups)
    df['GroupCategory'] = df['GroupSize'].apply(get_group_category)

# 3. 特徴量の選定
features = [
    "CryoSleep", "Age", "RoomService", "FoodCourt", "ShoppingMall", 
    "Spa", "VRDeck", "Cabin_Deck", "Cabin_Side", "GroupCategory"
]

# 4. 整形と学習
X = pd.get_dummies(train[features], drop_first=True)
y = train["Transported"].astype(int)
X_test = pd.get_dummies(test[features], drop_first=True)
X, X_test = X.align(X_test, join='left', axis=1, fill_value=0)

model = lgb.LGBMClassifier(n_estimators=100, learning_rate=0.05, random_state=1)
model.fit(X, y)

# 5. 予測と保存
predictions = model.predict(X_test)
output = pd.DataFrame({'PassengerId': test['PassengerId'], 'Transported': predictions.astype(bool)})
output.to_csv('sub_v14_group_cat.csv', index=False)

# 6. 分析ログの出力
print("\n 特徴量寄与度 (Importance) - Top 15")
importances = pd.DataFrame({'Feature': X.columns, 'Importance': model.feature_importances_}).sort_values(by='Importance', ascending=False)
print(importances.head(15))

3. 結果と分析:重要度の浮上と精度の乖離

リーダーボードの結果は 0.79985。惜しくも0.8を下回る結果となりました。 一方で、コンソールの Importance には明らかな変化が現れました。

 特徴量寄与度 (Importance)
...
13. Cabin_Deck_F         : 55
14. Cabin_Deck_U         : 54
15. GroupCategory_Small  : 24 (New!)

前回は圏外だったグループ関連の指標が、上位15項目に食い込んできました。 モデルが「2〜4人組であること」を判断の一助にしたことは確かです。 しかし、スコアが下がった理由は、カテゴリ化したことで「5人組」や「8人組」といった細かな人数の違いによる情報の解像度が失われ、予測がマイルドになりすぎたことにあると考えられます。

4. 結論

特徴量を「意味のある塊」にまとめる手法は、重要度を上げるのには有効でしたが、今回のような複雑なデータセットでは、生の数値が持っていた細かなニュアンスも重要だったようです。 次は、この「人数の意味」を消さずに、さらに情報の密度を高めるアプローチ(グループ内の他者の状態など)への転換が必要です。


一歩下がって、データの解像度を見直す。スコアの変動は、モデルからのフィードバックに他ならない。




【Kaggle挑戦記】Spaceship Titanic 攻略 #13:IDに隠された「絆」を解く。グループ人数と転送率の意外な相関<

1. PassengerId の仕様から「集団」を定義する

これまで乗客一人ひとりのスペック(年齢や支出)に注目してきましたが、今回は視点を広げ、乗客が属する「グループ」に着目しました。PassengerId の前半4桁を抽出し、同じIDを持つメンバーの数をカウント。新特徴量 GroupSize としてモデルに投入しました。

2. 【実装】グループ解析機能付き・フルソースコード

支出の論理補完、Cabinの物理分解、そして今回のグループサイズ抽出を統合したコードです。最後に、データの裏側を暴くための分析ログを出力するように設計しています。

import pandas as pd
import numpy as np
import lightgbm as lgb

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

# グループサイズを正確に測るため全データを結合
all_df = pd.concat([train, test], axis=0)
all_groups = all_df['PassengerId'].apply(lambda x: x.split('_')[0]).value_counts()

# 2. 特徴量エンジニアリング
spend_cols = ["RoomService", "FoodCourt", "ShoppingMall", "Spa", "VRDeck"]

for df in [train, test]:
    # --- A. 支出の論理補完 ---
    df[spend_cols] = df[spend_cols].fillna(0)
    total_spend = df[spend_cols].sum(axis=1)
    df.loc[(df['CryoSleep'].isnull()) & (total_spend > 0), 'CryoSleep'] = False
    df.loc[(df['CryoSleep'].isnull()) & (total_spend == 0), 'CryoSleep'] = True
    df['Age'] = df['Age'].fillna(df['Age'].median())

    # --- B. Cabinの物理分解 ---
    df['Cabin'] = df['Cabin'].fillna('U/U/U')
    df['Cabin_Deck'] = df['Cabin'].apply(lambda x: x.split('/')[0])
    df['Cabin_Side'] = df['Cabin'].apply(lambda x: x.split('/')[-1])

    # --- C. GroupSize(グループ人数)の抽出 ---
    df['Group_ID'] = df['PassengerId'].apply(lambda x: x.split('_')[0])
    df['GroupSize'] = df['Group_ID'].map(all_groups)

# 3. 学習の準備
features = ["CryoSleep", "Age", "RoomService", "FoodCourt", "ShoppingMall", "Spa", "VRDeck", "Cabin_Deck", "Cabin_Side", "GroupSize"]
X = pd.get_dummies(train[features], drop_first=True)
y = train["Transported"].astype(int)
X_test = pd.get_dummies(test[features], drop_first=True)
X, X_test = X.align(X_test, join='left', axis=1, fill_value=0)

# 4. モデル学習
model = lgb.LGBMClassifier(n_estimators=100, learning_rate=0.05, random_state=1)
model.fit(X, y)

# 5. 予測と保存
predictions = model.predict(X_test)
output = pd.DataFrame({'PassengerId': test['PassengerId'], 'Transported': predictions.astype(bool)})
output.to_csv('sub_v13_groupsize.csv', index=False)

# 6. 分析ログの出力
print("\n グループサイズ別 統計データ")
analysis = train.copy()
analysis['GroupSize'] = analysis['PassengerId'].apply(lambda x: x.split('_')[0]).map(all_groups)
summary = analysis.groupby('GroupSize')['Transported'].mean()
for size, rate in summary.items():
    print(f"グループ人数 {int(size)}人 : 転送率 {rate:.2%}")

print("\n 特徴量寄与度 (Importance)")
importances = pd.DataFrame({'Feature': X.columns, 'Importance': model.feature_importances_}).sort_values(by='Importance', ascending=False)
print(importances.head(15)) # 傾向把握のため上位15件を表示

3. 結果と考察:データが語る「家族の運命」

リーダーボードの結果は 0.80243。前回のベストスコア(0.80406)には一歩届きませんでしたが、コンソールが出力した統計データには驚くべき事実が隠されていました。

 グループサイズ別 統計データ
グループ人数 1人 : 転送率 45.24%
グループ人数 2人 : 転送率 53.80%
グループ人数 3人 : 転送率 59.31%
グループ人数 4人 : 転送率 64.08%
グループ人数 8人 : 転送率 39.42%

1人旅の転送率が約45%なのに対し、4人家族(グループ)では64.08%と跳ね上がっています。一方で、8人の大家族になると39.42%まで急落します。「中規模な家族ほど、何らかの理由で揃って異次元へ転送されやすかった」というドラマチックな偏りが見て取れます。

4. Importanceが示す「支出データの壁」

スコアが伸び悩んだ理由は、モデルが弾き出した Importance(重要度) の数値に如実に表れていました。

 特徴量寄与度 (Importance)
1. Spa           : 436
2. VRDeck        : 429
3. FoodCourt     : 414
4. Age           : 375
...
10. Cabin_Deck_E : 89
(GroupSize は圏外)

上位を占めるのは依然として Spa, VRDeck, FoodCourt といった「個人の状態」を示す支出データです。今回投入した GroupSize は、統計的な傾向(4人組は危ない、など)こそあるものの、LightGBMが「Spaに金を使っているかどうか」以上に優先すべき判断基準とは見なさなかったようです。

5. まとめ:0.8突破のその先にある「壁」

今回の実験で、宇宙船内の「社会的な繋がり」が運命を左右している確証は得られました。しかし、単なる「人数」という数字だけでは、支出データが持つ圧倒的な情報量には勝てないことも浮き彫りになりました。

「傾向はあるが、決め手にならない」。このジレンマを解消するには、次は単なる人数だけでなく、グループ内での「全員寝ていたか?」「全員同じデッキか?」といった、より深い関係性の抽出――つまり、集団としての「文脈」をさらに深掘りする必要がありそうです。