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

lib/sampler/tpe_multivariate.ex

defmodule Scout.Sampler.TPEMultivariate do
@moduledoc """
Production-ready multivariate TPE implementation for Scout.
Achieves parity with Optuna by properly handling parameter correlations
using Gaussian copula approach. This should replace the standard TPE
for problems with correlated parameters.
"""
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),
bandwidth_factor: Map.get(opts, :bandwidth_factor, 1.06), # Scott's rule
multivariate: Map.get(opts, :multivariate, true), # Enable by default
correlation_threshold: Map.get(opts, :correlation_threshold, 0.1)
}
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()
# Split history by performance
{good_trials, bad_trials} = split_by_performance(history, state)
if state.multivariate and length(param_keys) > 1 do
# Use multivariate approach for correlated parameters
multivariate_sample(good_trials, bad_trials, param_keys, spec, state)
else
# Fall back to univariate for single parameter or disabled multivariate
univariate_sample(good_trials, bad_trials, param_keys, spec, state)
end
end
end
# Multivariate sampling with correlation modeling
defp multivariate_sample(good_trials, bad_trials, param_keys, spec, state) do
# Build copula models
good_copula = build_copula_model(good_trials, param_keys, spec)
bad_copula = build_copula_model(bad_trials, param_keys, spec)
# Generate candidates mixing copula and random sampling
candidates = for i <- 1..state.n_candidates do
cond do
# 70% from good copula if available
i <= round(state.n_candidates * 0.7) and good_copula != nil ->
sample_from_copula(good_copula, param_keys, spec)
# 20% from bad copula for diversity
i <= round(state.n_candidates * 0.9) and bad_copula != nil ->
sample_from_copula(bad_copula, param_keys, spec)
# 10% random exploration
true ->
Scout.SearchSpace.sample(spec)
end
end
# Select best using EI
best = select_best_ei(candidates, good_trials, bad_trials, param_keys, state.bandwidth_factor)
{best, state}
end
# Univariate sampling (original TPE approach)
defp univariate_sample(good_trials, bad_trials, param_keys, spec, state) do
candidates = for _ <- 1..state.n_candidates do
Scout.SearchSpace.sample(spec)
end
best = select_best_ei(candidates, good_trials, bad_trials, param_keys, state.bandwidth_factor)
{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_model(trials, param_keys, spec) do
if length(trials) < 2 do
nil
else
# Convert to uniform marginals
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)
# Compute correlation matrix
corr_matrix = compute_correlation_matrix(uniform_data)
%{
data: uniform_data,
corr: corr_matrix,
n: length(param_keys),
n_samples: length(trials)
}
end
end
defp to_uniform(val, spec_entry) do
case spec_entry do
{:uniform, min, max} ->
clamped = max(min, min(max, val))
(clamped - min) / (max - min + 1.0e-10)
{:log_uniform, min, max} ->
clamped = max(min, min(max, val))
log_min = :math.log(min)
log_max = :math.log(max)
(:math.log(clamped) - log_min) / (log_max - log_min + 1.0e-10)
{:int, min, max} ->
clamped = max(min, min(max, round(val)))
(clamped - min) / (max - min + 1)
{:categorical, choices} ->
# For categorical, use index
idx = Enum.find_index(choices, & &1 == val) || 0
idx / max(1, length(choices) - 1)
_ -> 0.5
end
end
defp from_uniform(u, spec_entry) do
# Clamp to valid range
u = max(0.001, min(0.999, u))
case spec_entry do
{:uniform, min, max} ->
min + u * (max - min)
{:log_uniform, min, max} ->
log_min = :math.log(min)
log_max = :math.log(max)
:math.exp(log_min + u * (log_max - log_min))
{:int, min, max} ->
round(min + u * (max - min))
{:categorical, choices} ->
idx = round(u * (length(choices) - 1))
Enum.at(choices, idx)
_ -> u
end
end
defp compute_correlation_matrix(data) do
n = length(hd(data))
m = length(data)
if m < 3 do
# Identity matrix for insufficient data
for i <- 0..(n-1) do
for j <- 0..(n-1) do
if i == j, do: 1.0, else: 0.0
end
end
else
# Compute means
means = for i <- 0..(n-1) do
Enum.sum(Enum.map(data, fn row -> Enum.at(row, i) end)) / m
end
# Compute correlation matrix
for i <- 0..(n-1) do
for j <- 0..(n-1) do
if i == j do
1.0
else
xi_vals = Enum.map(data, fn row -> Enum.at(row, i) end)
xj_vals = Enum.map(data, fn row -> Enum.at(row, j) end)
compute_correlation(xi_vals, xj_vals, Enum.at(means, i), Enum.at(means, j), m)
end
end
end
end
end
defp compute_correlation(x_vals, y_vals, x_mean, y_mean, n) do
cov = Enum.zip(x_vals, y_vals)
|> Enum.map(fn {x, y} -> (x - x_mean) * (y - y_mean) end)
|> Enum.sum()
|> Kernel./(n - 1)
x_std = :math.sqrt(
Enum.map(x_vals, fn x -> :math.pow(x - x_mean, 2) end)
|> Enum.sum()
|> Kernel./(n - 1)
)
y_std = :math.sqrt(
Enum.map(y_vals, fn y -> :math.pow(y - y_mean, 2) end)
|> Enum.sum()
|> Kernel./(n - 1)
)
if x_std * y_std > 1.0e-10 do
max(-1.0, min(1.0, cov / (x_std * y_std)))
else
0.0
end
end
defp sample_from_copula(copula, param_keys, spec) do
n = copula.n
# Generate independent standard normals
z = List.duplicate(0, n) |> Enum.map(fn _ -> :rand.normal() end)
# Apply correlation structure
correlated = apply_correlation(z, copula.corr, n)
# Transform to uniform via normal CDF
uniform = Enum.map(correlated, fn z_val ->
# Standard normal CDF approximation
0.5 * (1 + :math.erf(z_val / :math.sqrt(2)))
end)
# Transform back to parameter space
Enum.zip(param_keys, uniform)
|> Map.new(fn {k, u} ->
{k, from_uniform(u, spec[k])}
end)
end
defp apply_correlation(z, corr_matrix, n) do
case n do
1 ->
z
2 ->
# Special case for 2D - exact correlation
r = corr_matrix |> Enum.at(0) |> Enum.at(1)
z1 = Enum.at(z, 0)
z2 = Enum.at(z, 1)
[z1, r * z1 + :math.sqrt(max(0, 1 - r*r)) * z2]
_ ->
# For higher dimensions, use simplified approach
# Apply average correlation to maintain dependencies
avg_corr = compute_avg_correlation(corr_matrix, n)
if abs(avg_corr) < 0.1 do
# Low correlation - return independent
z
else
# Mix components based on average correlation
z_mean = Enum.sum(z) / n
Enum.map(z, fn zi ->
(1 - abs(avg_corr)) * zi + avg_corr * z_mean
end)
end
end
end
defp compute_avg_correlation(corr_matrix, n) do
# Average of off-diagonal elements
sum = for i <- 0..(n-1), j <- 0..(n-1), i != j do
corr_matrix |> Enum.at(i) |> Enum.at(j)
end
|> Enum.sum()
if n > 1 do
sum / (n * (n - 1))
else
0.0
end
end
defp select_best_ei(candidates, good_trials, bad_trials, param_keys, bandwidth) do
scored = Enum.map(candidates, fn cand ->
good_score = kde_likelihood(cand, good_trials, param_keys, bandwidth)
bad_score = kde_likelihood(cand, bad_trials, param_keys, bandwidth)
# Expected Improvement calculation
ei = if bad_score > 1.0e-10 do
good_score / bad_score
else
good_score * 1000 # High reward if not in bad region
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)
# Scott's rule adjustment for dimensionality
h = bandwidth * :math.pow(length(trials), -1.0/(n_dims + 4))
scores = Enum.map(trials, fn trial ->
# Compute normalized distance
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
# Get scale for normalization
scale = estimate_scale(k, trials)
if scale > 0 do
diff = (v1 - v2) / scale
acc + diff * diff
else
acc
end
end)
# Multivariate Gaussian kernel
:math.exp(-dist_sq / (2 * h * h))
end)
# Return average likelihood
max(1.0e-10, Enum.sum(scores) / length(scores))
end
end
defp estimate_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
min_val = Enum.min(values)
max_val = Enum.max(values)
range = max_val - min_val
if range > 0 do
range
else
# Use standard deviation if no range
mean = Enum.sum(values) / length(values)
std = :math.sqrt(
Enum.map(values, fn v -> :math.pow(v - mean, 2) end)
|> Enum.sum()
|> Kernel./(length(values))
)
max(std, 1.0)
end
else
1.0
end
end
end