Machine Learning: モデル評価とチューニング — 評価手法とハイパーパラメータ最適化ガイド
最終更新:2026-08-26
評価手法が間違っていれば、すべての最適化は砂上の楼閣です。まず正確に測定し、それからチューニングを行いましょう。
1. 学習内容
- 評価手法:Hold-out / K-Fold / Stratified K-Fold / TimeSeriesSplit
- 分類の評価:混同行列、Precision-Recall曲線、F1-β、多クラス指標
- 回帰の評価:MAE / MSE / RMSE / MAPE / R²、指標とビジネス目標の整合
- ハイパーパラメータチューニング:GridSearchCV / RandomizedSearchCV / Optunaベイズ最適化
- 過学習の診断:学習曲線、バリデーション曲線
2. MLエンジニアの実体験
(1) 問題:テストセットで精度95%、デプロイ後に75%
Bobはtrain_test_splitでモデルを評価し、テスト精度95%を得ました。しかしデプロイの1週間後、実環境での精度はわずか75%でした。時系列データでは訓練セットとテストセットの分布が重複しており、モデルが未来の情報を「覗き見」していたためです。不適切な評価手法が指標の過大評価を招き、悲惨なローンチにつながりました。
(2) 解決策:適切な評価手法
時系列データにはTimeSeriesSplitを使う必要があります。訓練セットには過去データのみが含まれ、テストセットは未来データとなるため、実環境の条件をシミュレートできます。
PYTHON
from sklearn.model_selection import TimeSeriesSplit
tscv = TimeSeriesSplit(n_splits=5)
for train_idx, test_idx in tscv.split(X):
# train_idx always < test_idx (no future leakage)
model.fit(X[train_idx], y[train_idx])
score = model.score(X[test_idx], y[test_idx])
(3) 効果:真の性能を反映する指標
BobがTimeSeriesSplitで再評価したところ、精度は「過大評価された」95%から「現実的な」82%に下がりました。数値は下がりましたが、デプロイ後の精度も82%でした。評価と現実がようやく一致し、もう嫌なサプライズは起きなくなりました。
3. 評価手法
(1) 4つの交差検証戦略
graph TB
DATA[Dataset] --> HO[Hold-out<br/>Single Train/Test Split]
DATA --> KF[K-Fold CV<br/>Random Splits]
DATA --> SKF[Stratified K-Fold<br/>Class-Balanced Splits]
DATA --> TSS[TimeSeriesSplit<br/>Chronological Splits]
HO --> HO_USE[Quick baseline<br/>Large dataset]
KF --> KF_USE[General purpose<br/>Stable estimate]
SKF --> SKF_USE[Classification<br/>Imbalanced data]
TSS --> TSS_USE[Time series<br/>No future leakage]
▶ サンプル:4つの交差検証戦略の比較
PYTHON
from sklearn.model_selection import (train_test_split, KFold, StratifiedKFold,
TimeSeriesSplit, cross_val_score)
from sklearn.ensemble import RandomForestClassifier
from sklearn.datasets import load_iris
import numpy as np
X, y = load_iris(return_X_y=True)
model = RandomForestClassifier(n_estimators=50, random_state=42)
# 1. Hold-out
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
model.fit(X_tr, y_tr)
holdout_score = model.score(X_te, y_te)
# 2. K-Fold
kf_scores = cross_val_score(model, X, y, cv=KFold(n_splits=5, shuffle=True, random_state=42))
# 3. Stratified K-Fold
skf_scores = cross_val_score(model, X, y, cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42))
# 4. TimeSeriesSplit (for time-ordered data)
tss_scores = cross_val_score(model, X, y, cv=TimeSeriesSplit(n_splits=5))
print(f"Hold-out: {holdout_score:.4f}")
print(f"K-Fold: {kf_scores.mean():.4f} +/- {kf_scores.std():.4f}")
print(f"Stratified K-Fold: {skf_scores.mean():.4f} +/- {skf_scores.std():.4f}")
print(f"TimeSeriesSplit: {tss_scores.mean():.4f} +/- {tss_scores.std():.4f}")
出力:
TEXT
📖 参照専用
# Execution successful
| 手法 | データタイプ | 長所 | 短所 |
|---|---|---|---|
| Hold-out | 全般 | 高速 | 結果が分割に依存する |
| K-Fold | 全般 | 安定している | ランダムな分割 |
| Stratified K-Fold | 分類 | クラス比率を保持 | 分類専用 |
| TimeSeriesSplit | 時系列 | 未来のリークなし | 訓練サイズが徐々に増大 |
4. 分類と回帰の指標
(1) 分類指標の全体像
▶ サンプル:包括的な分類評価
PYTHON
from sklearn.metrics import (accuracy_score, precision_score, recall_score,
f1_score, roc_auc_score, classification_report,
confusion_matrix)
from sklearn.linear_model import LogisticRegression
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
X, y = load_breast_cancer(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
pipe = Pipeline([("scaler", StandardScaler()), ("model", LogisticRegression(max_iter=500))])
pipe.fit(X_train, y_train)
y_pred = pipe.predict(X_test)
y_prob = pipe.predict_proba(X_test)[:, 1]
print(f"Accuracy: {accuracy_score(y_test, y_pred):.4f}")
print(f"Precision: {precision_score(y_test, y_pred):.4f}")
print(f"Recall: {recall_score(y_test, y_pred):.4f}")
print(f"F1: {f1_score(y_test, y_pred):.4f}")
print(f"AUC-ROC: {roc_auc_score(y_test, y_prob):.4f}")
print(f"\nConfusion Matrix:\n{confusion_matrix(y_test, y_pred)}")
print(f"\nDetailed Report:\n{classification_report(y_test, y_pred)}")
出力:
TEXT
📖 参照専用
# Execution successful
(2) ビジネス目標に沿った回帰指標
▶ サンプル:回帰指標とそのビジネス上の意味
PYTHON
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
import numpy as np
y_true = np.array([100, 150, 200, 250, 300]) # thousand USD
y_pred = np.array([95, 160, 190, 260, 310])
mae = mean_absolute_error(y_true, y_pred)
rmse = np.sqrt(mean_squared_error(y_true, y_pred))
r2 = r2_score(y_true, y_pred)
mape = np.mean(np.abs((y_true - y_pred) / y_true)) * 100
print(f"MAE: {mae:.2f} k USD")
print(f"RMSE: {rmse:.2f} k USD")
print(f"R²: {r2:.4f}")
print(f"MAPE: {mape:.1f}%")
# Business impact calculation
avg_monthly_revenue = 1000 # thousand USD
mape_pct = mape / 100
inventory_cost_pct = 0.3 # 30% of overstock is waste
annual_loss = avg_monthly_revenue * mape_pct * inventory_cost_pct * 12
print(f"\nAnnual inventory loss from prediction error: {annual_loss:.0f} k USD")
出力:
TEXT
📖 参照専用
# Execution successful
| 指標 | 計算式 | ビジネス上の意味 |
|---|---|---|
| MAE | 平均|y-ŷ| | 平均的な偏差(千ドル) |
| RMSE | √(平均(y-ŷ)²) | 大きな誤差をより強く罰する |
| R² | 1 - SS_res/SS_tot | 説明された分散の割合 |
| MAPE | 平均|y-ŷ|/y × 100% | 相対誤差(%) |
5. ハイパーパラメータチューニング
(1) 3つの探索戦略
▶ サンプル:GridSearchとRandomSearchの比較
PYTHON
from sklearn.model_selection import GridSearchCV, RandomizedSearchCV
from sklearn.ensemble import RandomForestRegressor
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split
import time
X, y = fetch_california_housing(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
param_grid = {
"n_estimators": [50, 100, 200],
"max_depth": [5, 10, 15, None],
"min_samples_leaf": [1, 5, 10],
}
# GridSearch: exhaustive search
start = time.time()
grid = GridSearchCV(RandomForestRegressor(random_state=42), param_grid, cv=3, scoring="r2", n_jobs=-1)
grid.fit(X_train, y_train)
grid_time = time.time() - start
# RandomSearch: random sampling
start = time.time()
random = RandomizedSearchCV(RandomForestRegressor(random_state=42), param_grid,
n_iter=15, cv=3, scoring="r2", n_jobs=-1, random_state=42)
random.fit(X_train, y_train)
random_time = time.time() - start
print(f"GridSearch: R²={grid.best_score_:.4f}, Time={grid_time:.1f}s, Trials={len(grid.cv_results_['mean_test_score'])}")
print(f"RandomSearch: R²={random.best_score_:.4f}, Time={random_time:.1f}s, Trials=15")
出力:
TEXT
📖 参照専用
# Execution successful
| 手法 | 探索戦略 | カバレッジ | 速度 | 最適な用途 |
|---|---|---|---|---|
| GridSearch | 全組み合わせを網羅探索 | 100% | 低速 | パラメータが少ない場合 |
| RandomSearch | ランダムサンプリング | 部分的 | 高速 | パラメータが多い場合 |
| Optuna | ベイズ最適化 | 誘導付き探索 | 効率的 | 複雑な探索空間 |
(2) Optunaベイズ最適化
▶ サンプル:Optunaによるハイパーパラメータチューニング
PYTHON
# pip install optuna
import optuna
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import cross_val_score
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split
import numpy as np
X, y = fetch_california_housing(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
def objective(trial):
n_estimators = trial.suggest_int("n_estimators", 50, 500)
max_depth = trial.suggest_int("max_depth", 3, 20)
min_samples_leaf = trial.suggest_int("min_samples_leaf", 1, 20)
max_features = trial.suggest_float("max_features", 0.3, 1.0)
model = RandomForestRegressor(
n_estimators=n_estimators, max_depth=max_depth,
min_samples_leaf=min_samples_leaf, max_features=max_features,
random_state=42, n_jobs=-1,
)
scores = cross_val_score(model, X_train, y_train, cv=3, scoring="r2")
return scores.mean()
study = optuna.create_study(direction="maximize")
study.optimize(objective, n_trials=30, show_progress_bar=False)
print(f"Best R²: {study.best_value:.4f}")
print(f"Best params: {study.best_params}")
出力:
TEXT
📖 参照専用
# Function defined successfully
6. 過学習の診断
(1) 学習曲線とバリデーション曲線
▶ サンプル:学習曲線のプロット
PYTHON
from sklearn.model_selection import learning_curve, validation_curve
from sklearn.ensemble import RandomForestRegressor
from sklearn.datasets import fetch_california_housing
import matplotlib.pyplot as plt
import numpy as np
X, y = fetch_california_housing(return_X_y=True)
# Learning curve: performance vs training set size
train_sizes, train_scores, val_scores = learning_curve(
RandomForestRegressor(n_estimators=50, random_state=42),
X, y, train_sizes=np.linspace(0.1, 1.0, 10),
cv=3, scoring="r2", n_jobs=-1,
)
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Learning curve
axes[0].plot(train_sizes, train_scores.mean(axis=1), "o-", label="Training")
axes[0].plot(train_sizes, val_scores.mean(axis=1), "o-", label="Validation")
axes[0].fill_between(train_sizes, train_scores.mean(axis=1) - train_scores.std(axis=1),
train_scores.mean(axis=1) + train_scores.std(axis=1), alpha=0.1)
axes[0].fill_between(train_sizes, val_scores.mean(axis=1) - val_scores.std(axis=1),
val_scores.mean(axis=1) + val_scores.std(axis=1), alpha=0.1)
axes[0].set_xlabel("Training Size")
axes[0].set_ylabel("R² Score")
axes[0].set_title("Learning Curve")
axes[0].legend()
axes[0].grid(True, alpha=0.3)
# Validation curve: performance vs hyperparameter
param_range = [3, 5, 7, 10, 15, 20, None]
train_scores2, val_scores2 = validation_curve(
RandomForestRegressor(n_estimators=50, random_state=42),
X, y, param_name="max_depth", param_range=param_range,
cv=3, scoring="r2", n_jobs=-1,
)
axes[1].plot(range(len(param_range)), train_scores2.mean(axis=1), "o-", label="Training")
axes[1].plot(range(len(param_range)), val_scores2.mean(axis=1), "o-", label="Validation")
axes[1].set_xticks(range(len(param_range)))
axes[1].set_xticklabels([str(p) for p in param_range])
axes[1].set_xlabel("max_depth")
axes[1].set_ylabel("R² Score")
axes[1].set_title("Validation Curve")
axes[1].legend()
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("learning_validation_curves.png", dpi=150)
出力:
TEXT
📖 参照専用
# Execution successful
| 診断結果 | 曲線のパターン | 意味 | 解決策 |
|---|---|---|---|
| 学習不足 | 訓練スコアと検証スコアの両方が低い | モデルが単純すぎる | 複雑化 / 特徴量の追加 |
| 過学習 | 訓練スコアが高く検証スコアが低い | モデルが複雑すぎる | 正則化 / 簡素化 / データ追加 |
| 良好な適合 | 両方が高く近い値 | バランスが取れている | 変更不要 |
| データ不足 | 検証曲線がまだ上昇中 | データが不十分 | より多くのデータを収集 |
❓ よくある質問
Q 交差検証のfold数はどう選べばよいですか?
A 5または10が最も一般的な選択です。5-foldは速度と安定性のバランスが良く、10-foldはより安定しますが2倍遅くなります。大規模データセット(5万サンプル以上)では3-foldで十分です。小規模データセット(1,000サンプル未満)では10-foldを使いましょう。
Q 時系列データにK-Foldが使えないのはなぜですか?
A K-Foldはデータをランダムにシャッフルするため、「未来」のデータで訓練して「過去」を予測する事態が発生し、データリークを引き起こす可能性があります。TimeSeriesSplitは訓練セットが常にテストセットより前であることを保証し、実際の予測シナリオをシミュレートします。
Q GridSearchCVが遅すぎる場合はどうすればよいですか?
A 3つの戦略があります。1) 探索空間を縮小する(まず粗く探索し、次に細かく探索);2) RandomizedSearchCVを使う(n_iter=20);3) Optunaベイズ最適化を使う(インテリジェントな探索)。
Q MAPEの欠点は何ですか?
A MAPEはy_trueがゼロに近いと爆発します(ほぼゼロの値での除算)。代替案として、SMAPE(対称MAPE)やMASE(平均絶対スケール誤差)を使いましょう。
Q 学習曲線で訓練スコアと検証スコアの差が大きい場合は?
A 大きな差は過学習を意味します。解決策は、1) 正則化の強化;2) モデルの複雑さの低減;3) 訓練データの追加;4) Dropout / early stoppingの使用です。
Q OptunaはGridSearchよりどの程度優れていますか?
A Optunaはベイズ最適化を使って次のパラメータセットをインテリジェントに選択します。GridSearchが500回以上の試行を必要とする結果を、通常30〜50回の試行で見つけられ、計算リソースを80%以上節約できます。
📖 まとめ
- 評価手法はあらゆるMLプロジェクトの基盤です。分類にはStratifiedKFold、時系列にはTimeSeriesSplitを使いましょう
- 分類指標の選択はビジネスに依存します。解約予測 → 再現率、スパム検出 → 適合率、レコメンデーション → F1スコア
- 回帰指標はビジネスコストと整合させる必要があります。100万ドルの予測でMAPE 10%は10万ドルの誤差を意味します
- ハイパーパラメータチューニングの進化:GridSearch(網羅探索)→ RandomSearch(サンプリング)→ Optuna(インテリジェント探索)
- 過学習の診断:学習曲線でデータが十分かを確認し、バリデーション曲線でパラメータが最適かを確認します
- 高いスコアよりも正しい評価が重要です。過大評価された指標はデプロイ後に崩壊します
📝 練習問題
- 基礎(難易度 ⭐):IrisデータセットでRandomForestClassifierの5-foldと10-fold交差検証の平均精度と標準偏差を比較しましょう。ヒント:cross_val_score(cv=5/10)。
- 中級(難易度 ⭐⭐):GridSearchCVを使ってXGBoostの最適パラメータ(max_depth / learning_rate / n_estimators)を見つけ、最良パラメータとCV R²を出力しましょう。ヒント:Pipeline + param_grid。
- 挑戦(難易度 ⭐⭐⭐):学習曲線とバリデーション曲線を描画してモデルの学習不足/過学習を診断し、具体的な改善策を提案して改善を検証しましょう。ヒント:learning_curve + validation_curve + 改善前後の比較。