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 sampler warm_start_tpe.ex
Raw

lib/sampler/warm_start_tpe.ex

defmodule Scout.Sampler.WarmStartTPE do
@behaviour Scout.Sampler
@moduledoc """
TPE sampler with warm starting capability.
This implementation can leverage trials from previous studies to
accelerate optimization, similar to transfer learning in machine learning.
Useful for:
- Resuming interrupted optimization
- Transfer learning from similar tasks
- Incremental optimization with new data
"""
def init(opts) do
base_state = Scout.Sampler.TPE.init(opts)
Map.merge(base_state, %{
# Previous trials to warm start from
warm_start_trials: Map.get(opts, :warm_start_trials, []),
# Weight for previous trials (0.0 to 1.0)
warm_start_weight: Map.get(opts, :warm_start_weight, 0.5),
# Whether to adapt previous trials to current space
adapt_warm_start: Map.get(opts, :adapt_warm_start, true),
# Source study ID for warm starting
source_study_id: Map.get(opts, :source_study_id)
})
end
def next(space_fun, ix, history, state) do
# Combine current history with warm start trials
combined_history = combine_histories(
history,
state.warm_start_trials,
state.warm_start_weight,
state.adapt_warm_start,
space_fun.(ix)
)
if length(combined_history) < state.min_obs do
# Not enough data even with warm start, use random
Scout.Sampler.RandomSearch.next(space_fun, ix, history, state)
else
# Use TPE with combined history
tpe_state = Map.drop(state, [:warm_start_trials, :warm_start_weight,
:adapt_warm_start, :source_study_id])
Scout.Sampler.TPE.next(space_fun, ix, combined_history, tpe_state)
end
end
# Combine current and warm start histories with weighting
defp combine_histories(current_history, warm_start_trials, weight, adapt, current_spec) do
if warm_start_trials == [] do
current_history
else
# Adapt warm start trials to current search space if needed
adapted_trials = if adapt do
adapt_trials_to_space(warm_start_trials, current_spec)
else
warm_start_trials
end
# Weight the warm start trials
weighted_warm_trials = weight_trials(adapted_trials, weight)
# Combine with current history
weighted_warm_trials ++ current_history
end
end
# Adapt trials from a different search space to the current one
defp adapt_trials_to_space(trials, target_spec) do
Enum.map(trials, fn trial ->
adapted_params = adapt_parameters(trial.params, target_spec)
%{trial | params: adapted_params}
end)
|> Enum.filter(fn trial ->
# Only keep trials that could be successfully adapted
trial.params != nil
end)
end
# Adapt individual parameters to target specification
defp adapt_parameters(params, target_spec) do
try do
Enum.reduce(target_spec, %{}, fn {key, spec}, acc ->
value = Map.get(params, key)
adapted_value = if value != nil do
adapt_value(value, spec)
else
# Parameter doesn't exist in warm start, sample default
sample_default(spec)
end
if adapted_value != nil do
Map.put(acc, key, adapted_value)
else
acc
end
end)
rescue
_ -> nil
end
end
# Adapt a value to match the target specification
defp adapt_value(value, spec) do
case spec do
{:uniform, min, max} when is_number(value) ->
# Clamp to range
max(min, min(max, value))
{:log_uniform, min, max} when is_number(value) and value > 0 ->
# Clamp to range
max(min, min(max, value))
{:int, min, max} when is_number(value) ->
# Convert to integer and clamp
clamped = max(min, min(max, round(value)))
round(clamped)
{:choice, choices} ->
# Check if value is in choices
if value in choices do
value
else
# Try to find closest match
find_closest_choice(value, choices)
end
_ ->
nil
end
end
# Find the closest choice for categorical parameters
defp find_closest_choice(value, choices) do
# Simple string matching for now
value_str = to_string(value)
best_match = Enum.max_by(choices, fn choice ->
choice_str = to_string(choice)
String.jaro_distance(value_str, choice_str)
end)
best_match
end
# Sample a default value for missing parameters
defp sample_default(spec) do
case spec do
{:uniform, min, max} ->
(min + max) / 2.0
{:log_uniform, min, max} ->
:math.exp((:math.log(min) + :math.log(max)) / 2.0)
{:int, min, max} ->
div(min + max, 2)
{:choice, choices} ->
# Pick the first choice as default
List.first(choices)
_ ->
nil
end
end
# Apply weighting to warm start trials
defp weight_trials(trials, weight) do
Enum.map(trials, fn trial ->
# Adjust the score based on weight
# Lower weight means less influence
adjusted_score = if trial.score != nil do
# Blend towards mean to reduce influence
mean_score = calculate_mean_score(trials)
weight * trial.score + (1 - weight) * mean_score
else
trial.score
end
%{trial |
score: adjusted_score
}
end)
end
# Calculate mean score of trials
defp calculate_mean_score(trials) do
scores = trials
|> Enum.map(& &1.score)
|> Enum.filter(&is_number/1)
if scores == [] do
0.0
else
Enum.sum(scores) / length(scores)
end
end
@doc """
Load trials from a previous study for warm starting.
"""
def load_warm_start_trials(study_id) do
# This would typically load from storage
# For now, return empty list
[]
end
@doc """
Create a warm start sampler from a previous study.
"""
def from_study(source_study_id, opts \\ %{}) do
warm_trials = load_warm_start_trials(source_study_id)
init(Map.merge(opts, %{
warm_start_trials: warm_trials,
source_study_id: source_study_id
}))
end
end