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

lib/sampler/tpe_integrated.ex

defmodule Scout.Sampler.TPEIntegrated do
@behaviour Scout.Sampler
@moduledoc """
Integrated TPE that automatically selects univariate or multivariate
based on the search space and configuration.
This should replace the default TPE in production.
"""
alias Scout.Sampler.RandomSearch
def init(opts) do
%{
gamma: Map.get(opts, :gamma, 0.25),
n_candidates: Map.get(opts, :n_candidates, 24),
min_obs: Map.get(opts, :min_obs, 10),
goal: Map.get(opts, :goal, :minimize),
multivariate: Map.get(opts, :multivariate, :auto), # auto, true, or false
bandwidth_factor: Map.get(opts, :bandwidth_factor, 1.06)
}
end
def next(space_fun, ix, history, state) do
if length(history) < state.min_obs do
RandomSearch.next(space_fun, ix, history, state)
else
spec = space_fun.(ix)
param_keys = Map.keys(spec) |> Enum.sort()
# Decide whether to use multivariate
use_multivariate = case state.multivariate do
:auto -> should_use_multivariate?(param_keys, spec)
true -> true
false -> false
end
if use_multivariate and length(param_keys) > 1 do
# Use multivariate approach
multivariate_sample(history, param_keys, spec, state)
else
# Use univariate approach
univariate_sample(history, param_keys, spec, state)
end
end
end
defp should_use_multivariate?(param_keys, spec) do
# Use multivariate if:
# 1. Multiple numeric parameters exist
# 2. Parameters are likely correlated (similar ranges)
numeric_count = Enum.count(param_keys, fn k ->
case spec[k] do
{:uniform, _, _} -> true
{:log_uniform, _, _} -> true
{:int, _, _} -> true
_ -> false
end
end)
numeric_count > 1
end
defp multivariate_sample(history, param_keys, spec, state) do
# Split history by performance
{good_trials, bad_trials} = split_by_performance(history, state)
# Build copula models
good_copula = build_copula(good_trials, param_keys, spec)
bad_copula = build_copula(bad_trials, param_keys, spec)
# Generate candidates
candidates = for i <- 1..state.n_candidates do
cond do
# 70% from good copula
i <= round(state.n_candidates * 0.7) and good_copula != nil ->
sample_from_copula(good_copula, param_keys, spec)
# 20% from bad copula
i <= round(state.n_candidates * 0.9) and bad_copula != nil ->
sample_from_copula(bad_copula, param_keys, spec)
# 10% random
true ->
Scout.SearchSpace.sample(spec)
end
end
# Select best using EI
best = select_best_ei(candidates, good_trials, bad_trials, param_keys, state)
{best, state}
end
defp univariate_sample(history, param_keys, spec, state) do
# Simple univariate sampling
candidates = for _ <- 1..state.n_candidates do
Scout.SearchSpace.sample(spec)
end
{good_trials, bad_trials} = split_by_performance(history, state)
best = select_best_ei(candidates, good_trials, bad_trials, param_keys, state)
{best, state}
end
defp split_by_performance(history, state) do
sorted = case state.goal do
:minimize -> Enum.sort_by(history, & &1.score)
_ -> Enum.sort_by(history, & &1.score, :desc)
end
n_good = max(1, round(length(sorted) * state.gamma))
Enum.split(sorted, n_good)
end
defp build_copula(trials, param_keys, spec) do
if length(trials) < 2 do
nil
else
uniform_data = Enum.map(trials, fn trial ->
Enum.map(param_keys, fn k ->
val = Map.get(trial.params, k, 0.0) || 0.0
to_uniform(val, spec[k])
end)
end)
corr = compute_correlation_matrix(uniform_data)
%{
data: uniform_data,
corr: corr,
n_params: length(param_keys)
}
end
end
defp to_uniform(val, spec_entry) do
case spec_entry do
{:uniform, min, max} ->
(max(min, min(max, val)) - min) / (max - min + 1.0e-10)
{:log_uniform, min, max} ->
log_val = :math.log(max(min, min(max, val)))
(:math.log(val) - :math.log(min)) / (:math.log(max) - :math.log(min) + 1.0e-10)
{:int, min, max} ->
(max(min, min(max, round(val))) - min) / (max - min + 1)
_ -> 0.5
end
end
defp from_uniform(u, spec_entry) do
u = max(0.001, min(0.999, u))
case spec_entry do
{:uniform, min, max} ->
min + u * (max - min)
{:log_uniform, min, max} ->
:math.exp(:math.log(min) + u * (:math.log(max) - :math.log(min)))
{:int, min, max} ->
round(min + u * (max - min))
_ -> u
end
end
defp compute_correlation_matrix(data) do
n_dims = length(hd(data))
n_samples = length(data)
if n_samples < 3 do
# Identity matrix
for i <- 0..(n_dims-1), do: (for j <- 0..(n_dims-1), do: (if i == j, do: 1.0, else: 0.0))
else
means = for i <- 0..(n_dims-1) do
col = Enum.map(data, fn row -> Enum.at(row, i) end)
Enum.sum(col) / n_samples
end
for i <- 0..(n_dims-1) do
for j <- 0..(n_dims-1) do
if i == j do
1.0
else
col_i = Enum.map(data, fn row -> Enum.at(row, i) end)
col_j = Enum.map(data, fn row -> Enum.at(row, j) end)
compute_correlation(col_i, col_j, Enum.at(means, i), Enum.at(means, j))
end
end
end
end
end
defp compute_correlation(col_i, col_j, mean_i, mean_j) do
n = length(col_i)
cov = Enum.zip(col_i, col_j)
|> Enum.map(fn {xi, xj} -> (xi - mean_i) * (xj - mean_j) end)
|> Enum.sum()
|> Kernel./(n - 1)
std_i = :math.sqrt(
Enum.map(col_i, fn x -> :math.pow(x - mean_i, 2) end)
|> Enum.sum()
|> Kernel./(n - 1)
)
std_j = :math.sqrt(
Enum.map(col_j, fn x -> :math.pow(x - mean_j, 2) end)
|> Enum.sum()
|> Kernel./(n - 1)
)
if std_i * std_j > 1.0e-10 do
max(-1.0, min(1.0, cov / (std_i * std_j)))
else
0.0
end
end
defp sample_from_copula(copula, param_keys, spec) do
n = copula.n_params
# Generate correlated samples
z = for _ <- 1..n, do: :rand.normal()
correlated = case n do
2 ->
# 2D case with exact correlation
r = copula.corr |> Enum.at(0) |> Enum.at(1)
[z1, z2] = z
[z1, r * z1 + :math.sqrt(max(0, 1 - r*r)) * z2]
_ ->
# Higher dimensions
z
end
# Transform to uniform
uniform = Enum.map(correlated, fn z_val ->
0.5 * (1 + :math.erf(z_val / :math.sqrt(2)))
end)
# Map to parameter space
Enum.zip(param_keys, uniform)
|> Map.new(fn {k, u} -> {k, from_uniform(u, spec[k])} end)
end
defp select_best_ei(candidates, good_trials, bad_trials, param_keys, state) do
scored = Enum.map(candidates, fn cand ->
good_score = kde_likelihood(cand, good_trials, param_keys, state.bandwidth_factor)
bad_score = kde_likelihood(cand, bad_trials, param_keys, state.bandwidth_factor)
ei = if bad_score > 1.0e-10 do
good_score / bad_score
else
good_score * 1000
end
{cand, ei}
end)
{best, _} = Enum.max_by(scored, fn {_, score} -> score end)
best
end
defp kde_likelihood(params, trials, param_keys, bandwidth) do
if trials == [] do
1.0e-10
else
n_dims = length(param_keys)
h = bandwidth * :math.pow(length(trials), -1.0/(n_dims + 4))
scores = Enum.map(trials, fn trial ->
dist_sq = Enum.reduce(param_keys, 0.0, fn k, acc ->
v1 = params[k] || 0.0
v2 = Map.get(trial.params, k, 0.0) || 0.0
scale = get_scale(k, trials)
diff = if scale > 0, do: (v1 - v2) / scale, else: 0
acc + diff * diff
end)
:math.exp(-dist_sq / (2 * h * h))
end)
max(1.0e-10, Enum.sum(scores) / length(scores))
end
end
defp get_scale(param_key, trials) do
values = Enum.map(trials, fn t ->
Map.get(t.params, param_key, 0.0) || 0.0
end)
if length(values) > 1 do
range = Enum.max(values) - Enum.min(values)
if range > 0, do: range, else: 1.0
else
1.0
end
end
end