はじめに
前回の特徴量エンジニアリング入門では、生データをモデルに渡せる形に仕上げるところまで整理した。
今回はいよいよ予測モデルの本丸。LightGBMを使って実際に予測モデルを構築し、交差検証・ハイパーパラメータチューニングまでを一通り整理する。
「モデルを動かすだけなら数行で終わる。でも、それで信頼できる精度が出ているかどうかは別の話」というのが今回のテーマ。
このシリーズの流れ(おさらい)
問題設定(第1回)
→ 可視化・EDA(第2〜3回)でデータの傾向を把握
→ 統計学①②で数値的な根拠を整理
→ pandas入門でデータを加工・集計
→ 特徴量エンジニアリングでモデルに渡す形に仕上げる
→ 予測モデルの構築・チューニング(← 今回)
使用するデータ(前回の復習)
前回と同じ小売店の売上データを使う。
import pandas as pd
import numpy as np
np.random.seed(42)
n = 500
df = pd.DataFrame({
"日付": pd.date_range("2023-01-01", periods=n, freq="D"),
"カテゴリ": np.random.choice(["食品", "飲料", "日用品", "菓子"], n),
"地域": np.random.choice(["東京", "大阪", "名古屋"], n),
"来客数": np.random.randint(80, 300, n),
"気温": np.random.uniform(0, 35, n).round(1),
"特売フラグ": np.random.choice([0, 1], n, p=[0.8, 0.2]),
"売上": np.random.randint(80, 300, n) * 1000,
})
# 前回の特徴量エンジニアリングを再現
df["日付"] = pd.to_datetime(df["日付"])
df["月"] = df["日付"].dt.month
df["曜日"] = df["日付"].dt.dayofweek
df["週末フラグ"] = (df["曜日"] >= 5).astype(int)
df["四半期"] = df["日付"].dt.quarter
df["来客数×特売"] = df["来客数"] * df["特売フラグ"]
df = df.sort_values("日付").reset_index(drop=True)
df["売上_7日移動平均"] = df["売上"].rolling(window=7, min_periods=1).mean()
# カテゴリ変数をワンホットエンコーディング
df_encoded = pd.get_dummies(df, columns=["カテゴリ", "地域"], drop_first=True)
# 特徴量と目的変数を定義
feature_cols = [
"来客数", "気温", "特売フラグ",
"月", "曜日", "週末フラグ", "四半期",
"来客数×特売", "売上_7日移動平均",
"カテゴリ_日用品", "カテゴリ_菓子", "カテゴリ_飲料",
"地域_大阪", "地域_名古屋",
]
X = df_encoded[feature_cols]
y = df_encoded["売上"]
1. まずはシンプルにモデルを動かす
最初はパラメータを特に調整せず、デフォルトに近い設定でモデルを動かしてみる。
これをベースラインと呼ぶ。チューニング後の改善幅を測る基準になる。
from lightgbm import LGBMRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error, r2_score
# 時系列データなので末尾20%をテストに(シャッフルしない)
split_idx = int(len(df) * 0.8)
X_train, X_test = X.iloc[:split_idx], X.iloc[split_idx:]
y_train, y_test = y.iloc[:split_idx], y.iloc[split_idx:]
# ベースラインモデル
model_base = LGBMRegressor(random_state=42)
model_base.fit(X_train, y_train)
y_pred = model_base.predict(X_test)
print(f"MAE : {mean_absolute_error(y_test, y_pred):,.0f} 円")
print(f"R² : {r2_score(y_test, y_pred):.3f}")
評価指標の読み方
今回は回帰タスク(連続値の予測)なので、以下の指標を使う。
| 指標 | 意味 | 目安 |
|---|---|---|
| MAE(平均絶対誤差) | 予測値と実測値の差の平均。単位は目的変数と同じ | 小さいほどよい |
| RMSE(二乗平均平方根誤差) | 大きい誤差を強く罰するMAEの亜種 | 小さいほどよい |
| R²スコア | モデルがデータの分散をどれだけ説明できているか(最大1.0) | 1.0に近いほどよい |
from sklearn.metrics import root_mean_squared_error
rmse = root_mean_squared_error(y_test, y_pred)
print(f"RMSE: {rmse:,.0f} 円")
分類タスクの場合
目的変数が「勝ち/負け」「購入/未購入」のような2値の場合は、正解率(Accuracy)・適合率(Precision)・再現率(Recall)・F1スコア・AUCなどを使う。
2. 交差検証(Cross Validation)
ホールドアウトだけでは不十分な理由
先ほどの train_test_split はシンプルで分かりやすいが、テストデータたまたま当たりやすい分割になっている可能性がある。
1回の分割だけでは「本当に汎化性能が高いのか」「たまたまその分割でうまくいったのか」を区別できない。
K-Fold交差検証
データをK個のブロック(fold)に分割し、K回それぞれ異なるfoldをテストに使って評価する手法。
全データ(500件)
┌─────┬─────┬─────┬─────┬─────┐
│fold1│fold2│fold3│fold4│fold5│
└─────┴─────┴─────┴─────┴─────┘
1回目: [fold2〜5で学習] → [fold1でテスト] → スコア①
2回目: [fold1,3〜5で学習] → [fold2でテスト] → スコア②
...
5回目: [fold1〜4で学習] → [fold5でテスト] → スコア⑤
最終評価 = スコア①〜⑤の平均
from sklearn.model_selection import KFold, cross_val_score
kf = KFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(
LGBMRegressor(random_state=42),
X, y,
cv=kf,
scoring="neg_mean_absolute_error" # MAEは「負の値」で返ってくる
)
mae_scores = -scores # 正の値に戻す
print(f"各foldのMAE: {mae_scores.round(0)}")
print(f"平均MAE: {mae_scores.mean():,.0f} 円")
print(f"標準偏差: {mae_scores.std():,.0f} 円")
標準偏差が大きい場合は、foldによって精度がばらついている=モデルが不安定なサインである。
時系列データの注意点:TimeSeriesSplit
時系列データに KFold(shuffle=True) を使うと、未来のデータで学習して過去を予測するという現実ではあり得ない状況が生じてしまう(リーク)。
時系列データには TimeSeriesSplit を使い、常に「過去で学習→未来を予測」という向きを守る。
from sklearn.model_selection import TimeSeriesSplit, cross_val_score
tscv = TimeSeriesSplit(n_splits=5)
scores_ts = cross_val_score(
LGBMRegressor(random_state=42),
X, y,
cv=tscv,
scoring="neg_mean_absolute_error"
)
mae_ts = -scores_ts
print(f"各foldのMAE(時系列分割): {mae_ts.round(0)}")
print(f"平均MAE: {mae_ts.mean():,.0f} 円")
3. ハイパーパラメータチューニング
モデルの学習によって自動で決まるパラメータをパラメータ、学習前に人間が設定するパラメータをハイパーパラメータと呼ぶ。
LightGBMの主なハイパーパラメータを整理する。
| パラメータ | 意味 | デフォルト | チューニングの方向性 |
|---|---|---|---|
n_estimators |
木の本数 | 100 | 増やすと精度↑・過学習リスク↑ |
learning_rate |
学習率(1本あたりの更新幅) | 0.1 | 小さくするとより慎重に学習 |
max_depth |
木の深さの上限 | -1(制限なし) | 浅くすると過学習を抑制 |
num_leaves |
葉の最大数(複雑さの制御) | 31 | 小さくすると汎化しやすい |
min_child_samples |
葉を作るのに必要なサンプル数 | 20 | 大きくすると過学習を抑制 |
subsample |
各木で使うデータのサンプリング率 | 1.0 | 0.8前後にするとロバスト性が上がる |
colsample_bytree |
各木で使う特徴量のサンプリング率 | 1.0 | 0.8前後が定番 |
reg_alpha |
L1正則化 | 0 | 特徴量を絞りたいとき |
reg_lambda |
L2正則化 | 0 | 過学習を抑えたいとき |
① グリッドサーチ(Grid Search)
指定した候補の全組み合わせを試す方法。確実だが組み合わせ数が多いと時間がかかる。
from sklearn.model_selection import GridSearchCV
param_grid = {
"n_estimators": [100, 300, 500],
"learning_rate": [0.01, 0.05, 0.1],
"num_leaves": [15, 31, 63],
"min_child_samples": [10, 20, 50],
}
grid_search = GridSearchCV(
LGBMRegressor(random_state=42),
param_grid,
cv=TimeSeriesSplit(n_splits=5),
scoring="neg_mean_absolute_error",
n_jobs=-1, # 並列実行
verbose=1
)
grid_search.fit(X, y)
print(f"最良パラメータ: {grid_search.best_params_}")
print(f"最良MAE: {-grid_search.best_score_:,.0f} 円")
② ランダムサーチ(Random Search)
指定した範囲からランダムにパラメータを抽出して試す方法。
グリッドサーチより効率よく良いパラメータを見つけやすいとされている。
from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import randint, uniform
param_dist = {
"n_estimators": randint(100, 1000),
"learning_rate": uniform(0.01, 0.2),
"num_leaves": randint(10, 100),
"min_child_samples": randint(5, 100),
"subsample": uniform(0.6, 0.4),
"colsample_bytree": uniform(0.6, 0.4),
}
random_search = RandomizedSearchCV(
LGBMRegressor(random_state=42),
param_dist,
n_iter=50, # 試行回数
cv=TimeSeriesSplit(n_splits=5),
scoring="neg_mean_absolute_error",
random_state=42,
n_jobs=-1,
verbose=1
)
random_search.fit(X, y)
print(f"最良パラメータ: {random_search.best_params_}")
print(f"最良MAE: {-random_search.best_score_:,.0f} 円")
③ Optunaによるベイズ最適化(発展)
グリッド・ランダムサーチより効率的に探索できる手法として、Optunaが実務・コンペでよく使われる。
過去の試行結果を学習しながら、有望なパラメータ領域に絞って探索していく。
import optuna
optuna.logging.set_verbosity(optuna.logging.WARNING) # ログを抑制
from sklearn.model_selection import cross_val_score, TimeSeriesSplit
def objective(trial):
params = {
"n_estimators": trial.suggest_int("n_estimators", 100, 1000),
"learning_rate": trial.suggest_float("learning_rate", 0.01, 0.3, log=True),
"num_leaves": trial.suggest_int("num_leaves", 10, 100),
"min_child_samples": trial.suggest_int("min_child_samples", 5, 100),
"subsample": trial.suggest_float("subsample", 0.5, 1.0),
"colsample_bytree": trial.suggest_float("colsample_bytree", 0.5, 1.0),
"reg_alpha": trial.suggest_float("reg_alpha", 1e-8, 10.0, log=True),
"reg_lambda": trial.suggest_float("reg_lambda", 1e-8, 10.0, log=True),
"random_state": 42,
}
model = LGBMRegressor(**params)
scores = cross_val_score(
model, X, y,
cv=TimeSeriesSplit(n_splits=5),
scoring="neg_mean_absolute_error"
)
return -scores.mean()
study = optuna.create_study(direction="minimize")
study.optimize(objective, n_trials=50)
print(f"最良MAE: {study.best_value:,.0f} 円")
print(f"最良パラメータ: {study.best_params}")
4. 最終モデルの学習と評価
チューニングで得た最良パラメータを使って、最終モデルを学習・評価する。
best_params = study.best_params # または grid/random_search.best_params_
best_params["random_state"] = 42
# アーリーストッピング付きで学習(過学習を自動で止める)
model_final = LGBMRegressor(**best_params)
model_final.fit(
X_train, y_train,
eval_set=[(X_test, y_test)],
callbacks=[
lgb.early_stopping(stopping_rounds=50, verbose=False),
lgb.log_evaluation(period=100)
]
)
y_pred_final = model_final.predict(X_test)
print("=== 最終モデルの評価 ===")
print(f"MAE : {mean_absolute_error(y_test, y_pred_final):,.0f} 円")
print(f"RMSE: {root_mean_squared_error(y_test, y_pred_final):,.0f} 円")
print(f"R² : {r2_score(y_test, y_pred_final):.3f}")
特徴量重要度の確認
import matplotlib.pyplot as plt
importance = pd.Series(
model_final.feature_importances_,
index=feature_cols
).sort_values(ascending=True)
plt.figure(figsize=(8, 5))
importance.plot(kind="barh", color="steelblue")
plt.title("特徴量重要度(チューニング後)")
plt.xlabel("重要度スコア")
plt.tight_layout()
plt.show()
重要度が低い特徴量を除外して再学習することで、モデルをさらにシンプルにできることがある(特徴量選択の繰り返し)。
5. 過学習の確認
最後に、モデルが過学習していないかを確認する。
# 学習データとテストデータの両方で評価して比較
mae_train = mean_absolute_error(y_train, model_final.predict(X_train))
mae_test = mean_absolute_error(y_test, y_pred_final)
print(f"学習データのMAE : {mae_train:,.0f} 円")
print(f"テストデータのMAE: {mae_test:,.0f} 円")
print(f"差分(過学習の目安): {mae_test - mae_train:,.0f} 円")
| 状態 | 学習MAE | テストMAE | 対処 |
|---|---|---|---|
| 良好 | 低い | 学習とほぼ同等 | そのまま使う |
| 過学習 | 低い | 学習より大幅に高い |
num_leavesを減らす・正則化を強める・min_child_samplesを増やす |
| 未学習 | 高い | 高い |
n_estimatorsを増やす・特徴量を見直す |
まとめ
| ステップ | 内容 | ポイント |
|---|---|---|
| ベースライン構築 | デフォルトパラメータでまず動かす | チューニング前後の比較基準になる |
| 評価指標の選択 | 回帰はMAE/RMSE/R²、分類はF1/AUCなど | タスクに合わせて選ぶ |
| 交差検証 | K-Fold または TimeSeriesSplit | 1回の分割では信頼性が低い |
| チューニング手法 | Grid → Random → Optuna の順で試す | 探索空間が広いほどOptunaが有効 |
| 最終評価 | アーリーストッピング付きで最終学習 | 過学習のチェックも忘れずに |
今回でデータサイエンスの基本的な流れ(問題設定→EDA→統計→前処理→特徴量エンジニアリング→モデル構築→チューニング)を一通り整理できた。
次のステップとしては、モデルの解釈 SHAPがある。引き続き学習を進めていく予定。