0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Optuna 5.0は同じシードでもtrial 10から別の探索になる。効くのは多変量化ではなくバンド幅

0
Posted at

fig0-question.png

コードのdiffは0行のまま同じシードの提案がtrial 10で2本に割れるので、割っている実体はどの変更なのか、が今回の実測対象です。

optuna.create_study()にsamplerを渡さず、そのままoptimize()を実行している人に向けた話です。
今日(2026-08-03)公開されたOptuna 5.0.0rc1はその「渡していない部分」を4箇所入れ替え、同じシードを固定していても探索はtrial 10から別物になるので、どれだけ変わるのかを4.9.0と素のデフォルト同士のA/B実測で確かめました。

動機はリリース当日のデフォルト刷新を手元で確かめたくなったという単純な好奇心でしたが、測っているうちに、変更の柱に見える多変量化はそれ単体では勝率を上げず、一緒に入ったバンド幅計算の変更が差のほとんどを作っている、という分解結果になりました。
多変量TPEが6年間experimentalのまま留まり、5.0でようやくデフォルトになれた分かれ目は、多変量化そのものではなくバンド幅の計算式にあります。

samplerを自分で指定しているなら探索側の変更は1つも降ってきませんが、重要度のデフォルト評価器だけは変わるので、plot_param_importancesを見る習慣があるなら最初の表の4行目だけ確認して残りは読み飛ばしてください。

検証環境:WSL2(Ubuntu 24.04 / kernel 6.6.87.2)、CPU i7-14700F、Python 3.12.3、optuna 4.9.0 / 5.0.0rc1、LightGBM 4.7.0、scikit-learn 1.9.0、numpy 2.5.1。
この環境にはpipが無いためwheel展開+PYTHONPATH切替で2バージョンを併用していますが、読者はpip install optuna==4.9.0/==5.0.0rc1の環境を2つ作れば足ります。

デフォルトの変更は4つ、コードのdiffは0行

v5.0.0-rc1のリリースノートにはBreaking Changesが14件並びますが、「samplerを指定していない人の結果」を変えるものに絞ると4つです。

変わるもの PR sampler未指定のユーザーへの影響
TPESamplerのmultivariateがデフォルト有効 #6746 単目的の探索が変わる(逐次実行でも)
constant_liarがデフォルトTrue #6738 並列実行のときだけ探索が変わる(本文後半で実証)
多目的のデフォルトがNSGA-II→TPESampler #6766 多目的の探索が変わる
重要度のデフォルトがfANOVA→PED-ANOVA #6748 探索は不変、プロットの数字が変わる

注意点が1つあって、3行目の多目的の入れ替えだけはBreaking Changesの節ではなくEnhancementsに置かれているので(#6766)、既存の多目的studyの結果が黙って変わる変更なのにBreaking Changesだけ読むと見つかりません。

自分の環境がどちらの挙動かは、内部属性を2つ見れば1分で確かめられます。

import optuna  # 4.9.0と5.0.0rc1の両方で実行して見比べる

study = optuna.create_study()                    # 引数なし=そのバージョンのデフォルト
sampler = study.sampler                          # 実際に使われるサンプラーを取り出す
print(optuna.__version__, type(sampler).__name__)
print("multivariate:", sampler._multivariate)    # 4.9: False / 5.0: None(内部属性)
print("constant_liar:", sampler._constant_liar)  # 4.9: False / 5.0: True
mo = optuna.create_study(directions=["minimize", "minimize"])
print("多目的:", type(mo.sampler).__name__)       # 4.9: NSGAIISampler / 5.0: TPESampler

5.0のmultivariateがTrueではなくNoneなのは実行時に解決する条件付きデフォルトで、sampler.pyの_is_multivariate()が単目的ならTrue・多目的ならFalseを返す作りです。

4行目の重要度も実測しておくと、4.9で作った同一の100 trial(breast_cancer、seed 0)に対して、fANOVAはlearning_rateに0.99を与えて他を0.5%以下に潰しますが、PED-ANOVAの1位はsubsampleの0.349で、learning_rateは0.183の2位に下がります。
探索を1 trialも変えずに、ダッシュボードの1位が入れ替わる変更です。
どちらが正しいという話ではなく、fANOVAが全域の分散への寄与を測るのに対しPED-ANOVAは上位領域への到達に効いた度合いを測る、という質問の違いがそのまま数字に出ます。
ついでに書くと、fANOVAは内部のランダムフォレストに乱数が入るため同じ入力でも実行ごとに数字が少し動きます(手元の2回で0.9885と0.9900。PED-ANOVAは2回とも同じ値)。
重要度のプロットを閾値で運用しているなら、5.0に上げた日から数字の意味が変わることだけ覚えておいてください。

なお4.9でmultivariate=Trueを自分で渡すとExperimentalWarningが出ます——2020年から6年間experimentalだった機能が、5.0では警告なしのデフォルトです。

同じシードの提案は、trial 10で分かれる

A/Bの対象はLightGBMの2値分類(breast_cancer、569行30特徴)と10クラス分類(digits、1,797行64特徴)で、7パラメータを探索します。

# ab_minimal.py — optuna==4.9.0 と ==5.0.0rc1 の環境で1回ずつ実行して見比べる
import optuna
import lightgbm as lgb
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import cross_val_score, StratifiedKFold

optuna.logging.set_verbosity(optuna.logging.WARNING)  # trialごとのログを抑える
X, y = load_breast_cancer(return_X_y=True)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)  # CV分割は両版で固定


def objective(trial):
    params = dict(
        learning_rate=trial.suggest_float("learning_rate", 1e-3, 0.3, log=True),
        num_leaves=trial.suggest_int("num_leaves", 8, 256, log=True),
        max_depth=trial.suggest_int("max_depth", 3, 12),
        min_child_samples=trial.suggest_int("min_child_samples", 5, 100, log=True),
        subsample=trial.suggest_float("subsample", 0.5, 1.0),
        colsample_bytree=trial.suggest_float("colsample_bytree", 0.4, 1.0),
        reg_lambda=trial.suggest_float("reg_lambda", 1e-8, 10.0, log=True),
    )
    clf = lgb.LGBMClassifier(
        n_estimators=100, subsample_freq=1, random_state=0,  # 学習側の乱数も固定
        deterministic=True, force_col_wise=True, verbosity=-1, n_jobs=2, **params)
    return -cross_val_score(clf, X, y, cv=cv, scoring="neg_log_loss").mean()


for seed in range(10):
    sampler = optuna.samplers.TPESampler(seed=seed)  # seedだけ渡す=残りは全部デフォルト
    study = optuna.create_study(direction="minimize", sampler=sampler)
    study.optimize(objective, n_trials=100)
    print(optuna.__version__, "seed", seed, "best", round(study.best_value, 5))

seed以外を渡さず、乱数だけ固定してそのバージョンのデフォルトをそのまま使う形でbreast_cancerを10シード、digitsを8シード、各100 trial実行し、trialごとの提案パラメータを両バージョンで突き合わせました。

分かれる場所は全18シードで同じで、trial 0〜9の提案は7パラメータとも両バージョンで完全に一致し、trial 10で初めて別の値が出ます。
TPESamplerはn_startup_trials=10(両バージョン共通)までをランダムサンプリングで埋めてからTPEに切り替わるので、乱数の実装が同じ最初の10本は一致し、TPEの1本目が分岐点になります。

fig1-divergence.png

breast_cancer・seed 0の実ログから、trial 9までの一致とtrial 10での分岐を代表2パラメータで抜き出したものです。

LightGBMのベスト値は、ばらつきの内側から出ない

分かれた先でどれだけ差がつくかを、100 trial後のベストloglossをシードごとに対応させて比べます。

データセット 4.9.0の中央値[範囲] 5.0.0rc1の中央値[範囲] seed対応差の中央値[範囲] 5.0の勝ち
breast_cancer(10シード) 0.07316[0.07070, 0.07766] 0.07296[0.06690, 0.07438] +0.00060[−0.00275, +0.00801] 6/10
digits(8シード) 0.06132[0.05680, 0.06363] 0.05919[0.05592, 0.06536] +0.00209[−0.00468, +0.00690] 5/8

中央値だけ読めば両データセットとも5.0が良い方向ですが、この差は主張に使えません。
seed対応差の幅(約0.011と0.012)が中央値の差(0.0006と0.0021)の5倍以上あり、シードを引き直せば逆転する範囲です。
リリース初日に「5.0で精度が上がる」という数字を看板にするつもりだったので、ここで組み立てを一度考え直すことになりました。
実行時間も100 trialでbreast_cancer 6.7秒対6.5秒、digits 92.2秒対88.3秒(各中央値)と、体感できる差は出ないはずです。
4.9でmultivariate=Trueだけを足した腕も中央値0.07372(breast_cancer)と0.06162(digits)で、やはり幅の内側から出ません。

公式のベンチマークは多数の問題を集計して改善を示しているのでこの結果はそれと矛盾せず、1つのタスクを1回チューニングする使い方では集計で見える改善がばらつきに埋もれます。

差が出る形を、相関で作る

埋もれたままでは分解に進めないので、多変量TPEが捕まえるはずの構造——パラメータ間の相関——を意図的に持つ関数を作って測り直します。
6次元の2次関数を乱数種42の回転行列で回して軸ごとに1〜32倍の重みを付けたもので、あるパラメータの最適値が別のパラメータの値に依存します。

import numpy as np
import optuna

optuna.logging.set_verbosity(optuna.logging.WARNING)  # trialごとのログを抑える
DIM = 6
R = np.linalg.qr(np.random.RandomState(42).normal(size=(DIM, DIM)))[0]  # 回転行列は固定
OFFSET = np.array([1.0, -2.0, 0.5, 2.5, -1.0, 1.5])  # 最適解の位置
SCALE = np.array([1.0, 2.0, 4.0, 8.0, 16.0, 32.0])   # 回転後の軸の重み(条件数32)


def objective(trial):
    x = np.array([trial.suggest_float(f"x{i}", -5.0, 5.0) for i in range(DIM)])
    z = R @ (x - OFFSET)                              # 回転がパラメータ間の相関を作る
    return float(np.sum(SCALE * z * z))


for seed in range(20):
    sampler = optuna.samplers.TPESampler(seed=seed)   # seed以外はデフォルト
    study = optuna.create_study(direction="minimize", sampler=sampler)
    study.optimize(objective, n_trials=100)
    print(optuna.__version__, "seed", seed, "best", round(study.best_value, 2))

腕は3本——4.9のデフォルト(独立TPE)、4.9でmultivariate=Trueだけを渡した多変量TPE(Scott則バンド幅)、そして5.0のデフォルト(多変量TPE+新バンド幅)です。
各腕100 trial×20シードの対応比較で、結果はきれいに割れます。

腕 中央値[範囲] 4.9独立との対応勝敗
4.9デフォルト(独立TPE) 17.13[8.21, 46.54] —
4.9+multivariate=True(Scott則) 12.50[6.34, 60.63] 9勝11敗(差の中央値−0.12)
5.0デフォルト(新バンド幅) 5.77[0.67, 45.45] 16勝4敗(差の中央値+10.19)

多変量化だけを足した真ん中の腕は、表の中央値だけ見れば12.50対17.13で良さそうに見えます。
ところが同じシード同士で引き算すると9勝11敗・差の中央値−0.12で、勝ちは安定していません(大きく勝つシードの裏に大きく負けるシードがあり、範囲の上限60.63は3腕の中で最悪です)。
相関を捕まえるための多変量化のはずなのに、相関を作った関数で独立TPEに勝ち越せない。
ここで「5.0の改善=多変量化」という読みが崩れました。
一方バンド幅ごと入れ替わった5.0は16勝4敗、中央値で3倍近く深く掘っています。
ただし範囲の上限45.45が示すとおり外れシードは残っていて、20回に4回は4.9に負ける程度の分布です。

fig3-synth-curve.png

縦軸が対数のbest-so-far中央値で、破線の多変量だけの腕は独立TPEと絡みながら降りていきます。
5.0だけがtrial 20あたりから離れ、終盤も下がり続けるところを見てください。

消えたのは「全カーネルに同じ幅」という分岐

なぜ多変量化単体では勝てず、5.0では勝てるのか。
2バージョンのTPE実装を関数単位でdiffすると、単目的・制約なし・逐次実行のデフォルト経路で挙動を変える変更は2つに絞れます。
1つはmultivariateの有効化そのもの、もう1つがparzen_estimator.pyのバンド幅計算です(constant_liarは次の節で示すとおり逐次では何もしません。gammaと重みの関数は両バージョンで同一です)。
そしてバンド幅側のdiffで消えているのが、4.9の多変量TPEだけが通っていたこの分岐です。

# optuna 4.9.0 parzen_estimator.py(抜粋)。5.0.0rc1ではこの分岐が削除されている
if parameters.multivariate:
    SIGMA0_MAGNITUDE = 0.2
    sigma = (
        SIGMA0_MAGNITUDE
        * max(len(observations), 1) ** (-1.0 / (len(self._search_space) + 4))
        * (high - low)
    )
    sigmas = np.full(shape=(len(observations),), fill_value=sigma)
else:
    ...  # 隣のカーネルとの距離からσを決める(5.0はこちらに一本化)

4.9の多変量TPEは、カーネル密度推定の全カーネルに同じ幅σを割り当てます。
式はScott則系のヒューリスティックで、観測数nと次元dに対して0.2×n^(−1/(d+4))×(high−low)。
観測がどこに密集していようが全カーネル一律で、しかも6次元ならnの10乗根への逆比例でしか縮みません。
5.0はこの分岐を削除し、単変量側と同じ「両隣のカーネルとの距離の大きい方をσにする」計算へ一本化しました(リリースノートが引用するWatanabe 2023の整理に沿った変更です)。

区間[0, 10]に密集3点(1.0、1.2、1.4)と孤立2点(5.0、9.0)を置き、両バージョンの推定器に与えたときのσがこの表です。

カーネル位置 1.0 1.2 1.4 5.0 9.0
4.9のσ 1.529 1.529 1.529 1.529 1.529
5.0のσ 1.429 1.429 3.600 4.000 4.000

4.9の1.529は上の式にn=5、d=2を入れた値と一致します。
5.0は間隔の詰まった側の2点をクリップ下限((high−low)/min(100, 1+カーネル数)=10/7≈1.429)まで狭め、空白に面した残り3点の幅は隣までの距離3.6〜4.0そのものです。
観測の間隔がそのまま幅になるので、探索が進んで有望領域に点が詰まるほど、その場所のσは自動的に縮みます。

fig2-bandwidth.png

同一の観測5点へのσの割り当てで、4.9は5本とも同じ幅のまま、5.0だけが点の間隔に追従しています。

そしてこの差は、trialを増やしても埋まりません。
TPEは全trialではなくgamma関数で選んだ上位グループ(100 trialなら上位10個、上限25個)でカーネル密度を組むので、式のnは25で頭打ちです。
つまり4.9の多変量TPEは、良い側の10点がどれだけ密集していてもσ=1.59(n=10、6次元、範囲10の場合。nが上限の25でも1.45)より細かく提案を絞れません。
5.0の同条件のクリップ下限は0.83で、点の間隔が詰まればそこまで縮みます。
前の節のカーブで終盤も5.0だけが下がり続けるのは、この解像度の下限の差が効いているためだと読んでいます。
多変量TPEを6年experimentalに留めていた実体は、多変量化のアルゴリズムではなくこの数行のほうだった。
diffを追ってこの削除に行き着いた瞬間が、今回の検証でいちばん引っかかりが取れた場所です。

戻したくなったら、5.0でTPESampler(multivariate=False, constant_liar=False)を明示すれば4.9デフォルトと完全に同じ探索系列に戻ります(3シード×50 trial、カテゴリカル込みで全提案一致を確認済み)。
独立TPE側のバンド幅計算が両バージョンで同一なためです。
重要度はget_param_importancesにFanovaImportanceEvaluator()を渡せば従来の数字です。
逆に4.9のScott則多変量だけを5.0で再現する経路は、分岐ごと削除されているため残っていません。

constant_liarの相手はrunningのtrialだけ

リリースノートで多変量化と並んで柱に見えるconstant_liarは、running状態のtrialに仮の悪い値を置いて同じ場所を重ねて提案しないようにする仕組みで、相手がrunningのtrialだけなので逐次実行にはそもそも対象がいません。

import optuna  # 5.0.0rc1で実行する

optuna.logging.set_verbosity(optuna.logging.WARNING)  # trialごとのINFOログを抑える

def f(trial):
    x = trial.suggest_float("x", -10, 10)  # 2変数の単純な2次関数
    y = trial.suggest_float("y", -10, 10)
    return x * x + y * y

def run(liar):
    sampler = optuna.samplers.TPESampler(seed=0, constant_liar=liar)  # liar以外は共通
    study = optuna.create_study(sampler=sampler)
    study.optimize(f, n_trials=60)
    return [t.params for t in study.trials]  # 60trial分の提案の並びを返す

print(run(True) == run(False))  # 逐次実行ならTrue(全60trialの提案が一致する)

手元では3シードとも60 trialの提案が完全一致してTrueが返るので、study.optimize()を1本で実行している人にとってconstant_liarのデフォルト化は挙動を1 trialも変えません。

並列の効きは、評価に0.3秒かかる7次元の関数をn_jobs=4・120 trial・3シードで実行し、各trialの開始時点で同時に走っていたtrialへの正規化距離の最小値で測りました。
結果は、constant_liarを切っても距離の分布がほぼ動きません(シードごとの中央値はonで0.215〜0.241、offで0.203〜0.258)。
4並列程度では提案が重なる機会がそもそも少ないようで、この条件ではデフォルト化で何が変わるかを数字で示せていません。
目を引いたのは別の対比で、同じ並列条件の4.9デフォルトには距離0.05未満のほぼ重複した提案が327件中4件あり、5.0はon・offのどちらも0件でした。
offでも0件なので、重複が消えたのはconstant_liarではなく多変量化とバンド幅側の効果だと読むほかありません。

この機能は2022年のOptuna 3.0開発時に一度デフォルト化が検討され、公式ブログに「性能が必ずしも改善しない」としてFalseのまま見送った経緯が書かれています。
5.0はその決定の反転で、リリースノートには異常終了でrunningのまま残ったゾンビtrialもconstant_liarが避けてしまう、という注意まで載っています。
6年前の判断の理由と反転の理由が両方公開の場に残っているのは、追試する側としてありがたい作りです。

上げるかどうかと、サンプラー自体の時間

多変量TPEはtrialが増えるほどカーネル密度推定の構築が重くなるはずなので、サンプラー自体の時間も測りました。
目的関数を即時returnにして300 trialを3シード実行し、trial区間ごとの1 trialあたり所要の中央値を取ると、trial 250〜300の窓で4.9が11.6ms、5.0が7.2msです。
どちらもtrialとともに伸びますが(4.9は5.2→11.6ms、5.0は2.9→7.2ms)、全区間で5.0のほうが速く、300 trial合計では2.3秒対1.6秒でした。
重くなる前提で測り始めたので、これは逆です。
独立TPEが7パラメータそれぞれにカーネル密度推定を組み直すのに対し、多変量TPEは7次元の推定を1回組むだけで済む、という実装の構造がそのまま数字に出ています。

もう1つ、TPEのデフォルト改善そのものへの反論として「少ないtrial数ならガウス過程のほうが強い」があります。
5.0で安定版になったGPSamplerを同じbreast_cancer・100 trial・5シードで実行すると、ベストの中央値は0.07761で、TPEデフォルトの範囲の上限0.07438より悪い側に出ました(GPの範囲は0.07267〜0.07762)。
サンプラーの計算も重く、100 trialのwallは34.7秒対6.5秒です。
「少ないtrialsならGP」という一般論をこの1タスクでは否定できませんが、少なくとも100 trialの時点でTPEのデフォルトを置き換える理由は出ません。

使い方ごとの損得を並べると、いちばん得をするのはsamplerを指定せずGBDTのHPOを並列で実行している人です。
多変量化・バンド幅・constant_liarの3つが全部その構成に向いた変更で、コードを1行も書き換えずに受け取れます。
いちばん損な使い方は、重要度プロットを閾値運用したまま気づかず上げることです。
探索が変わらなくても1位が入れ替わる(最初の節の実測では0.99の1位が0.183の2位に落ちる)ので、閾値の意味が5.0を入れた日に消えます。
逐次で1タスクだけ調整している人は、LightGBMの表のとおりベスト値の期待差がばらつき未満なので、急いで上げる理由も避ける理由もありません。
なおrc1は評価用のプレリリースなので本番の更新はfinalを待つのが筋で、上げるときはoptuna.multi_objectiveのimportが残っているとImportErrorで止まる点だけ先に確かめてください。

測れていないことを並べておきます。
実データはLightGBMの2タスクだけで、数十万行の規模やXGBoost・ニューラルネットでは測っていません。
100 trialで打ち切っているので、バンド幅の解像度差が1,000 trialでどう伸びるかは未確認です。
多目的のNSGA-II→MOTPE入れ替えは今回の測定対象外で、合成関数も1つの形(回転した2次)しか試していません。
並列のconstant_liar単独の効果は4並列では分離できず、もっと高い並列度は未測のままです。

所感

デフォルト変更のA/Bという入り口から始めて、着地がparzen_estimator.pyの削除された1分岐になるとは組み立ての時点では思っていませんでした。
「多変量TPEが強い」という6年前からの公式の主張と、「デフォルトはずっと独立TPE」という6年間の実装が、バンド幅の式1つを挟んで両立していたことになります。
リリースノートの1行の裏で引用論文とPRと過去の見送りの記録が全部リンクで辿れるのは、この規模のOSSとして誠実な公開の仕方だと思います。
finalが出たら同じスクリプトをそのまま実行できるので、rc1との差分だけ測り直すつもりです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?