GCI優秀生・SIGNATE MASTERが考えるコンペのスコア向上Tips
機械学習コンペティションに参加し始めたばかりの方の中には、なかなかスコアが伸びずに悩んでいる方も多いのではないでしょうか。私自身も、始めたばかりの頃は同じように壁にぶつかり、思うように結果が出ない時期がありました。
しかし、正しい知識と実践的なテクニックを少しずつ積み重ねていくことで、着実にスコアを上げることができるようになります。
この記事では、私が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つの特徴量を使って平面上にプロットしたとき、「縦または横に直線を引いて」領域を分割していくアルゴリズムです。
つまり、1つの特徴量ごとに条件を区切りながらデータを分類していく仕組みになっています。
たとえば、「運賃が100ドルより高いか」「年齢が30歳未満か」といったように、単独の条件でデータを分けます。
ただし、「運賃が高く、家族が少ない人」といった2つの特徴量の関係性を直接表すのは苦手です。
このような“斜めの関係”(=特徴量同士の組み合わせによる傾向)を捉えるために、
「運賃 ÷ 家族人数」といった比率特徴量を作ることで、その関係を数値として扱えるようになります。
比率特徴量の導入効果
比率特徴量を導入すると、たとえば「Fare ÷ 家族人数」のように、2つの特徴量を組み合わせた“斜めの関係”を
1つの新しい軸として明示的にモデルに渡すことができます。
これにより、本来決定木が苦手とする関係性も、よりシンプルな分割で学習できるようになります。
注意点
比率を計算する際には、分母がゼロになるケースに注意が必要です。
ゼロで割ると計算エラーが発生するため、たとえば「年齢 + 1」や「人数 + 0.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})")
まとめ
機械学習コンペでスコアを上げるには、次の3つの要素を意識することが効果的です。
-
特徴量エンジニアリング
データの意味を理解し、仮説に基づいて新しい特徴量を作ることが重要です。
平均値との差分や比率など、相対的な情報を取り入れることで、モデルが本質的なパターンを捉えやすくなります。 -
Optunaによるパラメータチューニング
LightGBMなどのモデルは、パラメータ設定によって性能が大きく変わります。
Optunaを使えば、効率的に最適なパラメータを探索でき、精度を高めることができます。 -
Seed Averagingによる安定化
異なる乱数シードで複数モデルを学習させ、予測結果を平均することで、過学習やノイズの影響を抑えられます。
少ない手間で安定したスコアを得られる実践的な手法です。
これらの手法を組み合わせて活用することで、モデルの精度と再現性を高め、コンペでの成果を着実に伸ばすことができます。