マテリアルズインフォマティクス(MI)入門④【Optunaによるベイズ最適化で実践するハイパーパラメータチューニング】

第1回でベースラインとなる線形回帰モデルの限界(過学習)を学び、第2回では高性能なCatBoostモデルで予測精度を飛躍的に向上させました。さらに第3回では、SHAPを用いてその「ブラックボックス」モデルの判断根拠を解明し、AIに解釈性を与えました。

これまでのステップで、私たちは予測モデルを構築し、解釈する一連の基礎を固めてきました。しかし、私たちが手にしたCatBoostモデルは、まだその真のポテンシャルを発揮していません。なぜなら、それはライブラリが提供する「デフォルト設定」で動かしたに過ぎないからです。

本シリーズの中盤における重要なステップとして、第4回ではハイパーパラメータチューニングに挑戦します。機械学習モデルには、その挙動を決定づける無数の「設定値」が存在し、それらをデータセットに合わせて最適化することで、性能を極限まで引き出すことができます。

総当たり的な「グリッドサーチ」の限界を乗り越えるため、効率的かつインテリジェントな探索を可能にするベイズ最適化を導入し、その実装には国産ライブラリOptunaを用います。さあ、これまで育ててきたモデルの原石を磨き上げる工程を体験しましょう。

動作検証済み環境

Google Colaboratory
Python 3.11.13
matminer==0.9.3
scikit-learn==1.6.1
pandas==2.2.2
matplotlib==3.10.0
catboost==1.2.8
optuna==4.4.0

この記事から学べること

  • ハイパーパラメータチューニングの重要性: なぜデフォルト設定のままでは不十分で、チューニングがモデル性能を最大化する上で不可欠なのかを深く理解できます。
  • ベイズ最適化の直感的理解: 闇雲な探索ではなく、過去の結果を学習しながら効率的に最適解を見つけ出すベイズ最適化の賢い仕組みを学べます。
  • Optunaによる実践的チューニング: ベイズ最適化を驚くほど簡単に実装できるライブラリ Optuna の基本的な使い方をマスターし、自らの手でモデルを最適化するスキルを習得できます。
  • 交差検証による堅牢な評価: チューニングの過程でモデルの性能をより公平かつ安定的に評価するための、交差検証(Cross-Validation)の正しい適用方法を学べます。

関連理論の解説

1. ハイパーパラメータとは?

機械学習モデルにおけるパラメータには2種類あります。一つは、モデルがデータから学習して自動で決定する「パラメータ」(例:線形回帰の係数)。もう一つは、学習プロセスそのものを制御するために人間が事前に設定する「ハイパーパラメータ」です。CatBoostで言えば、木の深さ(depth)、学習率(learning_rate)、決定木の数(iterations)などがこれにあたります。これらはモデルの設計図そのものであり、その値によってモデルの複雑さや学習の進み方が大きく変わります。

2. なぜチューニングが必要か?

デフォルトのハイパーパラメータは、あくまで一般的な問題で無難な性能が出るように設定されているに過ぎません。しかし、現実のデータセットはそれぞれ独自の特性(データの規模、特徴量の数、ノイズの量など)を持っています。ハイパーパラメータチューニングとは、このデータセットの特性に最も適合するハイパーパラメータの組み合わせを探し出し、モデルの予測性能を最大化する、いわば「オーダーメイド仕立て」のプロセスです。

  1. ベイズ最適化:効率的な探索の技術

    最適なハイパーパラメータを探す伝統的な手法に「グリッドサーチ(総当たり)」がありますが、パラメータの数や範囲が増えると計算量が爆発的に増大します。そこで登場するのがベイズ最適化です。

    ベイズ最適化は、以下のような賢いアプローチを取ります。

    • 代理モデル: これまでの試行結果(「このパラメータの組み合わせだと、スコアはこうだった」という情報)を学習し、ハイパーパラメータとスコアの関係性を近似する簡単な予測モデル(代理モデル)を内部に構築します。

    • 獲得関数: 次に、この代理モデルの予測を利用して、「次に試すべき最も有望な点」を決定します。この決定には「獲得関数」という指標が使われ、スコアが高そうな領域を深掘りする「活用(Exploitation)」と、まだデータが少なく不確実だが潜在的に有望かもしれない領域を探す「探査(Exploration)」のバランスを自動で取ってくれます。

      このサイクルを繰り返すことで、ベイズ最適化は無駄な試行を極力減らし、有望な領域を集中的に探索することで、短時間で効率的に最適解にたどり着くことができます。Optunaは、この複雑なプロセスを非常に簡単なコードで実現してくれる強力なライブラリです。

  2. 交差検証:評価の信頼性を高める

    第1回から使ってきた train_test_split による評価は、データの分割の仕方がランダムであるため、たまたま「運の良い」分割で高いスコアが出たり、逆に「運の悪い」分割で低いスコアが出たりと、結果が不安定になる可能性があります。

    交差検証(Cross-Validation, CV)は、この問題を解決する手法です。訓練データをさらに複数個(例えば5個)に分割し、そのうちの1つを検証用、残りを学習用としてモデルを評価します。これを、全ての分割が検証用として1回ずつ使われるまで繰り返します。そして、算出された複数のスコアの平均値をとることで、特定のデータ分割に依存しない、より安定的で信頼性の高い(頑健な)性能評価が可能になります。ハイパーパラメータチューニングでは、この交差検証による頑健なスコアを最大化することを目指します。

実装方法

ワークフロー

# ===================================================================
# 0. 環境構築:必要なライブラリのインストール
# 1. 必要なライブラリのインポート
# 2. データセットの読み込み & 3. 特徴量エンジニアリング
# 4. 特徴量とターゲットの定義
# 5. Optunaの目的関数(Objective)を定義
# 6. Optunaによるベイズ最適化の実行
# 7. 最適化結果の確認
# 8. 最適化モデルでの再学習と最終評価
# 9. 結果の可視化
# ===================================================================

実行手順

  • 以下のコードブロック全体をコピーします。
  • Google Colaboratoryの新しいセルに貼り付けます。
  • セルが選択されていることを確認し、Shift キーと Enter キーを同時に押してコードを実行します。
# ===================================================================
# 0. 環境構築:必要なライブラリのインストール
# ===================================================================
!pip install matminer==0.9.3 scikit-learn==1.6.1
!pip install matplotlib==3.10.0 catboost==1.2.8 optuna==4.4.0
# ===================================================================
# 1. 必要なライブラリのインポート
# ===================================================================
import matplotlib.pyplot as plt
import numpy as np
import re
from matminer.datasets import load_dataset
from sklearn.model_selection import train_test_split, cross_val_score, KFold
from sklearn.metrics import mean_absolute_error, r2_score
from catboost import CatBoostRegressor
import optuna
from IPython.display import display
# ===================================================================
# 2. データセットの読み込み & 3. 特徴量エンジニアリング
# ===================================================================
print("ステップ2&3: データセットの読み込みと特徴量エンジニアリング...")
df = load_dataset("matbench_steels")
def extract_value(composition_string, element_name): pattern = r"{}(\d+\.\d*)".format(element_name) match = re.search(pattern, str(composition_string)) return float(match.group(1)) if match else 0.0
elements = [ "Fe", "C", "Mn", "Si", "Cr", "Ni", "Mo", "V", "N", "Nb", "Co", "W", "Al", "Ti",
]
for element in elements: df[element] = df["composition"].apply(lambda x: extract_value(x, element))
df_clean = df.drop(columns=["composition"])
print("完了しました。")
# ===================================================================
# 4. 特徴量とターゲットの定義
# ===================================================================
features = elements
target = "yield strength"
X = df_clean[features]
y = df_clean[target]
# 訓練データとテストデータに分割 (最終評価用)
X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42)
# ===================================================================
# 5. Optunaの目的関数(Objective)を定義
# ===================================================================
print("\nステップ5: Optunaの目的関数を定義します...")
def objective(trial): # 探索するハイパーパラメータの範囲を定義 params = { "iterations": trial.suggest_int("iterations", 50, 1000), "depth": trial.suggest_int("depth", 6, 7), "learning_rate": trial.suggest_float("learning_rate", 0.02, 0.025, log=True), "l2_leaf_reg": trial.suggest_float("l2_leaf_reg", 1e-3, 0.2, log=True), "random_seed": 42, "verbose": 0, } model = CatBoostRegressor(**params) kf = KFold(n_splits=5, shuffle=True, random_state=42) X_train_np = X_train.values y_train_np = y_train.values scores = cross_val_score(model, X_train_np, y_train_np, cv=kf, scoring="r2") return np.mean(scores)
print("完了しました。")
# ===================================================================
# 6. Optunaによるベイズ最適化の実行
# ===================================================================
print("\nステップ6: ハイパーパラメータの最適化を開始します...")
# 最適化のセッション(study)を作成。R2スコアを最大化(maximize)する。
study = optuna.create_study(direction="maximize")
# 10回のトライアルで最適化を実行
study.optimize(objective, n_trials=10)
print("最適化が完了しました。")
# ===================================================================
# 7. 最適化結果の確認
# ===================================================================
print("\nステップ7: 最適化結果を確認します...")
print(f"試行回数: {len(study.trials)}")
print(f"最高スコア (交差検証R2): {study.best_value:.4f}")
print("最適なハイパーパラメータ:")
display(study.best_params)
# ===================================================================
# 8. 最適化モデルでの再学習と最終評価
# ===================================================================
print("\nステップ8: 最適化されたモデルで再学習し、最終評価を行います...")
# --- 比較用のデフォルトモデル ---
print("\n--- (A) デフォルトモデルの性能 (第2回の結果) ---")
default_model = CatBoostRegressor(random_seed=42, verbose=0)
default_model.fit(X_train, y_train)
y_pred_default = default_model.predict(X_test)
r2_default = r2_score(y_test, y_pred_default)
mae_default = mean_absolute_error(y_test, y_pred_default)
print(f" R2スコア: {r2_default:.4f}")
print(f" MAE: {mae_default:.2f} MPa")
# --- 最適化されたモデル ---
print("\n--- (B) 最適化モデルの性能 ---")
best_params = study.best_params
tuned_model = CatBoostRegressor(**best_params, random_seed=42, verbose=0)
# 全ての訓練データで再学習
tuned_model.fit(X_train, y_train)
# テストデータで最終評価
y_pred_tuned = tuned_model.predict(X_test)
r2_tuned = r2_score(y_test, y_pred_tuned)
mae_tuned = mean_absolute_error(y_test, y_pred_tuned)
print(f" R2スコア: {r2_tuned:.4f}")
print(f" MAE: {mae_tuned:.2f} MPa")
print("\n処理はすべて完了しました。")
# ===================================================================
# 9. 結果の可視化
# ===================================================================
print("\nステップ9: 結果を可視化します...")
# Parity Plotによる性能比較
plt.figure(figsize=(10, 10))
# 理想線
max_val = max(y_test.max(), y_pred_default.max(), y_pred_tuned.max()) * 1.05
min_val = 0
plt.plot([min_val, max_val], [min_val, max_val], "k--", lw=2, label="Ideal (y=x)")
# デフォルトモデルと最適化モデルの予測をプロット
plt.scatter( y_test, y_pred_default, alpha=0.5, s=60, c="blue", label=f"Default (R2={r2_default:.3f})",
)
plt.scatter( y_test, y_pred_tuned, alpha=0.8, s=60, c="red", edgecolors="k", linewidth=0.5, label=f"Tuned (R2={r2_tuned:.3f})",
)
plt.xlabel("Actual Yield Strength (MPa)", fontsize=14)
plt.ylabel("Predicted Yield Strength (MPa)", fontsize=14)
plt.title("Default vs. Tuned Model Performance", fontsize=16)
plt.legend(fontsize=12)
plt.xlim(min_val, max_val)
plt.ylim(min_val, max_val)
plt.grid(True)
plt.show()

実行結果と考察

1. 最適化プロセスと最終結果

Optuna は10回の試行を通じて、交差検証でのR²スコアが 0.8093 となる最適なハイパーパラメータの組み合わせを探索しました。この最適化されたパラメータを用いてモデルを再学習し、未知のテストデータで最終評価を行った結果は以下の通りです。

モデルテストデータ R²スコアテストデータ MAE (MPa)
(A) デフォルトモデル0.80575.70
(B) 最適化モデル0.83670.93

ハイパーパラメータを最適化したことで、R²スコアは0.805から0.836へと向上しました。MAE(平均絶対誤差)はほぼ横ばいでしたが、R²スコアの改善は、モデルがデータのばらつきをより上手く説明できるようになったことを示しています。劇的な改善ではないものの、これはモデルアーキテクチャを変えずに性能のポテンシャルを引き出す、チューニングの重要な役割を示しています。

2. Parity Plotによる視覚的考察

Parity Plotで結果を視覚的に確認すると、最適化モデル(赤い点)はデフォルトモデル(青い点)と比較して、わずかに理想線(黒い破線)に近づいていることが見て取れます。特に予測値が高い領域での外れ値が少し抑制されるなど、細かな改善が見られます。これは、R²スコアの数値的な向上を裏付けるものです。

コードの詳細解説

Optuna を用いたハイパーパラメータチューニングのコードは、一見するとシンプルですが、その裏側ではモデルの性能を最大化するための極めて洗練されたプロセスが動いています。ここでは、各ステップが具体的に何を行い、なぜそれが重要なのかをさらに掘り下げていきましょう。

ステップ5: Optunaの目的関数(Objective)の定義

ここが今回の実装における、まさに心臓部です。Optuna に「どのような実験を」「どのように評価してほしいか」を伝える、いわば「評価のレシピ」を定義する場所です。

# ===================================================================
# 5. Optunaの目的関数(Objective)を定義
# ===================================================================
print("\nステップ5: Optunaの目的関数を定義します...")
def objective(trial): # 探索するハイパーパラメータの範囲を定義 params = { "iterations": trial.suggest_int("iterations", 50, 1000), "depth": trial.suggest_int("depth", 6, 7), "learning_rate": trial.suggest_float("learning_rate", 0.02, 0.025, log=True), "l2_leaf_reg": trial.suggest_float("l2_leaf_reg", 1e-3, 0.2, log=True), "random_seed": 42, "verbose": 0, } model = CatBoostRegressor(**params) kf = KFold(n_splits=5, shuffle=True, random_state=42) X_train_np = X_train.values y_train_np = y_train.values scores = cross_val_score(model, X_train_np, y_train_np, cv=kf, scoring="r2") return np.mean(scores)
print("完了しました。")
  • def objective(trial):
    • この関数は、Optunaが行う1回1回の試行(trial)の具体的な内容を定義します。Optunaはこの objective 関数を何度も(この例では100回)呼び出します。
    • そのたびに、Optunaは trial という特別なオブジェクトを関数に渡します。この trial オブジェクトを通じて、Optunaは「次に試すべきハイパーパラメータの組み合わせ」を提案してくれます。
    • 関数の最終的な役割は、与えられた trial のパラメータでモデルを評価し、その「良さ」を単一の数値(スコア)として返すことです。
  • trial.suggest_...
    • これは、Optunaに探索させるハイパーパラメータの「探索空間(サーチスペース)」を定義する命令です。ここで定義する範囲が、Optunaが探索する世界のすべてとなります。
    • trial.suggest_int("iterations", 50, 1000)iterations という名前のパラメータを、50から1000の間の整数で提案させます。
    • trial.suggest_float("learning_rate", 0.02, 0.025, log=True)learning_rate(学習率)のようなパラメータは、0.001、0.01、0.1のように桁(オーダー)で性能が変わることが多いため、log=True オプションが極めて重要です。これにより、対数スケールで均等に探索が行われ、0.001〜0.01の範囲も0.01〜0.1の範囲も、効率的に探索できます。もし log=False(デフォルト)だと、探索の大部分が0.1に近い範囲に集中してしまい、微細な値の最適化が困難になります。
  • cross_val_scoreによる頑健な評価
    • これは、Optunaに渡す評価スコアの信頼性を担保するための重要な一手です。
    • KFold(n_splits=5, ...) で訓練データを5つのブロックに分割します。
    • cross_val_score は内部で以下の処理を自動的に行います。
      1. ブロック1を検証用、2〜5を学習用としてスコアを計算
      2. ブロック2を検証用、1, 3〜5を学習用としてスコアを計算
      3. …これを5回繰り返し、5つのスコアを得る
    • np.mean(scores) でこれらの平均を取ることで、特定のデータ分割に依存する「運によるブレ」を打ち消し、より安定したモデル性能をOptunaに伝えることができます。
  • return np.mean(scores)
    • objective 関数の最終的な出口であり、Optunaが唯一参考にする情報 です。Optunaはこの返り値(この場合は交差検証によるR²スコアの平均値)を最大化するように、次の trial で試すハイパーパラメータをベイズ最適化の理論に基づきインテリジェントに決定します。

ステップ6: Optunaによるベイズ最適化の実行

objective という「評価のレシピ」を定義したら、いよいよ Optuna に調理(最適化)を任せます。

# ===================================================================
# 6. Optunaによるベイズ最適化の実行
# ===================================================================
print("\nステップ6: ハイパーパラメータの最適化を開始します...")
# 最適化のセッション(study)を作成。R2スコアを最大化(maximize)する。
study = optuna.create_study(direction="maximize")
# 10回のトライアルで最適化を実行
study.optimize(objective, n_trials=10)
print("最適化が完了しました。")
  • optuna.create_study(direction='maximize')
    • これは最適化セッション全体を管理する「研究室(Study)」を設立する命令です。この study オブジェクトが、10回の試行の全履歴(試したパラメータと結果のスコア)を記録・管理します。
    • direction='maximize' は、この研究室の目標が「 objective 関数の返り値を 最大化 すること」であるとOptunaに明確に伝えます。もし誤差(MAEなど)を最小化したい場合は 'minimize' を指定します。
  • study.optimize(objective, n_trials=100)
    • これが、最適化を開始する「魔法の1行」です。
    • この命令により、Optunaは objective 関数を n_trials=10 回呼び出します。
    • その裏では、「試行→評価→学習→推論」のサイクルが回っています。最初の数回はランダムに探索して全体の傾向を掴み(探査)、その後は過去の結果から「よりスコアが高くなりそうな有望な領域」を重点的に探索(活用)しつつ、まだ試していない未知の領域にも時々目を向ける、という非常に効率的な探索を自動で行います。

ステップ8: 最適化モデルでの再学習と最終評価

Optuna が見つけ出した「黄金のレシピ」を使って、最終的なモデルを完成させます。

# ===================================================================
# 8. 最適化モデルでの再学習と最終評価
# ===================================================================
print("\nステップ8: 最適化されたモデルで再学習し、最終評価を行います...")
# --- 比較用のデフォルトモデル ---
print("\n--- (A) デフォルトモデルの性能 (第2回の結果) ---")
default_model = CatBoostRegressor(random_seed=42, verbose=0)
default_model.fit(X_train, y_train)
y_pred_default = default_model.predict(X_test)
r2_default = r2_score(y_test, y_pred_default)
mae_default = mean_absolute_error(y_test, y_pred_default)
print(f" R2スコア: {r2_default:.4f}")
print(f" MAE: {mae_default:.2f} MPa")
# --- 最適化されたモデル ---
print("\n--- (B) 最適化モデルの性能 ---")
best_params = study.best_params
tuned_model = CatBoostRegressor(**best_params, random_seed=42, verbose=0)
# 全ての訓練データで再学習
tuned_model.fit(X_train, y_train)
# テストデータで最終評価
y_pred_tuned = tuned_model.predict(X_test)
r2_tuned = r2_score(y_test, y_pred_tuned)
mae_tuned = mean_absolute_error(y_test, y_pred_tuned)
print(f" R2スコア: {r2_tuned:.4f}")
print(f" MAE: {mae_tuned:.2f} MPa")
  • study.best_params
    • 100回の試行の中で、最も高い交差検証スコアを記録したハイパーパラメータの組み合わせ(辞書形式)を取り出します。これは、100回にわたる賢い探索の成果そのものです。
  • tuned_model.fit(X_train, y_train)
    • ここが非常に重要なポイントです。交差検証はあくまで最適なパラメータを見つけるための「評価手法」であり、その過程で学習されたモデルは最終的には使いません。
    • 見つけ出した最高のパラメータを使い、今度は訓練データ全体 (X_train) で改めてモデルを学習し直します。これにより、手元にある訓練データを100%活用した、最も性能の高いモデルを構築することができます。
  • tuned_model.predict(X_test)
    • これがプロジェクトの最終成績発表の瞬間です。学習とチューニングの全工程で一度も触れていない、完全に未知のテストデータで最終モデルの真の実力を評価します。
    • このスコアが、モデルが実世界でどれくらいの性能を発揮できるかを示す、最も信頼できる指標となります。デフォルトモデルのスコアと比較することで、ハイパーパラメータチューニングという工程がどれだけの価値をもたらしたかを定量的に証明できます。

最後に

4回にわたるハンズオンを通じて、私たちはマテリアルズインフォマティクスのプロジェクトにおける基本的な「型」を習得してきました。

  1. ベースライン構築: シンプルなモデルで出発点と課題を明確化。
  2. 高性能化: 複雑な関係を捉える高度なモデルで精度を向上。
  3. モデル解釈: XAI(SHAP)でブラックボックスに光を当て、判断根拠を理解。
  4. 性能最大化: ベイズ最適化(Optuna)でモデルをチューニングし、性能を極致へ。

単に「当たる」だけでなく、「なぜ当たるか」を説明でき、かつその性能が最大限に引き出されたモデルは、科学者の洞察を加速させる強力なツールとなります。

しかし、これまでの道のりはまだMIの広大な世界の入り口に立ったに過ぎません。私たちが使ってきた特徴量は、まだ単純な元素の含有率だけでした。

次回は、このモデルにさらなる科学的知見を吹き込むため、原子半径や電気陰性度といった**「物理記述子」**の世界へ足を踏み入れます。材料科学のドメイン知識をモデルに注入することで、果たして性能はさらに向上するのか。ぜひご期待ください。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です