機械学習コンペティションに参加し始めたばかりの方の中には、なかなかスコアが伸びずに悩んでいる方も多いのではないでしょうか。私自身も、始めたばかりの頃は同じように壁にぶつかり、思うように結果が出ない時期がありました。
しかし、正しい知識と実践的なテクニックを少しずつ積み重ねていくことで、着実にスコアを上げることができるようになります。
この記事では、私がGCIで優秀生に選ばれ、またSIGNATE MASTERとして数々のコンペに取り組んできた経験をもとに、スコア向上につながる具体的なテクニックをご紹介します。初心者の方でも理解しやすいよう、実践的なコード例も交えながら丁寧に解説していきますので、ぜひ参考にしてみてください!
こんな方におすすめ:
- KaggleやSIGNATEのテーブルコンペでスコアを上げたい
- GCIで上位入賞を狙っている
- コンペ上級者の考えが知りたい
効きやすい特徴量エンジニアリング
特徴量エンジニアリングは、機械学習コンペティションで勝つために欠かせない、最も重要なスキルの一つです。モデルの種類やアルゴリズムが同じでも、特徴量の工夫次第でスコアに大きな差が生まれます。
ここでは、私自身の経験から、特に効果が高いと感じている3つの特徴量エンジニアリング手法について、具体的に解説していきます。
どれも実践で結果が出やすいものなので、ぜひ参考にしてみてください!
仮説をもとにした特徴量エンジニアリング
仮説に基づく特徴量エンジニアリングは、データの背景や問題設定を深く理解し、「この要因が結果に影響しているのではないか」といった仮説を立てて新しい特徴量を作成する手法です。単に既存の特徴量を使うだけでなく、ドメイン知識や直感を活用することで、モデルの予測精度を大きく向上させることができます。
仮説を立てるための第一歩は、データの丁寧な分析です。各特徴量の分布を確認し、目的変数との関係性を探ったり、外れ値や欠損値の有無を調べたりします。こうした分析を通じて、データの特性や潜在的な課題に気づくことができます。
例えば、Titanicの生存予測タスクでは、単に「年齢」や「性別」といった情報を使うだけでなく、「家族の人数(SibSp + Parch)」「年齢と性別の組み合わせ」「客室クラスと運賃のバランス」など、より意味のある特徴量を仮説に基づいて設計することで、予測性能を高めることが可能です。
このような特徴量を作成するには、その問題領域(ドメイン)に関する知識が重要です。「何が予測に影響しそうか?」という視点から、既存の特徴量を組み合わせたり変換したりすることで、新しいインサイトを引き出すことができます。仮説が的を射ていれば、少数の特徴量追加でも大きな効果を生むことがあります。
以下は、特徴量エンジニアリングの実装例です:
import seaborn as sns
# Titanicデータセットの読み込み
titanic = sns.load_dataset("titanic")
def create_hypothesis_features(df):
"""仮説ベースの特徴量作成"""
df_new = df.copy()
# 家族の総人数(配偶者・兄弟姉妹 + 親・子供 + 本人)
df_new["family_size"] = df_new["sibsp"] + df_new["parch"] + 1
# 一人で乗船したかどうか
df_new["is_alone"] = (df_new["family_size"] == 1).astype(int)
# 年齢グループと性別の組み合わせ(女性・子供優先の原則)
df_new["is_child"] = (df_new["age"] 18).astype(int)
df_new["is_female_or_child"] = (
(df_new["sex"] == "female") | (df_new["age"] 18)
).astype(int)
# 客室クラスごとの料金の相対的な位置(同じクラス内での料金の高さ)
for pclass in df_new["pclass"].unique():
mask = df_new["pclass"] == pclass
fare_mean = df_new[mask]["fare"].mean()
df_new.loc[mask, f"fare_ratio_class_{pclass}"] = df_new.loc[mask, "fare"] / (
fare_mean + 0.1
)
# 乗船港と客室クラスの組み合わせ(社会的階層の指標)
df_new["embark_class"] = (
df_new["embarked"].astype(str) + "_" + df_new["pclass"].astype(str)
)
# 年齢の欠損を家族情報から推定するフラグ
df_new["age_is_missing"] = df_new["age"].isna().astype(int)
return df_new
# 特徴量作成実行
titanic_enhanced = create_hypothesis_features(titanic)
# 新しい特徴量を確認
new_features = ["family_size", "is_alone", "is_child", "is_female_or_child"]
print("新しい特徴量:")
print(titanic_enhanced[new_features].head())
平均値との差分特徴量
平均値との差分特徴量は、各データポイントが全体の平均値や特定グループの平均値とどれくらい違うかを数値として表現する特徴量です。これにより、そのデータが平均からどの程度ずれているかがわかりやすくなり、モデルが異常値やパターンの違いを捉えやすくなります。
この手法が特に有効なのは、絶対値そのものよりも、「平均からのズレ」が重要な場面です。
たとえばTitanicデータでは、「各乗客の年齢が、同じ客室クラスの平均年齢と比べてどれくらい高い(または低い)か」を差分として表すことで、その乗客の特徴や特異性をより明確に捉えることができます。
ただし、この手法を使う際には外れ値の影響に注意が必要です。極端な値が混ざっていると、平均が歪んでしまい、差分の意味が正確でなくなることがあります。
このような場合には、中央値との差分を使う、あるいは外れ値を除いた平均を計算するといった対策をとることで、より信頼性の高い特徴量を作ることができます。
以下は、平均値との差分特徴量の実装例です:
import seaborn as sns
# Titanicデータセットの読み込み
titanic = sns.load_dataset("titanic")
def create_mean_diff_features(df):
"""平均値との差分特徴量の作成"""
df_new = df.copy()
# 1. 全体平均との差分
df_new["age_diff_from_mean"] = df_new["age"] - df_new["age"].mean()
df_new["fare_diff_from_mean"] = df_new["fare"] - df_new["fare"].mean()
# 2. 客室クラス別平均との差分
for pclass in df_new["pclass"].unique():
mask = df_new["pclass"] == pclass
# 年齢の差分
age_mean = df_new[mask]["age"].mean()
df_new.loc[mask, f"age_diff_from_class_{pclass}"] = (
df_new.loc[mask, "age"] - age_mean
)
# 料金の差分
fare_mean = df_new[mask]["fare"].mean()
df_new.loc[mask, f"fare_diff_from_class_{pclass}"] = (
df_new.loc[mask, "fare"] - fare_mean
)
# 3. 性別・客室クラス別の年齢差分
for sex in df_new["sex"].unique():
for pclass in df_new["pclass"].unique():
mask = (df_new["sex"] == sex) & (df_new["pclass"] == pclass)
if mask.sum() > 0:
age_mean = df_new[mask]["age"].mean()
df_new.loc[mask, f"age_diff_{sex}_{pclass}"] = (
df_new.loc[mask, "age"] - age_mean
)
# 4. 乗船港別の料金差分
for embarked in df_new["embarked"].dropna().unique():
mask = df_new["embarked"] == embarked
if mask.sum() > 0:
fare_mean = df_new[mask]["fare"].mean()
df_new.loc[mask, f"fare_diff_from_port_{embarked}"] = (
df_new.loc[mask, "fare"] - fare_mean
)
return df_new
# 平均値との差分特徴量作成
titanic_enhanced = create_mean_diff_features(titanic)
# 結果の確認
print("年齢の平均値との差分特徴量:")
print(titanic_enhanced[["age", "age_diff_from_mean", "pclass"]].head(10))
比率特徴量
比率特徴量とは、既存の特徴量同士を割り算することで作成される新たな特徴量です。これは、絶対的な数値よりも、特徴量同士の相対的な関係が重要な場合に特に有効で、多くの機械学習コンペティションでも実際に高い効果を発揮しています。
たとえば、Titanicのデータセットでは「家族一人あたりの料金」のような比率特徴量が考えられます。これは「運賃(Fare)」を「家族の人数(SibSp + Parch + 1)」で割ることで得られるもので、一人あたりの実質的な支払額を表します。単に運賃の金額そのものを見るのではなく、家族構成を考慮した相対的な視点から評価することで、より意味のある比較が可能になります。
このような比率特徴量は、決定木系のモデル(LightGBMやXGBoostなど)と非常に相性が良いという特徴もあります。
決定木は、2つの特徴量を使って平面上にプロットしたとき、「縦または横に直線を引いて」領域を分割していくアルゴリズムです。つまり、各特徴量を個別にしか扱えず、「斜めの境界線(=2つの特徴量の組み合わせによる関係)」はそのままでは表現できません。
ここで比率特徴量を導入すると、たとえば「Fare ÷ 家族人数」のように、2つの特徴量を組み合わせた“斜めの関係”を1つの新しい軸として明示的にモデルに渡すことができます。これによって、もともと決定木では捉えづらかったデータの傾向を、モデルがシンプルな分割で学習できるようになります。
ただし、比率を計算する際には注意が必要です。特に分母がゼロになるケースではエラーが発生するため、「年齢 + 1」などのように小さな値を加えてゼロ除算を回避する工夫が重要です。
以下は、比率特徴量の実装例です:
import numpy as np
import seaborn as sns
# Titanicデータセットの読み込み
titanic = sns.load_dataset("titanic")
def create_ratio_features(df):
"""比率特徴量の作成"""
df_new = df.copy()
# 家族人数の計算(ゼロ除算対策)
df_new["family_size"] = df_new["sibsp"] + df_new["parch"] + 1
# 1. 料金関連の比率
# 家族一人あたりの料金
df_new["fare_per_person"] = df_new["fare"] / df_new["family_size"]
# 年齢あたりの料金(年齢による料金の相対値)
df_new["fare_per_age"] = df_new["fare"] / (df_new["age"] + 1)
# 2. 家族構成の比率
# 兄弟姉妹の割合
df_new["sibsp_ratio"] = df_new["sibsp"] / df_new["family_size"]
# 親子の割合
df_new["parch_ratio"] = df_new["parch"] / df_new["family_size"]
# 3. 年齢と客室クラスの関係
# 客室クラスに対する年齢の比率(若い高級客室利用者の検出)
df_new["age_class_ratio"] = df_new["age"] / df_new["pclass"]
# 4. 性別・年齢の複合指標
# 男性の場合の年齢スコア(年齢が若いほど高い)
df_new["male_youth_score"] = np.where(
df_new["sex"] == "male", 50 / (df_new["age"] + 1), 0
)
# 女性の場合の年齢スコア(全年齢で高い値)
df_new["female_score"] = np.where(
df_new["sex"] == "female", 100 / (df_new["age"] + 50), 0
)
# 5. 料金と客室クラスの比率(クラス内での相対的な料金)
df_new["fare_class_ratio"] = df_new["fare"] / df_new["pclass"]
# 6. 生存可能性スコア(複合的な比率)
# 女性・子供、高い客室クラス、少ない家族人数を考慮
df_new["survival_score"] = (
(df_new["sex"] == "female").astype(int) * 2
+ (df_new["age"] 18).astype(int) * 1.5
+ (4 - df_new["pclass"]) / 3
+ 1 / (df_new["family_size"] + 1)
)
return df_new
# 比率特徴量作成
titanic_enhanced = create_ratio_features(titanic)
# 作成された比率特徴量の確認
ratio_features = [
"fare_per_person",
"fare_per_age",
"sibsp_ratio",
"parch_ratio",
"age_class_ratio",
"survival_score",
]
print("比率特徴量のサンプル:")
print(titanic_enhanced[["fare", "age", "family_size"] + ratio_features[:3]].head(10))
Optunaでパラメータチューニング
LightGBMの性能は、学習率・木の深さ・特徴量のサンプリング率などのハイパーパラメータの設定に大きく依存します。しかし、これらを手動で調整するのは非常に時間がかかり、最適な組み合わせを見つけるのは困難です。
そこで活用したいのが、Optunaというハイパーパラメータ最適化ライブラリです。Optunaは、ベイズ最適化に基づくアルゴリズムを用いて、探索的かつ効率的に最適なパラメータを見つけ出します。
数行のコードを追加するだけで、最適化のプロセスを自動化でき、精度向上にもつながります。実務やコンペティションの現場でも広く使われている、非常に強力なツールです。
以下は、Optunaの実装例です:
import warnings
import lightgbm as lgb
import numpy as np
import optuna
import seaborn as sns
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import StratifiedKFold
from sklearn.preprocessing import LabelEncoder
warnings.filterwarnings("ignore")
# Titanicデータの読み込みと前処理
titanic = sns.load_dataset("titanic")
# 前処理関数
def create_features(df):
df_processed = df.copy()
# 欠損値処理
df_processed["age"].fillna(df_processed["age"].median(), inplace=True)
df_processed["fare"].fillna(df_processed["fare"].median(), inplace=True)
df_processed["embarked"].fillna(df_processed["embarked"].mode()[0], inplace=True)
# 特徴量作成
df_processed["family_size"] = df_processed["sibsp"] + df_processed["parch"] + 1
df_processed["is_alone"] = (df_processed["family_size"] == 1).astype(int)
df_processed["fare_per_person"] = df_processed["fare"] / df_processed["family_size"]
df_processed["is_child"] = (df_processed["age"] 18).astype(int)
df_processed["age_class_ratio"] = df_processed["age"] / df_processed["pclass"]
# カテゴリカル変数のエンコーディング
le_sex = LabelEncoder()
df_processed["sex_encoded"] = le_sex.fit_transform(df_processed["sex"])
le_embarked = LabelEncoder()
df_processed["embarked_encoded"] = le_embarked.fit_transform(
df_processed["embarked"]
)
# 特徴量選択
features = [
"pclass",
"sex_encoded",
"age",
"sibsp",
"parch",
"fare",
"embarked_encoded",
"family_size",
"is_alone",
"fare_per_person",
"is_child",
"age_class_ratio",
]
return df_processed[features], df_processed["survived"]
# データの準備
X, y = create_features(titanic)
def objective(trial):
"""最適化する目的関数"""
# パラメータの探索範囲を定義
params = {
"objective": "binary",
"metric": "binary_logloss",
"boosting_type": "gbdt",
"num_leaves": trial.suggest_int("num_leaves", 10, 100),
"learning_rate": trial.suggest_float("learning_rate", 0.01, 0.3),
"feature_fraction": trial.suggest_float("feature_fraction", 0.4, 1.0),
"bagging_fraction": trial.suggest_float("bagging_fraction", 0.4, 1.0),
"bagging_freq": trial.suggest_int("bagging_freq", 1, 7),
"min_data_in_leaf": trial.suggest_int("min_data_in_leaf", 5, 50),
"reg_alpha": trial.suggest_float("reg_alpha", 0, 10),
"reg_lambda": trial.suggest_float("reg_lambda", 0, 10),
"max_depth": trial.suggest_int("max_depth", 3, 12),
"verbosity": -1,
}
# 層化k分割交差検証でモデルを評価
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
cv_scores = []
for train_idx, valid_idx in skf.split(X, y):
X_train, X_valid = X.iloc[train_idx], X.iloc[valid_idx]
y_train, y_valid = y.iloc[train_idx], y.iloc[valid_idx]
# LightGBMデータセット作成
train_data = lgb.Dataset(X_train, label=y_train)
valid_data = lgb.Dataset(X_valid, label=y_valid, reference=train_data)
# モデル学習(アーリーストッピングなし)
model = lgb.train(
params, train_data, num_boost_round=200, valid_sets=[valid_data]
)
# 予測と評価
y_pred_proba = model.predict(X_valid)
auc = roc_auc_score(y_valid, y_pred_proba)
cv_scores.append(auc)
return np.mean(cv_scores)
# Optunaによる最適化実行
print("Optunaによるハイパーパラメータ最適化を開始...")
study = optuna.create_study(direction="maximize")
study.optimize(objective, n_trials=50)
# 結果の確認
print(f"\nベストAUCスコア: {study.best_value:.4f}")
print("ベストパラメータ:")
for key, value in study.best_params.items():
print(f" {key}: {value}")
Seed Averaging
Seed Averaging は、同じモデルを異なるランダムシード(乱数の初期値)で複数回学習させ、それぞれの予測結果を平均することで、予測の安定性と精度を向上させるアンサンブル手法です。
たとえば、LightGBMやXGBoostのようなモデルでは、データのシャッフルや木の構造がランダムに決まるため、同じデータ・同じハイパーパラメータであっても、ランダムシードが異なれば異なるモデルが生成されます。これらの予測(確率値)を平均することで、各モデルに含まれるランダムなノイズや過学習の偏りを打ち消す効果が得られます。
この手法は非常にシンプルで実装も容易ですが、驚くほど高い効果を発揮することがあります。特にKaggleのような、わずかな精度向上がスコアに直結する競技環境では、手軽にスコアを底上げできる強力なテクニックとして広く使われています。
時間や計算資源に余裕がある場合は、3〜5個程度のモデルを異なるシードで学習させて平均するだけでも、予測が滑らかになり、ブレにくくなることが期待できます。
初期の段階で手軽に導入できるアンサンブル手法として、特に初心者にもおすすめです。
以下は、Seed Averaging の実装例です:
import warnings
import lightgbm as lgb
import numpy as np
import seaborn as sns
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import StratifiedKFold
from sklearn.preprocessing import LabelEncoder
warnings.filterwarnings("ignore")
# Titanicデータの読み込み
titanic = sns.load_dataset("titanic")
# 前処理関数
def create_features(df):
df_processed = df.copy()
df_processed["age"].fillna(df_processed["age"].median(), inplace=True)
df_processed["fare"].fillna(df_processed["fare"].median(), inplace=True)
df_processed["embarked"].fillna(df_processed["embarked"].mode()[0], inplace=True)
df_processed["family_size"] = df_processed["sibsp"] + df_processed["parch"] + 1
df_processed["is_alone"] = (df_processed["family_size"] == 1).astype(int)
df_processed["fare_per_person"] = df_processed["fare"] / df_processed["family_size"]
df_processed["is_child"] = (df_processed["age"] 18).astype(int)
df_processed["age_squared"] = df_processed["age"] ** 2
df_processed["fare_log"] = np.log1p(df_processed["fare"])
le_sex = LabelEncoder()
df_processed["sex_encoded"] = le_sex.fit_transform(df_processed["sex"])
le_embarked = LabelEncoder()
df_processed["embarked_encoded"] = le_embarked.fit_transform(
df_processed["embarked"]
)
features = [
"pclass",
"sex_encoded",
"age",
"sibsp",
"parch",
"fare",
"embarked_encoded",
"family_size",
"is_alone",
"fare_per_person",
"is_child",
"age_squared",
"fare_log",
]
return df_processed[features], df_processed["survived"]
# データ準備
X, y = create_features(titanic)
# LightGBMパラメータ
base_params = {
"objective": "binary",
"metric": "binary_logloss",
"boosting_type": "gbdt",
"num_leaves": 31,
"learning_rate": 0.05,
"feature_fraction": 0.8,
"bagging_fraction": 0.8,
"bagging_freq": 5,
"min_data_in_leaf": 10,
"max_depth": 6,
"verbosity": -1,
}
# モデル学習
def train_single_seed(X_train, y_train, X_valid, y_valid, params, seed):
params_with_seed = params.copy()
params_with_seed["random_state"] = seed
params_with_seed["bagging_seed"] = seed
params_with_seed["feature_fraction_seed"] = seed
train_data = lgb.Dataset(X_train, label=y_train)
model = lgb.train(
params_with_seed,
train_data,
num_boost_round=300,
callbacks=[lgb.log_evaluation(0)],
)
y_pred_proba = model.predict(X_valid)
auc = roc_auc_score(y_valid, y_pred_proba)
return y_pred_proba, auc
# シードとKFold設定
seeds = [42, 713, 2025]
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
# 評価スコア格納
seed_aucs = {seed: [] for seed in seeds}
ensemble_aucs = []
for fold, (train_idx, valid_idx) in enumerate(skf.split(X, y), 1):
X_train, X_valid = X.iloc[train_idx], X.iloc[valid_idx]
y_train, y_valid = y.iloc[train_idx], y.iloc[valid_idx]
fold_preds = []
print(f"Fold {fold}:")
for seed in seeds:
y_pred_proba, auc = train_single_seed(
X_train, y_train, X_valid, y_valid, base_params, seed
)
seed_aucs[seed].append(auc)
fold_preds.append(y_pred_proba)
print(f" Seed {seed}: AUC = {auc:.4f}")
# アンサンブル予測
ensemble_pred_proba = np.mean(fold_preds, axis=0)
ensemble_auc = roc_auc_score(y_valid, ensemble_pred_proba)
ensemble_aucs.append(ensemble_auc)
print(f" Ensemble: AUC = {ensemble_auc:.4f}\n")
# 平均AUCの出力
print("全Foldの平均AUC:")
for seed in seeds:
mean_auc = np.mean(seed_aucs[seed])
std_auc = np.std(seed_aucs[seed])
print(f" Seed {seed}: Mean AUC = {mean_auc:.4f} (+/- {std_auc:.4f})")
mean_ens_auc = np.mean(ensemble_aucs)
std_ens_auc = np.std(ensemble_aucs)
print(f" Ensemble: Mean AUC = {mean_ens_auc:.4f} (+/- {std_ens_auc:.4f})")
本気でGCI優秀生を目指す学生へ
GCI(東京大学グローバル消費インテリジェンス寄付講座)は、東大松尾研究室が主催する、機械学習・データサイエンスを無料で学べる非常に有意義なオンライン講座です。
私自身、この講座を受講し、GCI優秀生に選ばれたことで、全国から集まる優秀な仲間たちと出会うことができました。そして今では、松尾研発AIスタートアップ「株式会社2WINS」で、データサイエンティストとして働いています。
GCIではスキルだけでなく、「GCI優秀生」という肩書きも非常に強力で、キャリアの大きな武器になりました。振り返ってみても、本気で取り組んで本当によかったと心から感じています。
しかし、受講当時にはこんな悩みもありました:
- オンライン講義のため、切磋琢磨できる仲間が身近にいない
- ひとりで優秀生を目指すのはモチベーションの維持が難しい
- 過去の優秀生がどのように活躍しているのか知りたい
そこで現在、私が所属する株式会社2WINSでは、「本気でGCI優秀生を目指す学生」を対象とした勉強会を開催しています。
同じ目標を持つ仲間とつながり、実践的な学びを深め、将来に活きる経験を積むチャンスです。興味のある方は、ぜひ気軽にご参加ください!
詳細は広告をクリック、または下のリンクからチェックできます!
https://glistening-glove-8c6.notion.site/GCI-2239326a3a96800291d8c07353f0de88?source=copy_link
Views: 0