Current section
Files
Jump to
Current section
Files
API_GUIDE.md
# Scout API Guide
Comprehensive guide to Scout's architecture, APIs, and internal workings.
## Table of Contents
- [Architecture Overview](#architecture-overview)
- [Public APIs](#public-apis)
- [Core Components](#core-components)
- [Samplers](#samplers)
- [Pruners](#pruners)
- [Storage Adapters](#storage-adapters)
- [Advanced Topics](#advanced-topics)
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────┐
│ Scout.Easy API │
│ (Optuna-compatible interface) │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ Scout.StudyRunner │
│ (Orchestrates trial execution) │
└─────┬────────┬────────┬────────┬────────────────────────────┘
│ │ │ │
┌──▼──┐ ┌─▼──┐ ┌──▼──┐ ┌──▼──┐
│ │ │ │ │ │ │ │
│Exec │ │Sam │ │Prun │ │Stor │
│utor │ │pler│ │er │ │e │
│ │ │ │ │ │ │ │
└─────┘ └────┘ └─────┘ └─────┘
```
### Key Design Principles
1. **Fault Tolerance**: BEAM supervision trees ensure individual trial failures don't crash the study
2. **Pluggable**: Samplers, pruners, and storage are swappable interfaces
3. **Observable**: Telemetry events at every step for monitoring
4. **Distributed**: Oban executor enables multi-node optimization
## Public APIs
### Scout.Easy - Main Entry Point
The recommended API for 99% of use cases. Provides Optuna-compatible interface.
```elixir
Scout.Easy.optimize(objective, search_space, opts)
```
**Parameters:**
- `objective` - Function `(params) -> number` or `(params, report_fn) -> number`
- `search_space` - Map of `%{param_name => distribution}`
- `opts` - Keyword list:
- `:n_trials` - Number of trials (default: 100)
- `:direction` - `:minimize` or `:maximize` (default: `:minimize`)
- `:sampler` - Sampler to use (default: `:random`)
- `:pruner` - Pruner to use (default: `nil`)
- `:seed` - Random seed for reproducibility
- `:parallelism` - Number of parallel workers (default: 1)
- `:timeout` - Timeout in milliseconds (default: `:infinity`)
- `:study_name` - Study identifier for resuming
**Returns:**
Map with:
- `:best_value` - Best objective value found
- `:best_params` - Parameters that achieved best value
- `:best_trial` - Full trial details
- `:n_trials` - Number of trials completed
- `:study_name` - Study identifier
- `:storage_mode` - `:ets` or `:postgres`
**Examples:**
```elixir
# Minimize Rosenbrock function
result = Scout.Easy.optimize(
fn params ->
(1 - params.x) ** 2 + 100 * (params.y - params.x ** 2) ** 2
end,
%{x: {:uniform, -2, 2}, y: {:uniform, -1, 3}},
n_trials: 100,
sampler: :tpe,
seed: 42
)
# Maximize with pruning
result = Scout.Easy.optimize(
fn params, report_fn ->
model = train_model(params)
for epoch <- 1..10 do
loss = evaluate_epoch(model, epoch)
case report_fn.(loss, epoch) do
:continue -> :ok
:prune -> throw(:early_stop)
end
end
final_accuracy(model)
end,
%{
learning_rate: {:log_uniform, 1e-5, 1e-1},
n_layers: {:int, 2, 8}
},
direction: :maximize,
pruner: :hyperband,
parallelism: 4
)
```
### Scout - Low-Level API
For advanced users who need fine control:
```elixir
# Create study struct
study = %Scout.Study{
id: "advanced_study",
goal: :minimize,
max_trials: 100,
search_space: fn _ix -> search_space_map end,
objective: objective_fn,
sampler: Scout.Sampler.TPE,
sampler_opts: %{seed: 42},
pruner: Scout.Pruner.MedianPruner,
pruner_opts: %{}
}
# Run study
{:ok, result} = Scout.run(study)
```
## Core Components
### Scout.Study
Struct defining an optimization study:
```elixir
%Scout.Study{
id: String.t(), # Unique study identifier
goal: :minimize | :maximize, # Optimization direction
max_trials: integer(), # Number of trials to run
parallelism: integer(), # Parallel workers (default: 1)
search_space: function(), # fn(trial_index) -> param_space
objective: function(), # fn(params) -> score
sampler: module(), # Sampler module
sampler_opts: map(), # Sampler configuration
pruner: module() | nil, # Pruner module (optional)
pruner_opts: map(), # Pruner configuration
seed: integer(), # Random seed
metadata: map() # Custom metadata
}
```
### Scout.Trial
Represents a single optimization trial:
```elixir
%Scout.Trial{
id: String.t(), # Unique trial ID
study_id: String.t(), # Parent study ID
params: map(), # Suggested parameters
value: number() | nil, # Objective value (nil if failed)
state: :running | :complete | :failed | :pruned,
started_at: DateTime.t(),
completed_at: DateTime.t() | nil
}
```
### Scout.StudyRunner
Main orchestrator that:
1. Generates trials using sampler
2. Executes objectives via executor
3. Collects results
4. Applies pruning decisions
5. Returns best trial
**Not directly called** - used internally by `Scout.run/1`.
## Samplers
Samplers suggest hyperparameters based on historical trials.
### Base Behavior
All samplers implement:
```elixir
@callback init(search_space, opts) :: sampler_state
@callback next(trial_index, history, sampler_state, search_space) ::
{params, new_state}
```
### Available Samplers
#### Random Sampler (`:random`)
```elixir
Scout.Sampler.RandomSearch
```
- **Algorithm**: Pure random sampling
- **Use case**: Baseline comparisons, very large spaces
- **Pros**: Simple, unbiased, parallelizable
- **Cons**: Doesn't learn from history
**Configuration:**
```elixir
sampler_opts: %{seed: 42}
```
#### TPE (`:tpe`) - **Recommended**
```elixir
Scout.Sampler.TPE
```
- **Algorithm**: Tree-structured Parzen Estimator (Optuna default)
- **Use case**: Most hyperparameter optimization problems
- **Pros**: Learns from history, handles mixed spaces
- **Cons**: Slower than random for very high dimensions (>50)
**Configuration:**
```elixir
sampler_opts: %{
seed: 42,
n_startup_trials: 10, # Random trials before TPE
n_ei_candidates: 24 # Candidates for EI calculation
}
```
**How it works:**
1. Models good/bad trials as separate distributions
2. Samples from "good" distribution
3. Selects parameters with highest Expected Improvement
#### Grid Sampler (`:grid`)
```elixir
Scout.Sampler.Grid
```
- **Algorithm**: Exhaustive grid search
- **Use case**: Small discrete spaces, reproducible experiments
- **Pros**: Guaranteed coverage, deterministic
- **Cons**: Exponential growth, not suitable for continuous spaces
**Configuration:**
```elixir
search_space: %{
learning_rate: {:choice, [1e-4, 1e-3, 1e-2]},
batch_size: {:choice, [16, 32, 64]}
}
# Evaluates all 3 × 3 = 9 combinations
```
#### CMA-ES (`:cmaes`)
```elixir
Scout.Sampler.CmaEs
```
- **Algorithm**: Covariance Matrix Adaptation Evolution Strategy
- **Use case**: Continuous optimization, local refinement
- **Pros**: State-of-the-art for continuous spaces
- **Cons**: Doesn't handle discrete/categorical well
**Configuration:**
```elixir
sampler_opts: %{
seed: 42,
sigma: 0.3, # Initial step size
population_size: 10 # Population per generation
}
```
#### NSGA-II (`:nsga2`)
```elixir
Scout.Sampler.NSGA2
```
- **Algorithm**: Non-dominated Sorting Genetic Algorithm
- **Use case**: Multi-objective optimization (accuracy vs latency)
- **Pros**: Finds Pareto front
- **Cons**: Slower than single-objective samplers
**Configuration:**
```elixir
objective = fn params ->
{accuracy, latency} = evaluate(params)
[-accuracy, latency] # Return vector of objectives
end
sampler_opts: %{
population_size: 50,
crossover_prob: 0.9,
mutation_prob: 0.1
}
```
**Returns Pareto front** - set of non-dominated solutions.
### Custom Samplers
Implement the `Scout.Sampler` behavior:
```elixir
defmodule MyCustomSampler do
@behaviour Scout.Sampler
def init(search_space, opts) do
# Initialize sampler state
%{seed: opts[:seed] || 42, history: []}
end
def next(trial_index, history, state, space_fun) do
# Generate parameters based on history
space = space_fun.(trial_index)
params = # ... your algorithm ...
{params, updated_state}
end
end
# Use it
Scout.Easy.optimize(obj, space, sampler: MyCustomSampler)
```
## Pruners
Pruners decide whether to stop trials early based on intermediate results.
### Base Behavior
```elixir
@callback should_prune?(trial, intermediate_values, all_trials, opts) ::
boolean()
```
### Available Pruners
#### Median Pruner (`:median`)
```elixir
Scout.Pruner.MedianPruner
```
- **Algorithm**: Stop if intermediate value worse than median
- **Use case**: Aggressive pruning, many cheap trials
- **Saves**: 30-50% compute time
**Configuration:**
```elixir
pruner_opts: %{
n_startup_trials: 5, # Don't prune first N trials
n_warmup_steps: 3 # Don't prune before step N
}
```
#### Percentile Pruner (`:percentile`)
```elixir
Scout.Pruner.PercentilePruner
```
- **Algorithm**: Stop if below percentile threshold
- **Use case**: Conservative pruning
- **Saves**: 20-40% compute time
**Configuration:**
```elixir
pruner_opts: %{
percentile: 25.0, # Stop bottom 25%
n_startup_trials: 5,
n_warmup_steps: 3
}
```
#### Hyperband (`:hyperband`)
```elixir
Scout.Pruner.Hyperband
```
- **Algorithm**: Adaptive resource allocation
- **Use case**: Deep learning, expensive trials
- **Saves**: 40-70% compute time
**Configuration:**
```elixir
pruner_opts: %{
max_resource: 81, # Maximum epochs/iterations
reduction_factor: 3 # Keeps 1/3 of trials per rung
}
```
**How it works:**
1. Divides trials into "rungs" (epochs 3, 9, 27, 81)
2. At each rung, keeps top 1/reduction_factor trials
3. Others are pruned
**Example:** With 81 trials and reduction_factor=3:
- Epoch 3: 81 trials
- Epoch 9: 27 trials (top 1/3)
- Epoch 27: 9 trials (top 1/3 of 27)
- Epoch 81: 3 trials (top 1/3 of 9)
## Storage Adapters
Scout supports pluggable storage backends.
### ETS Adapter (Default)
```elixir
Scout.Store.ETS
```
- **Type**: In-memory, per-node
- **Persistence**: None (data lost on restart)
- **Use case**: Quick experiments, notebooks
- **Performance**: Very fast
**No configuration needed** - works out of the box.
### PostgreSQL Adapter
```elixir
Scout.Store.Postgres
```
- **Type**: Relational database
- **Persistence**: Survives restarts
- **Use case**: Production, distributed optimization
- **Performance**: Fast (indexed queries)
**Configuration:**
```elixir
# config/config.exs
config :scout_core, Scout.Repo,
database: "scout_db",
username: "postgres",
password: "postgres",
hostname: "localhost"
# Or via environment variable
export DATABASE_URL="postgres://user:pass@localhost/scout_db"
```
**Migrations:**
```bash
mix ecto.create
mix ecto.migrate
```
**Scout auto-detects** PostgreSQL and switches from ETS.
### Custom Adapters
Implement `Scout.Store.Adapter` behavior:
```elixir
defmodule MyAdapter do
@behaviour Scout.Store.Adapter
def create_study(study) do
# Persist study
{:ok, study}
end
def get_study(study_id) do
# Fetch study
{:ok, study}
end
def create_trial(trial) do
# Persist trial
{:ok, trial}
end
# ... implement all callbacks ...
end
```
## Advanced Topics
### Distributed Optimization
Using Oban executor for multi-node optimization:
```elixir
# config/config.exs
config :scout_core,
executor: Scout.Executor.Oban
# Connect nodes
Node.connect(:"worker1@host1")
Node.connect(:"worker2@host2")
# Optimize (trials distributed across nodes)
result = Scout.Easy.optimize(obj, space,
parallelism: 20, # 20 workers across cluster
n_trials: 1000
)
```
### Telemetry Integration
Scout emits telemetry events for monitoring:
```elixir
:telemetry.attach_many(
"scout-handler",
[
[:scout, :trial, :start],
[:scout, :trial, :stop],
[:scout, :trial, :exception]
],
&MyApp.Telemetry.handle_event/4,
nil
)
def handle_event([:scout, :trial, :stop], measurements, metadata, _config) do
IO.puts("Trial #{metadata.trial_id} completed in #{measurements.duration}ms")
IO.puts("Value: #{metadata.value}")
end
```
**Available events:**
- `[:scout, :study, :start]`
- `[:scout, :study, :stop]`
- `[:scout, :trial, :start]`
- `[:scout, :trial, :stop]`
- `[:scout, :trial, :exception]`
- `[:scout, :trial, :pruned]`
### Conditional Search Spaces
Search space can depend on previous parameters:
```elixir
search_space = fn trial_index ->
# Different space for different trial phases
if trial_index < 10 do
%{x: {:uniform, -10, 10}} # Exploration
else
%{x: {:uniform, -1, 1}} # Exploitation
end
end
# Or conditional on other parameters
objective = fn params ->
# optimizer-specific learning rate ranges
lr_range = case params.optimizer do
"adam" -> {:log_uniform, 1e-5, 1e-2}
"sgd" -> {:log_uniform, 1e-3, 1e-1}
end
# ... use lr_range ...
end
```
### Warm Starting
Initialize with prior knowledge:
```elixir
# Load historical trials from previous study
history = load_previous_trials()
# Inject into new study
study = %Scout.Study{
# ... normal config ...
metadata: %{warm_start_trials: history}
}
{:ok, result} = Scout.run(study)
```
TPE sampler will consider warm-start trials when suggesting parameters.
### Constraints
Add parameter constraints:
```elixir
objective = fn params ->
# Reject invalid combinations
if params.min_child_weight > params.max_depth do
1_000_000 # Large penalty
else
actual_objective(params)
end
end
```
Or use explicit constraints (experimental):
```elixir
constraints = fn params ->
params.x + params.y <= 10 and
params.x >= params.y
end
# Sampler respects constraints when sampling
```
## Error Handling
### Objective Function Failures
```elixir
objective = fn params ->
try do
train_model(params)
rescue
e ->
Logger.error("Trial failed: #{inspect(e)}")
:infinity # Or return large penalty
end
end
```
Scout marks trial as `:failed` but continues optimization.
### Study Timeout
```elixir
result = Scout.Easy.optimize(obj, space,
timeout: 600_000 # 10 minutes
)
case result.status do
:completed -> IO.puts("Success!")
:error -> IO.puts("Timed out: #{result.error}")
end
```
### Storage Errors
PostgreSQL connection issues are handled gracefully:
```elixir
# Falls back to ETS if PostgreSQL unavailable
Scout.Easy.optimize(obj, space, n_trials: 100)
# => Uses ETS, logs warning
```
## Performance Tips
1. **Use TPE for <50 dimensions**, random for >50
2. **Enable pruning** for expensive objectives (saves 30-70%)
3. **Parallel optimization**: Set `parallelism: N` for N cores
4. **PostgreSQL for long studies** (hours/days)
5. **Batch trials**: Run 10-20 random trials before TPE kicks in
6. **Profile objectives**: 90% of time is in objective function
## See Also
- [Getting Started](GETTING_STARTED.md) - Quick start guide
- [Benchmark Results](BENCHMARK_RESULTS.md) - Performance validation
- [Deployment Guide](DEPLOYMENT.md) - Production deployment
---
**Questions?** Open an issue on [GitHub](https://github.com/jmanhype/scout/issues).