Current section
Files
Jump to
Current section
Files
lib/sampler/conditional_tpe.ex
defmodule Scout.Sampler.ConditionalTPE do
@behaviour Scout.Sampler
@moduledoc """
TPE sampler with support for conditional search spaces.
This implementation handles conditional parameters where certain parameters
only exist based on the values of other parameters. Similar to Optuna's
TPESampler(group=True) option.
Example:
# If classifier is "SVM", suggest C parameter
# If classifier is "RandomForest", suggest max_depth
"""
def init(opts) do
base_state = Scout.Sampler.TPE.init(opts)
Map.merge(base_state, %{
# Enable grouping for conditional parameters
group: Map.get(opts, :group, true),
# Track parameter dependencies
param_groups: Map.get(opts, :param_groups, %{}),
# Cache for conditional evaluations
condition_cache: %{}
})
end
def next(space_fun, ix, history, state) do
spec = space_fun.(ix)
if not state.group do
# Fallback to regular TPE without conditional support
Scout.Sampler.TPE.next(space_fun, ix, history, state)
else
# Handle conditional parameters
sample_with_conditions(spec, history, state, space_fun, ix)
end
end
# Sample parameters considering conditional dependencies
defp sample_with_conditions(spec, history, state, space_fun, ix) do
# Identify independent and conditional parameters
{independent_params, conditional_params} = classify_parameters(spec)
# First, sample independent parameters
independent_values = sample_independent_params(
independent_params,
history,
state,
space_fun,
ix
)
# Then, sample conditional parameters based on independent values
conditional_values = sample_conditional_params(
conditional_params,
independent_values,
history,
state
)
# Combine all parameters
all_params = Map.merge(independent_values, conditional_values)
{all_params, state}
end
# Classify parameters into independent and conditional
defp classify_parameters(spec) do
Enum.reduce(spec, {%{}, %{}}, fn {key, value}, {indep, cond} ->
case value do
{:conditional, condition_fn, param_spec} ->
{indep, Map.put(cond, key, {condition_fn, param_spec})}
_ ->
{Map.put(indep, key, value), cond}
end
end)
end
# Sample independent parameters using TPE
defp sample_independent_params(params, history, state, space_fun, ix) do
if map_size(params) == 0 do
%{}
else
# Create a temporary spec with only independent params
temp_spec_fun = fn _ -> params end
# Use TPE to sample independent parameters
{sampled, _} = Scout.Sampler.TPE.next(temp_spec_fun, ix, history, state)
sampled
end
end
# Sample conditional parameters based on conditions
defp sample_conditional_params(conditional_params, independent_values, history, state) do
Enum.reduce(conditional_params, %{}, fn {key, {condition_fn, param_spec}}, acc ->
# Check if condition is met
if condition_fn.(independent_values) do
# Condition is true, sample this parameter
value = sample_single_conditional_param(
key,
param_spec,
independent_values,
history,
state
)
Map.put(acc, key, value)
else
# Condition is false, skip this parameter
acc
end
end)
end
# Sample a single conditional parameter
defp sample_single_conditional_param(key, param_spec, parent_values, history, state) do
# Filter history to only include trials with matching parent values
filtered_history = filter_history_by_parents(history, parent_values)
if length(filtered_history) < state.min_obs do
# Not enough data, sample randomly
sample_random(param_spec)
else
# Use TPE on filtered history
sample_with_tpe(key, param_spec, filtered_history, state)
end
end
# Filter history to trials with matching parent parameter values
defp filter_history_by_parents(history, parent_values) do
Enum.filter(history, fn trial ->
Enum.all?(parent_values, fn {key, value} ->
Map.get(trial.params, key) == value
end)
end)
end
# Random sampling for a parameter
defp sample_random(param_spec) do
case param_spec do
{:uniform, min, max} ->
min + :rand.uniform() * (max - min)
{:log_uniform, min, max} ->
log_min = :math.log(min)
log_max = :math.log(max)
:math.exp(log_min + :rand.uniform() * (log_max - log_min))
{:int, min, max} ->
min + :rand.uniform(max - min + 1) - 1
{:choice, choices} ->
Enum.random(choices)
_ ->
0.0
end
end
# Sample using TPE on filtered history
defp sample_with_tpe(key, param_spec, filtered_history, state) do
# Build distributions from filtered history
obs = for t <- filtered_history,
is_number(t.score),
Map.has_key?(t.params, key),
do: {Map.get(t.params, key), t.score}
if obs == [] do
sample_random(param_spec)
else
# Split into good and bad
sorted = case state.goal do
:minimize -> Enum.sort_by(obs, fn {_p, s} -> s end, :asc)
_ -> Enum.sort_by(obs, fn {_p, s} -> s end, :desc)
end
n = length(sorted)
n_good = max(trunc(state.gamma * n), 1)
{good, bad} = Enum.split(sorted, n_good)
# Sample from good distribution
good_values = Enum.map(good, fn {v, _} -> v end)
if good_values == [] do
sample_random(param_spec)
else
# Use KDE to sample
sample_from_kde(good_values, param_spec)
end
end
end
# Sample from KDE built from good values
defp sample_from_kde(values, param_spec) do
# Simple KDE sampling: pick a random value and add noise
base = Enum.random(values)
case param_spec do
{:uniform, min, max} ->
bandwidth = (max - min) * 0.1
value = base + :rand.normal() * bandwidth
max(min, min(max, value))
{:log_uniform, min, max} ->
log_base = :math.log(base)
log_min = :math.log(min)
log_max = :math.log(max)
bandwidth = (log_max - log_min) * 0.1
log_value = log_base + :rand.normal() * bandwidth
:math.exp(max(log_min, min(log_max, log_value)))
{:int, min, max} ->
bandwidth = max((max - min) * 0.1, 1.0)
value = base + round(:rand.normal() * bandwidth)
max(min, min(max, value))
{:choice, choices} ->
# For categorical, use frequency-based sampling
freq = Enum.frequencies(values)
total = Enum.sum(Map.values(freq))
r = :rand.uniform() * total
{selected, _} = Enum.reduce_while(freq, {nil, 0}, fn {choice, count}, {_, cum} ->
new_cum = cum + count
if new_cum >= r do
{:halt, {choice, new_cum}}
else
{:cont, {choice, new_cum}}
end
end)
selected || Enum.random(choices)
_ ->
base
end
end
end
defmodule Scout.ConditionalSpace do
@moduledoc """
Helper module for defining conditional search spaces.
Example usage:
def search_space(_) do
%{
classifier: {:choice, ["SVM", "RandomForest", "XGBoost"]},
# SVM-specific parameters
svm_c: Scout.ConditionalSpace.conditional(
fn params -> params.classifier == "SVM" end,
{:log_uniform, 0.001, 1000}
),
svm_kernel: Scout.ConditionalSpace.conditional(
fn params -> params.classifier == "SVM" end,
{:choice, ["rbf", "linear", "poly"]}
),
# RandomForest-specific parameters
rf_max_depth: Scout.ConditionalSpace.conditional(
fn params -> params.classifier == "RandomForest" end,
{:int, 2, 32}
),
rf_n_estimators: Scout.ConditionalSpace.conditional(
fn params -> params.classifier == "RandomForest" end,
{:int, 10, 200}
),
# XGBoost-specific parameters
xgb_learning_rate: Scout.ConditionalSpace.conditional(
fn params -> params.classifier == "XGBoost" end,
{:log_uniform, 0.01, 0.3}
),
xgb_max_depth: Scout.ConditionalSpace.conditional(
fn params -> params.classifier == "XGBoost" end,
{:int, 3, 10}
)
}
end
"""
@doc """
Define a conditional parameter that only exists when condition is met.
"""
def conditional(condition_fn, param_spec) do
{:conditional, condition_fn, param_spec}
end
@doc """
Check if a parameter value meets its condition.
"""
def is_active?(condition_fn, params) do
condition_fn.(params)
end
@doc """
Filter parameters to only include active ones based on conditions.
"""
def filter_active_params(spec, current_params) do
Enum.reduce(spec, %{}, fn {key, value}, acc ->
case value do
{:conditional, condition_fn, _param_spec} ->
if condition_fn.(current_params) do
Map.put(acc, key, Map.get(current_params, key))
else
acc
end
_ ->
Map.put(acc, key, Map.get(current_params, key))
end
end)
end
end