Packages

Production-ready hyperparameter optimization for Elixir with high Optuna parity. Leverages BEAM fault tolerance, real-time dashboards, and native distributed computing.

Current section

Files

Jump to
scout lib executor iterative.ex
Raw

lib/executor/iterative.ex

defmodule Scout.Executor.Iterative do
@moduledoc """
Iterative executor with pruning support (Hyperband/SHA).
Objective arity 2: `fn params, report_fun -> ... end`
"""
@behaviour Scout.Executor
alias Scout.Telemetry
# Use ETS store by default
@store_impl Application.compile_env(:scout_core, :store, Scout.Store)
@impl Scout.Executor
def run(study) do
# Don't pollute global RNG state - seed will be set per-trial
:ok = @store_impl.put_study(%{id: study.id, goal: study.goal})
Telemetry.study_created(study.id, study.goal, %{executor: :iterative, max_trials: study.max_trials})
sampler = (study.sampler || Scout.Sampler.RandomSearch)
s_state = sampler.init(Map.merge(study.sampler_opts || %{}, %{goal: study.goal}))
{p_state, _} =
if study.pruner do
{:ok, study.pruner.init(study.pruner_opts || %{})}
else
{:ok, nil}
end
tasks =
for ix <- 0..(study.max_trials - 1) do
Task.Supervisor.async_nolink(Scout.TaskSupervisor, fn -> run_one(study, ix, sampler, s_state, study.pruner, p_state) end)
end
results = Enum.map(tasks, &Task.await(&1, :infinity))
best = pick_best(results, study.goal)
best_score = if best, do: best.score, else: 0.0
Telemetry.study_completed(study.id, %{duration_ms: 0, trial_count: length(results), best_score: best_score}, %{best_trial_id: if(best, do: best.id, else: nil)})
{:ok, %{best_params: best && best.params, best_score: best && best.score, trials: results}}
end
defp run_one(study, ix, sampler, s_state, pruner_mod, p_state) do
history = @store_impl.list_trials(study.id)
{params, _} = sampler.next(study.search_space, ix, history, s_state)
# Pruner: assign bracket
{bracket, p_state2} = if pruner_mod, do: pruner_mod.assign_bracket(ix, p_state), else: {0, p_state}
# Purpose: Use Store.start_trial/3 instead of crafting %Trial{} directly
{:ok, id} = @store_impl.start_trial(study.id, params, bracket)
Telemetry.trial_started(study.id, id, ix, params, %{bracket: bracket})
# Build minimal trial struct for return value only
t = %{id: id, study_id: study.id, params: params, bracket: bracket}
ctx = %{study_id: study.id, goal: study.goal, bracket: bracket}
report_fun = fn score, rung ->
@store_impl.record_observation(study.id, id, bracket, rung, score)
if pruner_mod do
{keep, _} = pruner_mod.keep?(id, [score], rung, ctx, p_state2)
if keep, do: :continue, else: (
# Purpose: Use Store.prune_trial/3 facade method
:ok = @store_impl.prune_trial(study.id, id, score);
Telemetry.trial_pruned(study.id, id, rung, score, bracket, "below_percentile");
:prune
)
else
:continue
end
end
result =
case safe_objective(study.objective, params, report_fun) do
{:ok, score, m} -> {:ok, score, m}
{:ok, score} when is_number(score) -> {:ok, score, %{}}
score when is_number(score) -> {:ok, score, %{}}
score when is_list(score) ->
# Handle multi-objective returns by taking the sum (simple aggregation)
aggregated_score = Enum.sum(score)
{:ok, aggregated_score, %{objectives: score}}
{:pruned, score} -> {:pruned, score}
{:error, reason} -> {:error, reason}
other -> {:error, {:invalid_objective_return, other}}
end
case result do
{:ok, score, m} ->
# Purpose: Use Store.finish_trial/4 facade method
:ok = @store_impl.finish_trial(study.id, id, score, m)
started_at = now() # Approximate since we don't have original
dur = 1000 # Default duration
Telemetry.trial_completed(study.id, id, score, dur, :completed)
Map.merge(t, %{status: :completed, score: score, metrics: m, completed_at: now()})
{:pruned, score} ->
# Purpose: Use Store.prune_trial/3 facade method
:ok = @store_impl.prune_trial(study.id, id, score)
Map.merge(t, %{status: :pruned, score: score, completed_at: now()})
{:error, reason} ->
# Purpose: Use Store.fail_trial/3 facade method
:ok = @store_impl.fail_trial(study.id, id, inspect(reason))
dur = 1000 # Default duration
Telemetry.trial_completed(study.id, id, 0.0, dur, :failed, %{error: inspect(reason)})
Map.merge(t, %{status: :failed, error: inspect(reason), completed_at: now()})
end
end
defp safe_objective(fun, params, report_fun) when is_function(fun, 2) do
try do fun.(params, report_fun) rescue e -> {:error, e} catch :exit, r -> {:error, {:exit, r}} end
end
defp safe_objective(fun, params, _report_fun) when is_function(fun, 1) do
try do fun.(params) rescue e -> {:error, e} catch :exit, r -> {:error, {:exit, r}} end
end
defp pick_best(trials, :maximize), do: Enum.max_by(trials, & &1.score, fn -> nil end)
defp pick_best(trials, :minimize), do: Enum.min_by(trials, & &1.score, fn -> nil end)
defp now, do: System.system_time(:millisecond)
end