Current section
Files
Jump to
Current section
Files
lib/sampler/gp.ex
defmodule Scout.Sampler.GP do
@moduledoc """
Gaussian Process (GP) based Bayesian Optimization sampler.
Uses a Gaussian Process surrogate model to predict the objective function
and acquisition functions (EI, UCB, PI) to guide the search.
Equivalent to Optuna's GPSampler and similar to Scikit-Optimize.
"""
@behaviour Scout.Sampler
@default_n_startup_trials 10
@default_acquisition_func :ei # Expected Improvement
@default_kernel :matern52
@default_alpha 1.0e-6 # Noise level
@default_normalize_y true
@default_xi 0.01 # Exploration parameter for EI
@default_kappa 1.96 # Exploration parameter for UCB
def init(opts \\ %{}) do
%{
n_startup_trials: Map.get(opts, :n_startup_trials, @default_n_startup_trials),
acquisition_func: Map.get(opts, :acquisition_func, @default_acquisition_func),
kernel: Map.get(opts, :kernel, @default_kernel),
alpha: Map.get(opts, :alpha, @default_alpha),
normalize_y: Map.get(opts, :normalize_y, @default_normalize_y),
xi: Map.get(opts, :xi, @default_xi),
kappa: Map.get(opts, :kappa, @default_kappa),
length_scales: Map.get(opts, :length_scales, %{}),
gp_model: nil,
seed: Map.get(opts, :seed)
}
end
def next(space_fun, ix, history, state) do
# Set random seed if provided
if state.seed do
:rand.seed(:exsplus, {state.seed, ix, 0})
end
# Get search space
spec = space_fun.(ix)
# Use random sampling for startup trials
if length(history) < state.n_startup_trials do
params = random_sample(spec)
{params, state}
else
# Build or update GP model
state = update_gp_model(state, history, spec)
# Optimize acquisition function to get next point
params = optimize_acquisition(state, spec)
{params, state}
end
end
defp random_sample(spec) do
for {param_name, param_spec} <- spec, into: %{} do
value = 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)
end
{param_name, value}
end
end
defp update_gp_model(state, history, spec) do
# Extract X (parameters) and y (objectives) from history
{x_train, y_train} = prepare_training_data(history, spec)
# Normalize y values if requested
{y_normalized, y_mean, y_std} = if state.normalize_y do
normalize_values(y_train)
else
{y_train, 0.0, 1.0}
end
# Build kernel
kernel = build_kernel(state.kernel, spec, state.length_scales)
# Fit GP model
gp_model = fit_gp(x_train, y_normalized, kernel, state.alpha)
%{state |
gp_model: Map.merge(gp_model, %{
y_mean: y_mean,
y_std: y_std,
x_train: x_train,
y_train: y_train
})
}
end
defp prepare_training_data(history, spec) do
# Convert history to feature matrix and target vector
valid_trials = history
|> Enum.filter(&(&1.score != nil))
|> Enum.take(-100) # Limit to recent trials for efficiency
x_train = valid_trials
|> Enum.map(fn trial ->
encode_parameters(trial.params, spec)
end)
y_train = valid_trials
|> Enum.map(&(&1.score))
{x_train, y_train}
end
defp encode_parameters(params, spec) do
# Convert parameters to numerical vector
spec
|> Enum.map(fn {param_name, param_spec} ->
value = Map.get(params, param_name)
case param_spec do
{:uniform, min, max} ->
# Scale to [0, 1]
(value - min) / (max - min)
{:log_uniform, min, max} ->
# Log scale to [0, 1]
log_min = :math.log(min)
log_max = :math.log(max)
(:math.log(value) - log_min) / (log_max - log_min)
{:int, min, max} ->
# Scale to [0, 1]
(value - min) / (max - min)
{:choice, choices} ->
# One-hot encoding
Enum.find_index(choices, &(&1 == value)) / max(1, length(choices) - 1)
end
end)
end
defp decode_parameters(encoded_values, spec) do
# Convert numerical vector back to parameters
spec
|> Enum.zip(encoded_values)
|> Enum.map(fn {{param_name, param_spec}, encoded_value} ->
value = case param_spec do
{:uniform, min, max} ->
min + encoded_value * (max - min)
{:log_uniform, min, max} ->
log_min = :math.log(min)
log_max = :math.log(max)
:math.exp(log_min + encoded_value * (log_max - log_min))
{:int, min, max} ->
round(min + encoded_value * (max - min))
{:choice, choices} ->
idx = round(encoded_value * (length(choices) - 1))
Enum.at(choices, idx)
end
{param_name, value}
end)
|> Enum.into(%{})
end
defp normalize_values(values) do
mean = Enum.sum(values) / length(values)
variance = values
|> Enum.map(fn v -> :math.pow(v - mean, 2) end)
|> Enum.sum()
|> Kernel./(length(values))
std = :math.sqrt(max(variance, 1.0e-10))
normalized = Enum.map(values, fn v -> (v - mean) / std end)
{normalized, mean, std}
end
defp build_kernel(:rbf, spec, length_scales) do
# Radial Basis Function (Gaussian) kernel
_dim = map_size(spec)
ls = get_length_scales(length_scales, spec, 1.0)
fn x1, x2 ->
dist_sq = x1
|> Enum.zip(x2)
|> Enum.zip(ls)
|> Enum.map(fn {{v1, v2}, l} ->
:math.pow((v1 - v2) / l, 2)
end)
|> Enum.sum()
:math.exp(-0.5 * dist_sq)
end
end
defp build_kernel(:matern52, spec, length_scales) do
# Matérn 5/2 kernel - good default for optimization
_dim = map_size(spec)
ls = get_length_scales(length_scales, spec, 1.0)
fn x1, x2 ->
r = x1
|> Enum.zip(x2)
|> Enum.zip(ls)
|> Enum.map(fn {{v1, v2}, l} ->
abs(v1 - v2) / l
end)
|> Enum.sum()
|> Kernel.*(:math.sqrt(5))
(1 + r + r * r / 3) * :math.exp(-r)
end
end
defp build_kernel(:matern32, spec, length_scales) do
# Matérn 3/2 kernel
_dim = map_size(spec)
ls = get_length_scales(length_scales, spec, 1.0)
fn x1, x2 ->
r = x1
|> Enum.zip(x2)
|> Enum.zip(ls)
|> Enum.map(fn {{v1, v2}, l} ->
abs(v1 - v2) / l
end)
|> Enum.sum()
|> Kernel.*(:math.sqrt(3))
(1 + r) * :math.exp(-r)
end
end
defp get_length_scales(length_scales, spec, default) do
spec
|> Enum.map(fn {param_name, _} ->
Map.get(length_scales, param_name, default)
end)
end
defp fit_gp(x_train, y_train, kernel, alpha) do
# Compute kernel matrix
n = length(x_train)
k_matrix = for i <- 0..(n-1), j <- 0..(n-1) do
x_i = Enum.at(x_train, i)
x_j = Enum.at(x_train, j)
k_val = kernel.(x_i, x_j)
# Add noise to diagonal
if i == j do
k_val + alpha
else
k_val
end
end
|> Enum.chunk_every(n)
# Compute Cholesky decomposition for efficient solving
l_matrix = cholesky(k_matrix)
# Solve L * alpha = y_train
alpha_coeffs = solve_triangular(l_matrix, y_train, :lower)
%{
kernel: kernel,
l_matrix: l_matrix,
alpha_coeffs: alpha_coeffs,
x_train: x_train,
y_train: y_train
}
end
defp predict_gp(gp_model, x_test) do
# Compute kernel vector between test point and training points
k_star = gp_model.x_train
|> Enum.map(fn x_train ->
gp_model.kernel.(x_test, x_train)
end)
# Mean prediction
mean = k_star
|> Enum.zip(gp_model.alpha_coeffs)
|> Enum.map(fn {k, a} -> k * a end)
|> Enum.sum()
# Variance prediction
v = solve_triangular(gp_model.l_matrix, k_star, :lower)
var = 1.0 - (v |> Enum.map(&(&1 * &1)) |> Enum.sum())
std = :math.sqrt(max(var, 1.0e-10))
# Denormalize if needed
mean_denorm = mean * gp_model.y_std + gp_model.y_mean
std_denorm = std * gp_model.y_std
{mean_denorm, std_denorm}
end
defp optimize_acquisition(state, spec) do
# Multi-start optimization of acquisition function
n_restarts = 10
candidates = for _ <- 1..n_restarts do
# Random starting point
x0 = for _ <- 1..map_size(spec), do: :rand.uniform()
# L-BFGS-B optimization (simplified with gradient descent)
optimize_from_point(x0, state, spec)
end
# Select best candidate
best = candidates
|> Enum.max_by(fn x ->
acquisition_value(x, state)
end)
decode_parameters(best, spec)
end
defp optimize_from_point(x0, state, _spec) do
# Simplified gradient-based optimization
learning_rate = 0.1
n_iters = 50
Enum.reduce(1..n_iters, x0, fn _, x ->
# Compute gradient numerically
grad = numerical_gradient(x, state)
# Update with gradient ascent (maximizing acquisition)
x_new = x
|> Enum.zip(grad)
|> Enum.map(fn {xi, gi} ->
# Clip to [0, 1]
max(0, min(1, xi + learning_rate * gi))
end)
x_new
end)
end
defp numerical_gradient(x, state) do
eps = 1.0e-6
x
|> Enum.with_index()
|> Enum.map(fn {_, i} ->
# Forward difference
x_plus = List.update_at(x, i, &(&1 + eps))
x_minus = List.update_at(x, i, &(&1 - eps))
f_plus = acquisition_value(x_plus, state)
f_minus = acquisition_value(x_minus, state)
(f_plus - f_minus) / (2 * eps)
end)
end
defp acquisition_value(x, state) do
{mean, std} = predict_gp(state.gp_model, x)
case state.acquisition_func do
:ei -> expected_improvement(mean, std, state)
:ucb -> upper_confidence_bound(mean, std, state)
:pi -> probability_improvement(mean, std, state)
:lcb -> -upper_confidence_bound(-mean, std, state) # For maximization
end
end
defp expected_improvement(mean, std, state) do
# Current best value (assuming minimization)
f_best = Enum.min(state.gp_model.y_train)
if std < 1.0e-10 do
0.0
else
z = (f_best - mean - state.xi) / std
ei = std * (z * normal_cdf(z) + normal_pdf(z))
ei
end
end
defp upper_confidence_bound(mean, std, state) do
# UCB = mean - kappa * std (for minimization)
-mean + state.kappa * std
end
defp probability_improvement(mean, std, state) do
f_best = Enum.min(state.gp_model.y_train)
if std < 1.0e-10 do
if mean < f_best, do: 1.0, else: 0.0
else
z = (f_best - mean - state.xi) / std
normal_cdf(z)
end
end
# Matrix operations (simplified implementations)
defp cholesky(matrix) do
n = length(matrix)
l = List.duplicate(List.duplicate(0.0, n), n)
Enum.reduce(0..(n-1), l, fn i, l_acc ->
Enum.reduce(0..i, l_acc, fn j, l_acc2 ->
if i == j do
# Diagonal element
sum_sq = if j > 0 do
0..(j-1)
|> Enum.map(fn k ->
elem_at(l_acc2, j, k) * elem_at(l_acc2, j, k)
end)
|> Enum.sum()
else
0.0
end
val = :math.sqrt(elem_at(matrix, j, j) - sum_sq)
set_elem(l_acc2, j, j, val)
else
# Off-diagonal element
sum_prod = if j > 0 do
0..(j-1)
|> Enum.map(fn k ->
elem_at(l_acc2, i, k) * elem_at(l_acc2, j, k)
end)
|> Enum.sum()
else
0.0
end
val = (elem_at(matrix, i, j) - sum_prod) / elem_at(l_acc2, j, j)
set_elem(l_acc2, i, j, val)
end
end)
end)
end
defp solve_triangular(l_matrix, b, :lower) do
n = length(b)
x = List.duplicate(0.0, n)
Enum.reduce(0..(n-1), x, fn i, x_acc ->
sum = if i > 0 do
0..(i-1)
|> Enum.map(fn j ->
elem_at(l_matrix, i, j) * Enum.at(x_acc, j)
end)
|> Enum.sum()
else
0.0
end
val = (Enum.at(b, i) - sum) / elem_at(l_matrix, i, i)
List.replace_at(x_acc, i, val)
end)
end
defp elem_at(matrix, i, j) do
matrix |> Enum.at(i) |> Enum.at(j)
end
defp set_elem(matrix, i, j, value) do
row = Enum.at(matrix, i)
new_row = List.replace_at(row, j, value)
List.replace_at(matrix, i, new_row)
end
defp normal_cdf(z) do
0.5 * (1 + erf(z / :math.sqrt(2)))
end
defp normal_pdf(z) do
:math.exp(-0.5 * z * z) / :math.sqrt(2 * :math.pi())
end
defp erf(x) do
# Approximation of error function
a1 = 0.254829592
a2 = -0.284496736
a3 = 1.421413741
a4 = -1.453152027
a5 = 1.061405429
p = 0.3275911
sign = if x < 0, do: -1, else: 1
x = abs(x)
t = 1.0 / (1.0 + p * x)
y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * :math.exp(-x * x)
sign * y
end
end