Pythonを用いた機械学習のデータコンペでよく用いられ、速い軽い正しいでお馴染みのLightGBM。
前回はその中身について、軽く理解しました。
今回はLightGBMの実装を通し、さらに理解を深めていきます。
LightGBMの実装
今回LightGBMで予測させる対象は私が検討している競馬の着順予測です。
pandasでランダムなデータセットを用意し、LightGBMへ与え1~3位を予測します。
データセットの用意
まずはサンプルデータセットの生成を行います。
pandasのDataFrameを使って、年齢や体重、オッズをランダムで生成します。
自分が作成している競馬予想AIではWebサイトからスクレイピングを行い、データセットを取得します。
詳しくはこちらをご参照ください。
今回200頭の馬の年齢と体重をランダムで設定し、適当な「馬の強さスコア」を決め、オッズと順位をこのスコアに比例させました(多少のランダム性有)。
こうすることでスコアと順位に相関を持たせ、アルゴリズムの評価もしやすいはずです。
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(42)
n_samples = 200
# 基本情報
df = pd.DataFrame({
"horse_id": np.arange(n_samples),
"age": np.random.randint(2, 10, size=n_samples),
"weight": np.random.randint(400, 600, size=n_samples),
})
# -----------------------------
# 相関を持たせるためのスコア計算
# -----------------------------
# 年齢は4歳が最も強い → 4 からの距離が大きいほど弱い
age_score = np.abs(df["age"] - 4)
# 体重は軽いほど強い → 400kg が最強と仮定
weight_score = (df["weight"] - 400) / 50 # 50kg で1ポイント弱くなるイメージ
# 総合スコア(小さいほど強い)
strength_score = age_score + weight_score
df["strength_score"] = strength_score # 特徴量には含めないが確認用に保存
# -----------------------------
# odds と finish_position を生成
# -----------------------------
# odds:強いほど(score 小)1に近づく、弱いほど大きくなる
df["odds"] = 1.0 + (strength_score.max() - strength_score) * np.random.uniform(0.5, 2.0, size=n_samples)
# 順位:強いほど1に近づく
base_position = (strength_score.max() - strength_score) * np.random.uniform(0.5, 2.0, size=n_samples)
noise = np.random.normal(0, 1.5, size=n_samples) # ランダム性を追加
df["finish_position"] = np.clip(1 + base_position + noise, 1, 18).astype(int)
# 3着以内を target=1 とする
df["target"] = (df["finish_position"] <= 3).astype(int)
出力を散布図で確認してみましょう。
オッズ vs 馬の強さスコア
plt.figure(figsize=(8, 6))
plt.scatter(df["strength_score"], df["odds"], alpha=0.7)
plt.xlabel("strength_score")
plt.ylabel("odds")
plt.title("Scatter Plot of strength_score vs odds")
plt.xlim(0, df["strength_score"].max()+1)
plt.tick_params(direction='in')
plt.yticks(np.arange(0, df["odds"].max()+1, 2))
plt.grid(True)
plt.show()
順位 vs 馬の強さスコア
plt.figure(figsize=(8, 6))
plt.scatter(df["strength_score"], df["finish_position"], alpha=0.7)
plt.xlabel("strength_score")
plt.ylabel("finish_position")
plt.title("Scatter Plot of strength_score vs finish_position")
plt.tick_params(direction='in')
plt.xlim(0, df["strength_score"].max()+1)
plt.xticks(np.arange(0, df["strength_score"].max()+1, 2))
plt.yticks(np.arange(0, df["finish_position"].max()+1, 2))
plt.grid(True)
plt.show()
どちらも負の相関が見えますね。
(すなわち馬が強いほどオッズと順位が下がる)
これでスコアの高い馬を学習させれば1位に近い順位を予測するはずです。
LightGBMの実行
200頭の学習データができたため、こちらを用いてLightGBMで予測を行っていきましょう。
生成したデータより、特徴量は"age", "weight", "odds"の3つとなります。
("strength_score"はodd, finish_positionの算出用なので除外します)
そして予測する目的変数は3位以上を1とした"target"の列になります。
早速LightGBMモジュールをインストールしてプログラムを実装していきましょう。
ここでLightGBMのモジュールインストールにトラブルが発生します。
詳しくはこちらの記事をご覧ください。
まずは特徴量と目的変数のセットです。
features = ["age", "weight", "odds"]
X = df[features]
y = df["target"]
ちなみにXを大文字、yを小文字で書くのは行列とベクトルを区別しているためです。
続いてデータを学習用とテスト用に分割し、LightGBMにセットします。
scikit-learnのtrain_test_split関数を使うとDataFrame型を簡単に分割できます。
ここでは3/4を学習用、1/4をテスト用とします。
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=50, random_state=42)
train_data = lgb.Dataset(X_train, label=y_train)
test_data = lgb.Dataset(X_test, label=y_test, reference=train_data) # test_data を参照データとして指定
さらにLightGBMのハイパーパラメータを設定します。
各パラメータの説明はコメントに記載しています。
params = {
'objective': 'binary', # 2クラス分類
'metric': 'binary_logloss', # ロス関数
'boosting_type': 'gbdt', # 勾配ブースティング
'learning_rate': 0.1, # 学習率(小さいほど慎重に学習、ただし学習時間は長くなる)
'num_leaves': 31, # 木の葉の数(複雑なモデルほど大きく)
'verbose': -1, # 全てのログの出力を非表示
'early_stopping_round': 30 # 30回連続で改善しなければ終了
'num_boost_round': 300 # 最大100本の木を作成
}
これでモデルの訓練と予測ができます。
訓練させたmodelとテストデータを用いて、1に分類される確率を求め、さらに0または1に丸め込みます。
model = lgb.train(params, train_data, valid_sets=[train_data, test_data])
y_pred_prob = model.predict(X_test, num_iteration=model.best_iteration)
y_pred = (y_pred_prob >= 0.5).astype(int)
ここで訓練の際、valid_setsの中にtrain_dataとtest_dataを入れている理由として、学習中に過学習をチェックしているためが挙げられます。
したがってMustではないが、モデルがどこで過学習を起こしているか確認するためにはこの引数は指定した方が良いです。
最後に予測結果を確認しましょう。
これもscikit-learnのaccuracy_score関数を使えばテスト用の目的変数と01で丸め込んだ予測値を比較して正答率を出力してくれます。
accuracy = accuracy_score(y_test, y_pred)
print(f"Accuracy: {accuracy * 100:.2f}%")
results = X_test.copy()
results["actual"] = y_test
results["predicted"] = y_pred
results["predicted_prob"] = y_pred_prob
print(results.head(10))
Accuracy: 78.00%
age weight odds actual predicted predicted_prob
95 7 543 4.888071 1 0 0.413570
15 6 427 8.127804 0 0 0.191794
30 6 527 9.416742 0 0 0.283141
158 2 483 9.756491 0 0 0.065150
128 5 543 7.706999 0 0 0.119303
115 5 527 8.611836 0 0 0.199332
69 7 512 3.044430 0 1 0.657367
170 9 588 1.037077 1 1 0.867171
174 9 432 6.787914 0 0 0.297707
45 5 461 12.135225 0 0 0.192278
正答率(Accuracy)は78%。
たった3つの特徴量でまあまあな正答率をたたき出していると思います。
最後に、完全にランダムなデータで予測をさせてみましょう。
相関を持たせたデータを予測させたときよりは、正答率が下がっているはずです。
ランダムなデータの予測
特徴量はランダムにしますが、目的変数(当たりの数)は揃えてあげます。
すなわちデータは変わらないが、200頭の馬の中から上記で目的変数が1となった馬58頭を当てるという問題設定は変えないようにしています。
データセットを生成するコードはこちら
np.random.seed(42)
n_samples = 200
# 基本情報
df_rand = pd.DataFrame({
"horse_id": np.arange(n_samples),
"age": np.random.randint(2, 10, size=n_samples),
"weight": np.random.randint(400, 600, size=n_samples),
"odds": np.random.uniform(1.0, 20.0, size=n_samples),
# "finish_position": np.random.randint(1, 19, size=n_samples)
})
# -----------------------------
# finish_position をコントロールして作る
# -----------------------------
# 58 個を「finish_position <= 3」にする
idx_high = np.random.choice(df_rand.index, size=58, replace=False)
# その 58 行を 1〜3 のランダム値にする
df_rand.loc[idx_high, "finish_position"] = np.random.randint(1, 4, size=58)
# 残り 142 行を 4〜18 にする(3着以内を target=1 にしたい場合)
idx_low = df_rand.index.difference(idx_high)
df_rand.loc[idx_low, "finish_position"] = np.random.randint(4, 19, size=len(idx_low))
# target を作成
df_rand["target"] = (df_rand["finish_position"] <= 3).astype(int)
さらにオッズに入れていたランダム性を除外した、強さに比例するデータセットも用意します。
np.random.seed(42)
n_samples = 200
# 基本情報
df_fix = pd.DataFrame({
"horse_id": np.arange(n_samples),
"age": np.random.randint(2, 10, size=n_samples),
"weight": np.random.randint(400, 600, size=n_samples),
})
# -----------------------------
# 相関を持たせるためのスコア計算
# -----------------------------
# 年齢は4歳が最も強い → 4 からの距離が大きいほど弱い
age_score = np.abs(df_fix["age"] - 4)
# 体重は軽いほど強い → 400kg が最強と仮定
weight_score = (df_fix["weight"] - 400) / 50 # 50kg で1ポイント弱くなるイメージ
# 総合スコア(小さいほど強い)
strength_score = age_score + weight_score
df_fix["strength_score"] = strength_score # 特徴量には含めないが確認用に保存
# -----------------------------
# odds と finish_position を生成
# -----------------------------
# odds:強いほど(score 小)1に近づく、弱いほど大きくなる
df_fix["odds"] = 1.0 + (strength_score.max() - strength_score)
# -----------------------------
# finish_position をコントロールして作る
# -----------------------------
# 58 個を「finish_position <= 3」にする
idx_high = np.random.choice(df_fix.index, size=58, replace=False)
# その 58 行を 1〜3 のランダム値にする
df_fix.loc[idx_high, "finish_position"] = np.random.randint(1, 4, size=58)
# 残り 142 行を 4〜18 にする(3着以内を target=1 にしたい場合)
idx_low = df_fix.index.difference(idx_high)
df_fix.loc[idx_low, "finish_position"] = np.random.randint(4, 19, size=len(idx_low))
# target を作成
df_fix["target"] = (df_fix["finish_position"] <= 3).astype(int)
これらの予測精度を比較すると、完全にランダムなデータが68%, 少しランダムなデータが78%, ランダム性が一切ないデータが64%となりました。
(すなわち上から相関がない、ある、とても強い、というデータになります)
| Randomness | Accuracy | Correlation |
|---|---|---|
| High | 68% | None |
| Low | 78% | Low |
| None | 64% | High |
完全にランダムなデータが予測が難しいのは想定通りでした。
一方ランダム性がない単純に強さに比例するデータは予測しやすいかと思いきや、最も正答率が低いという結果になりました。
これは問題設定が1~3位は1, それ以下の順位を0とするステップ関数を予測することになっており、LightGBMがそのような問題に向いていないためです。
むしろロジスティック回帰の方がこのような問題は向いているとのことなので、いつか実装して確かめてみたいですね。
以上、LightGBMの実装でした。
こちらを用いて競馬予測をしていく記事を書いているので、興味のある人はぜひ自分のプロフィールから記事を見てください!


