【機械学習初心者向け】Space Titanicで基本的な特徴量エンジニアリングをしてみた

スポンサーリンク

この記事では、人気のKaggleコンペティション「Space Titanic」を題材に、データクリーニングから最初の機械学習モデルを提出するまでの一連の流れを、ステップバイステップで解説します。

第一弾では「とりあえず手を動かして、機械学習を体験してみる」ことを目標にします。この記事を読み終える頃には、Kaggleで予測を提出できるようになっているはずです!

この記事でやること

  1. データの観察: どんなデータがあるのか見てみよう
  2. データの前処理: 機械学習できるようデータを整えよう(欠損値処理、カテゴリ変数の変換など)
  3. モデルの学習と予測: XGBoostという強力なモデルを使って予測しよう
  4. Kaggleへの提出: 作ったモデルの性能を見てみよう

環境構築にお困りの方はこちらの記事も参考までに。

使用データについて

今回使用するのは、Kaggleの「Space Titanic」コンペティションで提供されているデータセットです。

背景ストーリー(翻訳したものをざっくりと):

宇宙船タイタニック号が時空の異常に巻き込まれてしまいました!乗客の一部は別の次元に転送されてしまったようです。私たちのタスクは、乗客のデータから、誰が転送されてしまったのか(Transported 列)を予測することです。

データファイル: 主に以下の2つのCSVファイルを使用します。

  • train.csv: モデルの学習に使用するデータ。各乗客の情報と、その乗客が転送されたかどうか(Transported)が含まれています。
  • test.csv: モデルの予測対象となるデータ。Transported 列以外は train.csv と同じカラム構成です。

カラム一覧とその意味

データに含まれる主なカラムとその意味は以下の通りです。

カラム名説明データ型
PassengerId乗客のユニークID(例: gggg_pp、ggggはグループ、ppはグループ内番号)object
HomePlanet出身惑星object (カテゴリ)
CryoSleep仮死状態で航行中か(船内活動をしていない状態)bool
Cabin客室番号(例: deck/num/side、deckはデッキ、numは番号、sideはP:左舷/S:右舷)object (カテゴリ)
Destination目的地の惑星object (カテゴリ)
Age年齢float
VIPVIPサービスを利用したかbool
RoomServiceルームサービスでの請求額float
FoodCourtフードコートでの請求額float
ShoppingMallショッピングモールでの請求額float
Spaスパでの請求額float
VRDeckVRデッキでの請求額float
Name乗客の名前object
Transported乗客が別の次元に転送されたかどうか(目的変数)bool

これらの情報を元に、乗客が転送されたかどうかを予測するモデルを作っていきます。
では、早速データの読み込みと観察から始めましょう!

import numpy as np
import pandas as pd
import warnings
import xgboost as xgb

# データのインポート
df_train = pd.read_csv("data/train.csv")
df_test = pd.read_csv("data/test.csv")

# データの先頭を少し見てみましょう
print(df_train.head())
print(df_test.head())

ステップ1:データの読み込みと観察

まずは、分析の土台となるデータをプログラムに読み込み、どんな情報が含まれているのか大まかに掴んでいきましょう。

カテゴリ変数のユニーク値を確認

データの中には、数値(年齢や支払い金額など)だけでなく、カテゴリ(出身惑星や目的地など)で表される情報も多く含まれています。これらのカテゴリ変数が具体的にどのような値を持っているのかを確認するのは、データを理解する上で重要です。

以下のようにしてカテゴリ変数の列(object型やbool型)に含まれるユニークな値の数を調べます。

# ユニーク値の探索
cat_cols = df_train.select_dtypes(include=['object', 'bool']).columns

uniq_counts = (
    df_train[cat_cols]
    .nunique(dropna=True)            # NaN (欠損値) を除いてユニーク数を計算
    .sort_index()
)

print("学習データのカテゴリカルな列に含まれるユニークな値の数:")
print(uniq_counts)

このコードを実行すると、例えば HomePlanet にはいくつの異なる惑星が存在するのか、Cabin にはいくつの種類の客室があるのか、といった情報が分かります。出力は以下のとおりです。

# 出力
Cabin          6560
CryoSleep         2
Destination       3
HomePlanet        3
Name           8473
PassengerId    8693
Transported       2
VIP               2
dtype: int64

Name、PassengerIdは乗客に固有の値なのでユニークな値が多いのは当然です。
特にユニークな値の数が非常に多いカラムはCabinですね。
そのままでは機械学習モデルで扱いにくいため、学習前にどう処理するか工夫が必要です。

ステップ2:欠損値代入

実際のデータには、残念ながら情報が欠けている部分(欠損値、よく NaN と表示されます)が存在することがよくあります。これらの欠損値をそのままにしておくと、多くの機械学習モデルはうまく学習できません。そのため、欠損値がどれくらいあるのかを確認し、適切に対応することが非常に重要です。

欠損率の確認

まずは、訓練データとテストデータの各カラムに、どれくらいの欠損値が含まれているのかを確認しましょう。
以下のコードでは、各カラムをチェックし、欠損値があればその数と全体に対する割合(パーセンテージ)を表示しています。

# 欠損値確認
print("-- 訓練データの欠損値状況 --")
for column in df_train.columns:
    if df_train[column].isnull().any(): # その列に一つでも欠損値があるか
        nan_count = df_train[column].isnull().sum() # 欠損値の数
        total_rows = len(df_train) # 全体の行数
        nan_percentage = (nan_count / total_rows) * 100 # 欠損値の割合
        print(f"{column}: {nan_count}件の欠損 ({nan_percentage:.2f}%)")

print("\n-- テストデータの欠損値状況 --")
for column in df_test.columns:
    if df_test[column].isnull().any():
        nan_count = df_test[column].isnull().sum()
        total_rows = len(df_test)
        nan_percentage = (nan_count / total_rows) * 100
        print(f"{column}: {nan_count}件の欠損 ({nan_percentage:.2f}%)")

出力は以下になります。

-- Columns with NaN in train_df, their counts, and percentages --
HomePlanet: 201 NaNs (2.31%)
CryoSleep: 217 NaNs (2.50%)
Cabin: 199 NaNs (2.29%)
Destination: 182 NaNs (2.09%)
Age: 179 NaNs (2.06%)
VIP: 203 NaNs (2.34%)
RoomService: 181 NaNs (2.08%)
FoodCourt: 183 NaNs (2.11%)
ShoppingMall: 208 NaNs (2.39%)
Spa: 183 NaNs (2.11%)
VRDeck: 188 NaNs (2.16%)
Name: 200 NaNs (2.30%)

-- Columns with NaN in test_df, their counts, and percentages --
HomePlanet: 87 NaNs (2.03%)
CryoSleep: 93 NaNs (2.17%)
Cabin: 100 NaNs (2.34%)
Destination: 92 NaNs (2.15%)
Age: 91 NaNs (2.13%)
VIP: 93 NaNs (2.17%)
RoomService: 82 NaNs (1.92%)
FoodCourt: 106 NaNs (2.48%)
ShoppingMall: 98 NaNs (2.29%)
Spa: 101 NaNs (2.36%)
VRDeck: 80 NaNs (1.87%)
Name: 94 NaNs (2.20%)

12個の列に欠損値があることがわかります。つまり、PassengerId、Transported(目的変数)以外には欠損値があります。

この結果を見ることで、「このカラムは欠損が多いから注意が必要だな」「このカラムはほとんど欠損がないな」といった判断ができます。欠損が多いカラムの扱いには特に注意が必要です。

今回は全て欠損率が2%くらいと多くないため、平均値や最頻値で埋める方法をとりましょう。

欠損値補完

欠損値を確認したら、次はその欠損値をどう扱うかを決めます。主な方法としては、欠損値を含む行や列を削除する方法と、何らかの値で欠損値を埋める(補完する)方法があります。今回は、情報をできるだけ失わないように、補完する方法を選びます。

以下のような方針で欠損値を補完していきます。

  • 数値カラム(年齢や各種サービスの利用額など):そのカラムの平均値で補完
  • ブール値カラムCryoSleep, VIP)やカテゴリカルカラムHomePlanet, Destination, Cabin):そのカラムで最もよく出現する値(最頻値)で補完
  • Name カラム: 今回の予測では直接使用しないと判断し、カラムごと削除
# 欠損値を埋める
# まずは訓練データから補完に使う値を計算
# 数値列(平均値)
age_mean          = df_train["Age"].mean()
roomservice_mean  = df_train["RoomService"].mean()
foodcourt_mean    = df_train["FoodCourt"].mean()
shoppingmall_mean = df_train["ShoppingMall"].mean()
spa_mean          = df_train["Spa"].mean()
vrdeck_mean       = df_train["VRDeck"].mean()
# ブールとカテゴリは最頻値
homeplanet_mode = df_train["HomePlanet"].mode()[0] # mode()はシリーズを返すので[0]で値を取得
destination_mode = df_train["Destination"].mode()[0]
cabin_mode       = df_train["Cabin"].mode()[0]
cryoSleep_mode   = df_train["CryoSleep"].mode()[0]
vip_mode         = df_train["VIP"].mode()[0]

# Nameは予測に直接使わないため削除
df_train = df_train.drop(columns=['Name'])
df_test  = df_test.drop(columns=['Name'])

# --- 訓練データの欠損値補完 ---
df_train["Age"]          = df_train["Age"].fillna(age_mean)
df_train["RoomService"]  = df_train["RoomService"].fillna(roomservice_mean)
df_train["FoodCourt"]    = df_train["FoodCourt"].fillna(foodcourt_mean)
df_train["ShoppingMall"] = df_train["ShoppingMall"].fillna(shoppingmall_mean)
df_train["Spa"]          = df_train["Spa"].fillna(spa_mean)
df_train["VRDeck"]       = df_train["VRDeck"].fillna(vrdeck_mean)

df_train["CryoSleep"] = df_train["CryoSleep"].fillna(cryoSleep_mode)
df_train["VIP"]       = df_train["VIP"].fillna(vip_mode)
df_train["HomePlanet"]  = df_train["HomePlanet"].fillna(homeplanet_mode)
df_train["Destination"] = df_train["Destination"].fillna(destination_mode)
df_train["Cabin"]       = df_train["Cabin"].fillna(cabin_mode) # Cabinも一旦最頻値で埋めて後で処理

# --- テストデータの欠損値補完 ---
# テストデータの欠損値補完には、訓練データから計算した平均値や最頻値を使うことがポイント!
df_test["Age"]          = df_test["Age"].fillna(age_mean)
df_test["RoomService"]  = df_test["RoomService"].fillna(roomservice_mean)
df_test["FoodCourt"]    = df_test["FoodCourt"].fillna(foodcourt_mean)
df_test["ShoppingMall"] = df_test["ShoppingMall"].fillna(shoppingmall_mean)
df_test["Spa"]          = df_test["Spa"].fillna(spa_mean)
df_test["VRDeck"]       = df_test["VRDeck"].fillna(vrdeck_mean)

df_test["CryoSleep"] = df_test["CryoSleep"].fillna(cryoSleep_mode)
df_test["VIP"]       = df_test["VIP"].fillna(vip_mode)
df_test["HomePlanet"]  = df_test["HomePlanet"].fillna(homeplanet_mode)
df_test["Destination"] = df_test["Destination"].fillna(destination_mode)
df_test["Cabin"]       = df_test["Cabin"].fillna(cabin_mode)

# 補完後の欠損値がないか確認(省略しますが、再度isnull().sum()で確認すると良いでしょう)

ステップ3:カテゴリ変数のエンコーディング

さて、欠損値の対応が終わりました。次に取り組むのは、機械学習モデルがデータをよりよく理解できるようにするための「エンコーディング」という処理です。

多くの機械学習モデルは、数値データを入力として受け取ることを前提としています。しかし、このデータには「出身惑星(例:Earth, Mars)」や「目的地(例:TRAPPIST-1e)」のような文字で表されるカテゴリデータがたくさん含まれていますよね。これらをそのままモデルに入力することはできません。

そこで、これらのカテゴリデータを数値表現に変換するエンコーディングが必要になります。

# まずは学習データ (df_train) から処理します

# --- ブール値 (True/False) を 0/1 に変換 ---
# これは最もシンプルなエンコーディングの一つです。
bool_map = {True: 1, False: 0}

df_train["VIP"] = df_train["VIP"].map(bool_map)
df_train["CryoSleep"] = df_train["CryoSleep"].map(bool_map)
df_train['Transported'] = df_train['Transported'].map(bool_map)  # 予測対象の列も同様に

VIPCryoSleep、そして予測対象である Transported は、元々 True または False のブール値でした。これを、機械学習モデルが扱いやすいように 1(True)と 0(False)に変換しています。

特徴量エンジニアリング①:PassengerId を分割してみる

PassengerIdgggg_pp という形式(例: 0001_01)で、gggg が同乗者のグループID、pp がそのグループ内での個人IDを示しています。このままだとただのIDですが、グループに関する情報やグループ内の人数が予測に役立つかもしれません。

そこで、この PassengerId を分割して新しい特徴量(モデルが学習に使う情報)を作り出します。これを特徴量エンジニアリングと呼びます。

# PassengerIdをグループIDとグループ内番号に分割
df_train[['Group', 'NumInGroup']] = df_train['PassengerId'].str.split('_', expand=True).astype(int)
# 'PassengerId' 自体は後で削除します

ここでは、_ を区切り文字として PassengerId を2つの列 GroupNumInGroup に分割し、それぞれを整数型に変換しています。これで、例えば「同じグループの人は一緒に転送されやすい/されにくい」といった傾向をモデルが学習できるかもしれません。

One-Hot Encoding:HomePlanet と Destination

HomePlanet(出身惑星)や Destination(目的地)のようなカテゴリ変数は、互いに順序関係がない(例:EarthがMarsより「偉い」わけではない)名義尺度です。このような変数を数値に変換する一般的な方法の一つが One-Hot Encoding (ワンホットエンコーディング) です。

これは、各カテゴリ値を新しい列とし、該当するカテゴリであれば 1、そうでなければ 0 を入れる方法です。例えば HomePlanet に “Earth”, “Mars”, “Europa” の3種類がある場合、以下のように3つの新しい列が作られます。

  • HomePlanet_Earth
  • HomePlanet_Mars
  • HomePlanet_Europa

ある乗客の HomePlanet が “Earth” なら、HomePlanet_Earth1 になり、他は 0 になります。
Pandasの get_dummies() 関数を使うと、これを簡単に行えます。

# One-Hot Encoding を行う
df_train = pd.get_dummies(
    df_train,
    columns=['HomePlanet', 'Destination'], # これらの列をOne-Hot化
    drop_first=True # 最初のカテゴリの列を削除する
)

ここで drop_first=True というオプションが重要です。これを使うと、例えば HomePlanet の最初のカテゴリ(例:Earth)に対応する列 HomePlanet_Earth が作られず、他の HomePlanet_MarsHomePlanet_Europa が全て 0 の場合にそれが “Earth” であることを示すようになります。これは、複数の列が互いに完全に依存しあう関係(多重共線性)を防ぐためで、モデルの安定性を高める効果があります。

特徴量エンジニアリング②:Cabin の分割とエンコーディング

Cabin(客室番号)は、例えば F/33/S のように デッキ/番号/サイド という構造を持っています。この情報も、それぞれが予測に役立つ可能性があります。

Side: 客室が船のどちら側か (P: 左舷, S: 右舷)

Deck: どのデッキにいたか (A, B, C, …)

CabinNum: 客室番号 (数値)

それぞれをモデルが使える形に変換しています。

# Cabin列を Deck, CabinNum, Sideに分割
df_train[['Deck', 'CabinNum', 'Side']] = df_train['Cabin'].str.split('/', expand=True)

# CabinNum を数値型に変換
df_train['CabinNum'] = df_train['CabinNum'].astype(float) # 欠損値がある可能性も考慮しfloat型に

# Side ('P' or 'S') を数値 (1 or 0) にマッピング
df_train['Side'] = df_train['Side'].map({'P': 1, 'S': 0})

# Deck を One-Hot Encoding (先ほどと同様に drop_first=True)
df_train = pd.get_dummies(
    df_train,
    columns=['Deck'],
    drop_first=True
)

# 元のCabin列とPassengerId列はもう不要なので削除
df_train = df_train.drop(columns=['Cabin', 'PassengerId'])

Cabin から3つの新しい情報を引き出し、CabinNum は数値として、Side は0/1の値として、そして Deck はOne-Hot Encodingで処理しています。Cabin 自体は多くのユニーク値を持ち、そのままでは扱いにくいため、このように情報を分解して活用するのは非常に有効なアプローチです。

テストデータにも同じ処理を!

ここまで学習データ (df_train) に対して行ってきた前処理は、全く同じようにテストデータ (df_test) に対しても行う必要があります。モデルが学習したのと同じ形式のデータを予測時にも与えなければ、正しい予測はできません。

# --- テストデータ (df_test) のダミー変数化 ---
# 学習データと全く同じ処理を施します

bool_map      = {True: 1, False: 0}
# deck_columns  = [col for col in df_train.columns if col.startswith('Deck_')] # これは後で使うかも

# 1) ブール値を0/1に
df_test['VIP']       = df_test['VIP'].map(bool_map)
df_test['CryoSleep'] = df_test['CryoSleep'].map(bool_map)

# 2) PassengerId を分割
df_test[['Group', 'NumInGroup']] = (
    df_test['PassengerId']
    .str.split('_', expand=True)
    .astype(int)
)

# 3) HomePlanet, Destination を One-Hot Encoding
df_test = pd.get_dummies(
    df_test,
    columns=['HomePlanet', 'Destination'],
    drop_first=True
)

# 4) Cabin を分割し、Deck を One-Hot Encoding
df_test[['Deck', 'CabinNum', 'Side']] = df_test['Cabin'].str.split('/', expand=True)
df_test['CabinNum'] = pd.to_numeric(df_test['CabinNum'], errors='coerce') # 数値に変換できない場合はNaNにする
df_test['Side']     = df_test['Side'].map({'P': 1, 'S': 0})

df_test = pd.get_dummies(
    df_test,
    columns=['Deck'],
    drop_first=True
)

# 不要な列を削除
df_test = df_test.drop(columns=['Cabin', 'PassengerId'])

# --- One-Hot Encoding 後のカラム数合わせ(重要!) ---
# 学習データとテストデータで、One-Hot Encodingによって作られた列の数が
# 食い違うことがあります (例えば、テストデータにしか現れないカテゴリがあった場合など)。
# そのような場合、列数を合わせる処理が必要になります。
# 今回のコードでは、そのための直接的な処理は書かれていませんが、
# 念のため、生成されたダミー変数名を確認しておきます。

ohe_cols_train = [c for c in df_train.columns if c.startswith(('HomePlanet_', 'Destination_', 'Deck_'))]
ohe_cols_test = [c for c in df_test.columns if c.startswith(('HomePlanet_', 'Destination_', 'Deck_'))]
print("\n学習データに作られたOne-Hotエンコード列:", ohe_cols_train)
print("テストデータに作られたOne-Hotエンコード列:", ohe_cols_test)

One-Hot Encoding時の注意点:

学習データとテストデータでカテゴリの種類が異なると、get_dummies で生成される列の数や種類がズレることがあります。例えば、学習データには HomePlanet_Mars があったのに、テストデータには Mars 出身の乗客がいなかった場合、テストデータには HomePlanet_Mars 列が作られません。 このような場合、モデルが学習時と異なる特徴量のセットを受け取ることになり、エラーが出たり、予測精度が低下したりします。 上のコードでは、ohe_cols_trainohe_cols_test で生成された列名を確認しています。今回は、このような問題は起こりませんでしたが、常に意識しておくと良いポイントです。

ステップ4:モデルの学習と予測

これまでのステップで、データを読み込み、欠損値を処理し、そして機械学習モデルが理解しやすいようにカテゴリ変数を数値に変換(エンコーディング)してきました。

「とりあえずやってみる」が今回のテーマですので、ここでは非常に強力で人気のある機械学習アルゴリズムの一つである XGBoost を使ってみましょう。細かい理論やパラメータ調整は今回は省略し、まずは動かしてみることを体験します。

説明変数と目的変数の準備

まず、学習データを「何を使って予測するか(説明変数 X)」と「何を予測したいか(目的変数 y)」に分けます。

目的変数 (train_y): 乗客が転送されたかどうか (Transported) のデータ(0か1にエンコード済み)。

説明変数 (train_x): 乗客が転送されたかどうか (Transported) 以外の、すべての前処理済みデータ。

# 説明変数、目的変数への分割
train_y = df_train["Transported"]
train_x = df_train.drop("Transported", axis=1) # Transported列以外を説明変数とする

# テストデータも同様に準備 (こちらは予測対象なので目的変数はない)
test_x = df_test.copy() # df_testをそのまま予測に使うが、念のためコピーしておくことも良い習慣

モデルの選択、学習、予測

XGBoostモデルを使って学習と予測を行います。手順はとてもシンプルです。

  1. モデルの初期化: XGBoostの分類器(XGBClassifier)を使います、と宣言します。
  2. モデルの学習: 準備した説明変数 (train_x) と目的変数 (train_y) を使って、モデルに「どのような乗客が転送されやすいか」のパターンを学習させます (.fit())。
  3. 予測の実行: 学習済みモデルを使って、テストデータ (test_x) の乗客たちが転送されたかどうかを予測します (.predict())。
# モデルの初期化
clf = xgb.XGBClassifier() # とりあえずデフォルトの設定で使ってみます

# モデルの学習
clf.fit(train_x, train_y)

# テストデータで予測
result = clf.predict(test_x)
print("予測結果 (最初の10件):", result[:10]) # 予測結果は0か1の配列になります

これで、result という変数の中に、テストデータの各乗客が転送されたかどうかの予測結果(0または1)が格納されました。

提出用ファイルの作成

Kaggleコンペティションでは、予測結果を特定のフォーマットのCSVファイルにして提出する必要があります。Space Titanicコンペでは、PassengerId と予測した Transported の値を紐付けたファイルが求められます。

予測結果の result は数値(0または1)なので、提出フォーマットに合わせてブール値(TrueまたはFalse)に変換し、元のテストデータから PassengerId を持ってきて結合します。

# 予測結果をブール値 (True/False) に変換
result_bool = result.astype(bool)

# 元のテストデータから PassengerId を取得するために再度読み込む
# (df_test は前処理で PassengerId をドロップしているため)
df_test_origin = pd.read_csv("data/test.csv")

# 提出用DataFrameの作成
submission = pd.DataFrame({
    "PassengerId": df_test_origin["PassengerId"],
    "Transported": result_bool # bool型の予測結果
})

# CSVファイルとして保存 (index=False で余計な行番号を入れない)
submission_filename = "submission_space_titanic_v1.csv" # ファイル名を定義
submission.to_csv(submission_filename, index=False)

print(f"\n提出ファイル '{submission_filename}' を作成しました。")
print("提出ファイルの先頭5行:")
print(submission.head())

結果発表とまとめ

Space Titanicコンペティションのデータを使って、最初の機械学習モデルの訓練から予測、そして提出ファイルの作成までを完了しました!疲れ様でした。

Kaggleに提出すると、スコア 0.79705でした。
基本的な前処理とXGBoostのデフォルト設定だけでこのスコアが出せたのは、最初の結果としてはまずまずではないでしょうか?

以上です。最後まで読んでいただきありがとうございました!

著者プロフィール
この記事を書いた人
RIO

大学で有機化学を専攻していた人 / 修士→博士→化学メーカー研究職 / 大学院での経験、研究職の仕事、勉強記事など発信していきます! / 学部時代は企業でプログラミング業務のインターンシップをしてました。
Xやnoteで就活系の発信もしてます!

RIOをフォローする
プログラミング機械学習
スポンサーリンク
RIOをフォローする

コメント

タイトルとURLをコピーしました