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 easy.ex
Raw

lib/easy.ex

defmodule Scout.Easy do
@moduledoc """
Simple Optuna-like API for Scout.
Provides a 3-line interface matching Optuna's simplicity:
result = Scout.Easy.optimize(objective, search_space, n_trials: 100)
IO.inspect(result.best_params)
IO.inspect(result.best_value)
Just like Optuna's:
study = optuna.create_study()
study.optimize(objective, n_trials=100)
print(study.best_params)
"""
@doc """
Optimize an objective function with simple API matching Optuna.
## Storage Modes
Scout automatically selects storage based on availability:
- **Postgres** (persistent): Studies survive restarts, no `Process.sleep(:infinity)` needed
- **ETS** (in-memory): Fast but ephemeral, data lost when process exits
To enable Postgres persistence:
1. Set DATABASE_URL environment variable
2. Run `mix ecto.create && mix ecto.migrate`
3. Scout will auto-detect and use Postgres
Check current mode: `Scout.Store.storage_mode() # => :postgres | :ets`
## Parameters
* `objective` - Function that takes params and returns score
* `search_space` - Map defining parameter space
* `opts` - Keyword list of options:
* `:n_trials` - Number of trials (default: 100)
* `:direction` - :minimize or :maximize (default: :minimize)
* `:sampler` - Sampler atom or module (default: :random)
* `:seed` - Random seed for reproducibility
* `:timeout` - Timeout in milliseconds
* `:study_name` - Name for the study
* `:parallelism` - Number of parallel workers (default: 1)
## Returns
Map with optimization results:
* `:best_value` - Best objective value found
* `:best_params` - Parameters that achieved best value
* `:best_trial` - Full trial information
* `:n_trials` - Number of trials completed
* `:study_name` - Study identifier
## Examples
# Simple optimization (like Optuna)
result = Scout.Easy.optimize(
fn params -> (params.x - 2) ** 2 + (params.y - 3) ** 2 end,
%{x: {:uniform, -5, 5}, y: {:uniform, -5, 5}},
n_trials: 100,
direction: :minimize
)
IO.puts("Best: \#{result.best_value}")
IO.puts("Params: \#{inspect(result.best_params)}")
"""
@spec optimize(function(), map(), keyword()) :: map()
def optimize(objective, search_space, opts \\ []) do
# Parse options with Optuna-like defaults
n_trials = Keyword.get(opts, :n_trials, 100)
direction = Keyword.get(opts, :direction, :minimize)
sampler = Keyword.get(opts, :sampler, :random)
pruner = Keyword.get(opts, :pruner)
seed = Keyword.get(opts, :seed, :rand.uniform(1000000))
study_name = Keyword.get(opts, :study_id) || Keyword.get(opts, :study_name, "study_#{System.system_time(:second)}")
parallelism = Keyword.get(opts, :parallelism, 1)
timeout = Keyword.get(opts, :timeout, :infinity)
# Convert Optuna direction to Scout goal
goal = case direction do
:minimize -> :minimize
:maximize -> :maximize
"minimize" -> :minimize
"maximize" -> :maximize
_ -> :minimize
end
# Ensure Scout is started
ensure_scout_started()
# Log storage mode for visibility
storage_mode = Scout.Store.storage_mode()
IO.puts("Scout using #{storage_mode} storage (#{if storage_mode == :postgres, do: "persistent", else: "ephemeral"})")
# Create Scout study
study = %Scout.Study{
id: study_name,
goal: goal,
max_trials: n_trials,
parallelism: parallelism,
search_space: build_search_space(search_space),
objective: wrap_objective(objective),
sampler: resolve_sampler(sampler),
sampler_opts: %{seed: seed},
pruner: resolve_pruner(pruner),
pruner_opts: %{},
seed: seed,
metadata: %{created_by: "Scout.Easy", api_version: "1.0"}
}
# Run optimization with timeout
result = if timeout == :infinity do
Scout.run(study)
else
task = Task.async(fn -> Scout.run(study) end)
case Task.yield(task, timeout) || Task.shutdown(task) do
{:ok, res} -> res
nil -> {:error, :timeout}
end
end
# Format results like Optuna
case result do
{:ok, res} ->
best_val = res[:best_score] || res[:best_value]
%{
best_value: best_val,
best_score: best_val, # Alias for compatibility
best_params: res[:best_params] || %{},
best_trial: res[:best_trial],
n_trials: res[:n_trials] || n_trials,
study_name: study_name,
study: study_name, # Alias for compatibility
storage_mode: storage_mode,
status: :completed
}
{:error, reason} ->
%{
best_value: nil,
best_params: %{},
best_trial: nil,
n_trials: 0,
study_name: study_name,
status: :error,
error: reason
}
end
end
@doc """
Create a study for manual control (like optuna.create_study).
## Examples
study = Scout.Easy.create_study(direction: :maximize)
result = Scout.Easy.optimize_study(study, objective, n_trials: 50)
"""
@spec create_study(keyword()) :: map()
def create_study(opts \\ []) do
# Support both :name and :study_name for compatibility
study_name = Keyword.get(opts, :name) || Keyword.get(opts, :study_name, "study_#{System.system_time(:second)}")
direction = Keyword.get(opts, :direction, :minimize)
sampler = Keyword.get(opts, :sampler, :random)
%{
name: study_name, # For compatibility with documented API
study_name: study_name, # For consistency with optimize() function
direction: direction,
sampler: sampler,
trials: [],
created_at: DateTime.utc_now()
}
end
@doc """
Load an existing study (like optuna.load_study).
"""
@spec load_study(String.t()) :: {:ok, map()} | {:error, term()}
def load_study(study_name) do
# In a real implementation, this would load from storage
%{
study_name: study_name,
direction: :minimize,
sampler: :random,
trials: [],
loaded_at: DateTime.utc_now()
}
end
@doc """
Get the best value from a study (like optuna study.best_value).
"""
@spec best_value(map()) :: number() | nil
def best_value(study) do
# For created studies, we don't have trials yet
# In a real implementation, this would query the best trial
nil
end
@doc """
Get the best parameters from a study (like optuna study.best_params).
"""
@spec best_params(map()) :: map() | nil
def best_params(study) do
# For created studies, we don't have trials yet
# In a real implementation, this would query the best trial
nil
end
# Private helper functions
defp ensure_scout_started do
case Application.ensure_all_started(:scout_core) do
{:ok, _} -> :ok
{:error, {:already_started, _}} -> :ok
_ ->
# Start Scout components with ETS store
case Process.whereis(Scout.Store) do
nil ->
# Start the store using the configured adapter
adapter = Application.get_env(:scout_core, :store_adapter, Scout.Store.ETS)
{:ok, _} = adapter.start_link([])
:ok
_ ->
:ok
end
end
end
defp build_search_space(space) when is_map(space) do
# Convert to function that returns the space
fn _ix -> space end
end
defp build_search_space(space_fn) when is_function(space_fn, 1) do
space_fn
end
defp wrap_objective(objective) when is_function(objective, 1) do
# Objective that just takes params
objective
end
defp wrap_objective(objective) when is_function(objective, 2) do
# Objective with pruning support (params, report_fn)
objective
end
defp resolve_sampler(:random), do: Scout.Sampler.RandomSearch
defp resolve_sampler(:tpe), do: Scout.Sampler.TPE
defp resolve_sampler(:grid), do: Scout.Sampler.Grid
defp resolve_sampler(:bandit), do: Scout.Sampler.Bandit
defp resolve_sampler(:cmaes), do: Scout.Sampler.CmaEs
defp resolve_sampler(:nsga2), do: Scout.Sampler.NSGA2
defp resolve_sampler(module) when is_atom(module), do: module
defp resolve_sampler(_), do: Scout.Sampler.RandomSearch
defp resolve_pruner(:median), do: Scout.Pruner.MedianPruner
defp resolve_pruner(:percentile), do: Scout.Pruner.PercentilePruner
defp resolve_pruner(:hyperband), do: Scout.Pruner.Hyperband
defp resolve_pruner(module) when is_atom(module), do: module
defp resolve_pruner(_), do: nil
end