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

lib/sampler/motpe.ex

defmodule Scout.Sampler.MOTPE do
@behaviour Scout.Sampler
@moduledoc """
Multi-objective Tree-structured Parzen Estimator (MOTPE) sampler.
This implementation handles multi-objective optimization where multiple
objectives need to be optimized simultaneously. Similar to Optuna's
MOTPESampler.
The sampler uses Pareto dominance to determine which trials are "good"
and which are "bad" for building the TPE models.
"""
def init(opts) do
base_state = Scout.Sampler.TPE.init(opts)
Map.merge(base_state, %{
# Number of objectives to optimize
n_objectives: Map.get(opts, :n_objectives, 2),
# Reference point for hypervolume calculation (optional)
reference_point: Map.get(opts, :reference_point),
# Whether to use hypervolume or Pareto dominance
use_hypervolume: Map.get(opts, :use_hypervolume, false),
# Weights for objectives (for weighted sum approach)
objective_weights: Map.get(opts, :objective_weights),
# Scalarization method: "pareto", "weighted_sum", "chebyshev"
scalarization: Map.get(opts, :scalarization, "pareto")
})
end
def next(space_fun, ix, history, state) do
spec = space_fun.(ix)
if length(history) < state.min_obs do
# Not enough observations, use random sampling
Scout.Sampler.RandomSearch.next(space_fun, ix, history, state)
else
# Use multi-objective TPE
sample_motpe(spec, history, state)
end
end
# Sample using multi-objective TPE
defp sample_motpe(spec, history, state) do
# Convert multi-objective scores based on scalarization method
scalarized_history = case state.scalarization do
"pareto" ->
# Use Pareto dominance to classify good/bad
classify_by_pareto(history, state.n_objectives)
"weighted_sum" ->
# Use weighted sum of objectives
scalarize_weighted_sum(history, state.objective_weights || default_weights(state.n_objectives))
"chebyshev" ->
# Use Chebyshev scalarization
scalarize_chebyshev(history, state.reference_point || default_reference(state.n_objectives))
_ ->
# Default to Pareto
classify_by_pareto(history, state.n_objectives)
end
# Use regular TPE with scalarized history
tpe_state = Map.drop(state, [:n_objectives, :reference_point, :use_hypervolume,
:objective_weights, :scalarization])
{params, _} = Scout.Sampler.TPE.next(fn _ -> spec end, 0, scalarized_history, tpe_state)
{params, state}
end
# Classify trials using Pareto dominance
defp classify_by_pareto(history, n_objectives) do
# Extract multi-objective scores
trials_with_scores = Enum.filter(history, fn trial ->
is_map(trial.score) and map_size(trial.score) == n_objectives
end)
if trials_with_scores == [] do
history
else
# Compute Pareto front
pareto_front = compute_pareto_front(trials_with_scores)
# Convert to single-objective scores
# Pareto optimal solutions get good scores, dominated get bad scores
Enum.map(history, fn trial ->
if trial in pareto_front do
# Assign good score (negative distance to ideal point)
ideal_point = compute_ideal_point(trials_with_scores)
score = -distance_to_point(trial.score, ideal_point)
%{trial | score: score}
else
# Assign bad score (positive distance to nadir point)
nadir_point = compute_nadir_point(trials_with_scores)
score = distance_to_point(trial.score, nadir_point)
%{trial | score: score}
end
end)
end
end
# Compute Pareto front
defp compute_pareto_front(trials) do
Enum.filter(trials, fn trial ->
not Enum.any?(trials, fn other ->
other != trial and dominates?(other.score, trial.score)
end)
end)
end
# Check if solution a dominates solution b
defp dominates?(a, b) when is_map(a) and is_map(b) do
# Assuming minimization for all objectives
keys = Map.keys(a)
at_least_one_better = Enum.any?(keys, fn k ->
Map.get(a, k, 0) < Map.get(b, k, 0)
end)
none_worse = Enum.all?(keys, fn k ->
Map.get(a, k, 0) <= Map.get(b, k, 0)
end)
at_least_one_better and none_worse
end
defp dominates?(_, _), do: false
# Compute ideal point (best value for each objective)
defp compute_ideal_point(trials) do
objectives = trials
|> List.first()
|> Map.get(:score)
|> Map.keys()
Enum.reduce(objectives, %{}, fn obj, acc ->
best = trials
|> Enum.map(fn t -> Map.get(t.score, obj, 0) end)
|> Enum.min()
Map.put(acc, obj, best)
end)
end
# Compute nadir point (worst value for each objective)
defp compute_nadir_point(trials) do
objectives = trials
|> List.first()
|> Map.get(:score)
|> Map.keys()
Enum.reduce(objectives, %{}, fn obj, acc ->
worst = trials
|> Enum.map(fn t -> Map.get(t.score, obj, 0) end)
|> Enum.max()
Map.put(acc, obj, worst)
end)
end
# Calculate Euclidean distance to a point
defp distance_to_point(scores, point) when is_map(scores) and is_map(point) do
keys = Map.keys(point)
sum_sq = Enum.reduce(keys, 0.0, fn k, acc ->
diff = Map.get(scores, k, 0) - Map.get(point, k, 0)
acc + diff * diff
end)
:math.sqrt(sum_sq)
end
defp distance_to_point(_, _), do: 0.0
# Scalarize using weighted sum
defp scalarize_weighted_sum(history, weights) do
Enum.map(history, fn trial ->
if is_map(trial.score) do
score = Enum.reduce(weights, 0.0, fn {obj, weight}, acc ->
acc + weight * Map.get(trial.score, obj, 0)
end)
%{trial | score: score}
else
trial
end
end)
end
# Scalarize using Chebyshev method
defp scalarize_chebyshev(history, reference_point) do
Enum.map(history, fn trial ->
if is_map(trial.score) do
# Chebyshev distance: max of weighted differences
score = reference_point
|> Enum.map(fn {obj, ref} ->
abs(Map.get(trial.score, obj, 0) - ref)
end)
|> Enum.max()
%{trial | score: score}
else
trial
end
end)
end
# Default weights (equal for all objectives)
defp default_weights(n_objectives) do
weight = 1.0 / n_objectives
for i <- 0..(n_objectives - 1), into: %{} do
{:"obj_#{i}", weight}
end
end
# Default reference point (zeros)
defp default_reference(n_objectives) do
for i <- 0..(n_objectives - 1), into: %{} do
{:"obj_#{i}", 0.0}
end
end
@doc """
Calculate hypervolume indicator for a set of solutions.
This is useful for comparing the quality of Pareto fronts.
"""
def hypervolume(solutions, reference_point) do
# Simplified 2D hypervolume calculation
# For production, use a proper hypervolume algorithm
if map_size(reference_point) != 2 do
# Only support 2D for now
0.0
else
sorted = Enum.sort_by(solutions, fn sol ->
Map.get(sol, :obj_0, 0)
end)
hv = 0.0
prev_x = 0.0
{hv, _} = Enum.reduce(sorted, {hv, prev_x}, fn sol, {acc_hv, prev} ->
x = Map.get(sol, :obj_0, 0)
y = Map.get(sol, :obj_1, 0)
ref_x = Map.get(reference_point, :obj_0, 0)
ref_y = Map.get(reference_point, :obj_1, 0)
if x < ref_x and y < ref_y do
area = (ref_x - x) * (ref_y - y)
new_hv = acc_hv + area
{new_hv, x}
else
{acc_hv, prev}
end
end)
hv
end
end
@doc """
Convert single-objective trial to multi-objective format.
"""
def to_multi_objective(trial, objective_extractors) do
scores = Enum.reduce(objective_extractors, %{}, fn {name, extractor}, acc ->
Map.put(acc, name, extractor.(trial))
end)
%{trial | score: scores}
end
end