Claude CodeでOptunaの探索空間を絞り直す — study.dbを読んで再探索
Optunaの探索スクリプトをclaude -pで書かせ、study.dbから重要度と範囲の端に張り付いた値を読み、探索空間を狭めて再探索する手順。pruner設定と権限の分け方も。
Optunaの探索は、1回目で当たりを引くより、1回目の結果から範囲を狭めて2回目を回すほうが効きます。その「結果を読んで範囲を決め直す」作業をClaude Codeに任せるには、探索の結果が study.db という1つのSQLiteファイルに残っていて、スクリプトで読み出せる状態になっていれば足ります。
この記事では、探索スクリプトを claude -p で書かせるところから、パラメータ重要度と範囲の端に張り付いた値を読み、探索空間を狭めて再探索するところまでを順に扱います。MLflowでrunを比べる流れはClaude CodeでMLflowの実験管理を組み込むにあり、ここでは探索空間の設計とpruner(見込みのない試行の打ち切り)に絞ります。
絞り直しの流れ:探索、診断、再探索
全体は5段階です。Claudeに任せるのは1と4、人間が手元で回すのは2と5という分担にしています。
探索空間を絞り直す5段階
- 1
探索スクリプトを書かせる
claude -pでsearch.pyを作らせます。保存先はsqlite:///study.db、study_nameは固定、load_if_exists=Trueを必須にします。 - 2
探索を自分で回す
python search.pyを端末で実行します。長時間の探索をClaudeのBashに任せない理由は後述します。 - 3
study.dbから診断値を出す
analyze.pyが重要度、状態別の試行数、上位の試行が範囲のどこにいるかを表示します。 - 4
診断を読ませて新しい範囲を決める
analyze.pyの出力だけをclaude -pに渡し、パラメータごとに「固定する・狭める・広げる」を判断させます。 - 5
新しいstudy名で再探索する
範囲を変えたスクリプトで、別の
study_nameとして回します。
探索スクリプトを書かせるときの指定
探索そのものはOptunaが回すので、Claudeに頼むのは「後から結果を読める形で保存するスクリプト」です。ここを指定しないと、保存先を省いたスクリプトができ、診断の段階で読むものが無くなります。
OptunaのRDBストレージの手順では、storage にSQLiteのURLを渡すと、DBファイルが新しいstudyのレコード付きで自動的に作られます。同じ study_name と load_if_exists=True で作り直せば、既存のstudyに続きから試行を足せます。
一方で、ストレージにはsamplerとprunerのインスタンスの状態は保存されません。シードを固定した探索を再開するときは、samplerをpickleで保存して復元する必要があると、同じ手順に書かれています。
claude -p "search.py を作る。要件: \
Optunaで digits の SGDClassifier を最大化する。\
storage は sqlite:///study.db、study_name は sgd-digits、load_if_exists=True。\
alpha(1e-8〜1e-1)と eta0(1e-4〜1.0)は log=True で探索する。\
epoch ごとに trial.report し、should_prune で TrialPruned を送出する。\
pruner は MedianPruner(n_startup_trials=5, n_warmup_steps=3)。\
探索は実行しない。" \
--allowedTools "Read,Write"最後の「探索は実行しない」が要点です。--allowedTools に Bash を入れなければ、そもそも実行できません。claude -p は、許可リストにないツールの呼び出しが承認を要する場合、手動モードでは拒否します。
生成されるスクリプトの形は、例えば次のようになります。以下は手順を示すための例で、データセットとモデルは自分の学習コードに置き換えます。
import optuna
from sklearn.datasets import load_digits
from sklearn.linear_model import SGDClassifier
from sklearn.model_selection import train_test_split
X, y = load_digits(return_X_y=True)
X_tr, X_va, y_tr, y_va = train_test_split(X, y, random_state=0)
def objective(trial):
alpha = trial.suggest_float("alpha", 1e-8, 1e-1, log=True)
eta0 = trial.suggest_float("eta0", 1e-4, 1.0, log=True)
clf = SGDClassifier(alpha=alpha, eta0=eta0,
learning_rate="constant", random_state=0)
for step in range(20):
clf.partial_fit(X_tr, y_tr, classes=list(range(10)))
score = clf.score(X_va, y_va)
trial.report(score, step)
if trial.should_prune():
raise optuna.TrialPruned()
return score
study = optuna.create_study(
study_name="sgd-digits",
storage="sqlite:///study.db",
direction="maximize",
load_if_exists=True,
pruner=optuna.pruners.MedianPruner(n_startup_trials=5, n_warmup_steps=3),
)
study.optimize(objective, n_trials=50)suggest_float の log=True は、範囲を対数の目盛りで均等にサンプリングする指定です。学習率や正則化係数のように桁で効くパラメータに使います。このとき low は0より大きくなければならず、step と同時には指定できません。
pruner(打ち切り)の引数
MedianPruner は、同じステップで他の試行の中央値より悪い試行を打ち切ります。引数は3つ見れば足ります。
| 引数 | 既定値 | 効き方 |
|---|---|---|
n_startup_trials | 既定値5 | 効き方同じstudyで試行がこの数だけ終わるまで、打ち切りを行わない |
n_warmup_steps | 既定値0 | 効き方現在のステップがこの値未満の間は打ち切りを行わない |
interval_steps | 既定値1 | 効き方打ち切りを判定する間隔(ステップ数) |
trial.report の step は0始まりの整数として扱われます。なお、prunersモジュールは単一目的の最適化で使う前提です。多目的の探索にpruner付きのスクリプトを書かせないよう、プロンプトに目的数を書いておきます。
study.dbから診断値を出すスクリプト
診断のスクリプトは、生のSQLを書かせず、Optunaの load_study を通して読ませます。テーブル構造に依存せず、公開されているAPIだけで読めます。
import math
import optuna
from optuna.trial import TrialState
study = optuna.load_study(study_name="sgd-digits",
storage="sqlite:///study.db")
# 1) 状態別の試行数
print({s.name: sum(t.state == s for t in study.trials) for s in TrialState})
# 2) パラメータ重要度(完了した試行から計算される)
print(optuna.importance.get_param_importances(study))
# 3) 上位の試行が、範囲のどこにいるか
done = [t for t in study.trials if t.state == TrialState.COMPLETE]
rev = study.direction.name == "MAXIMIZE"
top = sorted(done, key=lambda t: t.value, reverse=rev)
top = top[:max(5, len(done) // 5)]
for name, dist in done[0].distributions.items():
f = math.log if dist.log else (lambda v: v)
vals = [t.params[name] for t in top]
pos = [(f(v) - f(dist.low)) / (f(dist.high) - f(dist.low)) for v in vals]
edge = sum(p < 0.05 or p > 0.95 for p in pos)
print(name, "範囲", dist.low, dist.high,
"上位の最小/最大", min(vals), max(vals),
"端に近い数", f"{edge}/{len(pos)}")3つの診断が、それぞれ別の判断に対応します。
- 状態別の試行数:
COMPLETE以外にPRUNEDとFAIL、実行中のRUNNINGがあります。診断の前提として、完了した試行がどれだけあるかを見ます - 重要度:
get_param_importancesは、完了した試行をもとに計算します。値は0以上で、既定では合計が1.0になるよう正規化され、重要なものが先頭に並びます。既定の評価器はPedAnovaImportanceEvaluatorです - 端に近い数: 上位の試行の値が、範囲の下端か上端に偏っていないかを見ます。0.05と0.95という境界はこのスクリプトでの目安で、Optunaの仕様ではありません
重要度の関数は、Optunaのドキュメントで実験的な機能とされています。インターフェースが将来のバージョンで予告なく変わりえるので、analyze.py の中で呼び出しを1か所にまとめておくと、変更に追随しやすくなります。
診断の出力を読ませて、範囲の方針を決める
診断スクリプトの出力は数行なので、パイプで渡せます。標準入力は10MBまでという上限があるため、試行ごとの全ログを渡す設計は避け、集計済みの出力だけを渡します。
python analyze.py | claude --bare -p \
"探索空間の見直し案を出す。パラメータごとに fix(固定)、narrow(狭める)、\
widen(広げる)、keep(据え置き)のどれかと、新しい low/high を返す。\
根拠は重要度と『端に近い数』のみ。" \
--output-format json \
--json-schema '{"type":"object","properties":{"params":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"action":{"type":"string"},"low":{"type":"number"},"high":{"type":"number"}},"required":["name","action"]}}},"required":["params"]}' \
| jq '.structured_output'--json-schema を付けると、スキーマに沿った結果が structured_output というフィールドに入ります。有効な出力が得られなかった実行では、このフィールド自体がありません。自動で後続の処理につなぐなら、jq の結果が null でないことを確認してから進めます。
--bare は起動時の自動検出を省くモードで、スクリプトや連携の呼び出しに推奨されています。ただしOAuthやキーチェーンを読まないので、ANTHROPIC_API_KEY を環境変数に設定しておく必要があります。
診断値からの判断の目安
Claudeに渡す前に、人間側でも判断の型を持っておくと、出力の良し悪しを見分けられます。
| 診断の様子 | 取る方針 | 理由 |
|---|---|---|
| 重要度が小さく、上位の値がばらけている | 取る方針固定、または範囲を縮める | 理由動かしても目的関数が変わりにくく、探索の次元を無駄に増やしている |
| 上位の大半が上端か下端に張り付いている | 取る方針張り付いた側へ範囲を広げる | 理由最適値が範囲の外にある可能性がある |
| 上位が範囲の中ほどに集まっている | 取る方針集まった付近まで狭める | 理由同じ試行数でも、狭い範囲を密に探索できる |
| 完了した試行が少ない | 取る方針方針を決めず、試行を追加する | 理由重要度も上位の分布も、少数の試行では偏る |
最後の行は、重要度の計算が完了した試行を使う点とつながっています。pruner付きの探索で多くの試行が PRUNED になった探索では、重要度の根拠になる試行数が見かけより少なくなります。
狭めた範囲で再探索する
範囲を変えるときは、新しい study_name で別のstudyを作ります。同じstudyの範囲だけを書き換えて続けると、診断の前後で試行の母集団が混ざり、次の診断が読みにくくなります。同じDBファイルに複数のstudyを置けるので、保存先を変える必要はありません。
claude -p "search.py を search_v2.py にコピーし、次の変更だけ行う。\
study_name を sgd-digits-v2 にする。\
alpha の範囲を 1e-6〜1e-3 に、eta0 の範囲を 1e-2〜1.0 にする。\
それ以外は変えない。" \
--allowedTools "Read,Write"新しいstudyの最初に、前回の最良の組を試したいときは enqueue_trial が使えます。指定した値を次の試行の候補として固定できます。
study.enqueue_trial({"alpha": 3e-5, "eta0": 0.2})狭めた範囲に前回の最良値が入っているかは、スクリプトの診断でも確かめられます。入っていなければ、範囲の決め方を見直す材料になります。
権限と実行の分担
探索の実行はClaudeに任せず、人間が端末から起動します。--bare では、タイムアウトに達したコマンドがバックグラウンドに移らずに停止するためです。数十分かかる探索を claude -p の中のBashで走らせると、途中で止まる可能性があります。Claudeには読み取りと書き込みだけを許可し、時間のかかる処理は手元で回す分担にすると、探索の途中でセッションが切れても study.db に結果が残ります。
診断スクリプトの実行だけをClaudeに任せたい場合は、コマンドを完全一致で許可できます。
{
"permissions": {
"allow": [
"Read",
"Write",
"Bash(python analyze.py)"
]
}
}Bash(python analyze.py) は、その文字列と一致するコマンドだけを許可します。python search.py は一致しないので、Claudeが探索を始める経路は開きません。
CLAUDE.mdに書いておく規約
セッションをまたいで同じ形で探索を積み上げるには、次のような規約を置いておきます。
## Optuna
- storage は sqlite:///study.db、study は study_name で区別する
- 範囲を変えるときは study_name を変え、既存の study には試行を足さない
- 探索(python search*.py)は実行しない。診断は python analyze.py だけ
- 重要度と上位の分布を根拠にし、試行が20未満なら方針を出さない
- 学習率や正則化係数は log=True で探索する最後から2つ目の「20未満なら方針を出さない」は、このプロジェクトでの運用の目安です。Optunaが定める値ではないので、探索の規模に合わせて変えます。
つまずきやすい点
落とし穴
強制終了した探索がRUNNINGのまま残る
探索のプロセスを途中で止めると、そのときの試行は RUNNING のまま study.db に残ることがあります。RDBStorage の heartbeat_interval を設定すると、最後のハートビートから猶予期間を過ぎた実行中の試行が失敗扱いになります。この機能は実験的で、APIが将来変わる可能性があります。
- 重要度が出ない: 重要度は完了した試行から計算されるため、
COMPLETEが少ないstudyでは評価できません。診断スクリプトの先頭で状態別の試行数を表示しておくと、Claudeが原因を取り違えません - 範囲を変えたあとの比較: 同じstudyに別の範囲の試行を混ぜると、診断の前後が区別できなくなります。範囲を変えるたびにstudy名を変える運用なら、上位の分布をstudyごとに読めます
- 打ち切りを強くしすぎる: 重要度は完了した試行から計算されるので、
PRUNEDが増えるほど根拠にできる試行が減ります。n_startup_trialsとn_warmup_stepsを小さくしすぎないようにします - 診断を渡しすぎる: 試行を1件ずつ全部渡すと、入力が長くなって根拠が散ります。集計済みの数行に絞るほうが、判断の精度も上がります
まとめ
Optunaの探索空間を絞り直す作業は、study.db から診断値を出す部分を決まった形のスクリプトにすると、Claude Codeに渡しやすくなります。探索の実行は人間が起動し、Claudeには書き込みと診断の読み取りだけを許可します。
最初の1歩は、analyze.py の3つの出力(状態別の試行数、重要度、端に近い数)を作ることです。出力が数行に収まれば、claude -p に渡して範囲の方針を決めさせる工程は、1コマンドで回せます。CIから同じ形でスクリプトを回すなら、Claude CodeでCIのテスト失敗を自動修正するの考え方がそのまま使えます。ログが大きくなる場合の絞り方はclaude -pにログを渡すと10MB上限で止まるにまとめています。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。