Current section
Files
Jump to
Current section
Files
lib/sampler/multivar_tpe.ex
defmodule Scout.Sampler.MultivarTPE do
@behaviour Scout.Sampler
@moduledoc """
Multivariate Tree-structured Parzen Estimator (TPE) sampler.
This implementation models correlations between parameters using
multivariate kernel density estimation, similar to Optuna's
multivariate=True option.
"""
alias Scout.Sampler.RandomSearch
def init(opts) do
%{
gamma: Map.get(opts, :gamma, 0.15),
n_candidates: Map.get(opts, :n_candidates, 24),
min_obs: Map.get(opts, :min_obs, 20),
bw_method: Map.get(opts, :bw_method, :scott),
goal: Map.get(opts, :goal, :maximize),
seed: Map.get(opts, :seed),
# Multivariate-specific options
consider_correlations: Map.get(opts, :consider_correlations, true),
correlation_threshold: Map.get(opts, :correlation_threshold, 0.3)
}
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)
# Extract numeric parameters for multivariate modeling
numeric_keys = extract_numeric_keys(spec)
if length(numeric_keys) < 2 or not state.consider_correlations do
# Fall back to univariate if only one numeric parameter
# or correlations disabled
base_state = Map.drop(state, [:consider_correlations, :correlation_threshold, :bw_method])
Scout.Sampler.TPE.next(space_fun, ix, history, base_state)
else
# Multivariate TPE
sample_multivariate(spec, numeric_keys, history, state, space_fun, ix)
end
end
end
# Extract numeric parameter keys from spec
defp extract_numeric_keys(spec) do
spec
|> Enum.filter(fn {_k, v} ->
case v do
{:uniform, _, _} -> true
{:log_uniform, _, _} -> true
{:int, _, _} -> true
_ -> false
end
end)
|> Enum.map(fn {k, _} -> k end)
end
# Multivariate sampling with correlation modeling
defp sample_multivariate(spec, numeric_keys, history, state, space_fun, ix) do
# Split observations into good and bad
{good_obs, bad_obs} = split_observations(history, state)
# Extract parameter matrices
good_matrix = build_parameter_matrix(good_obs, numeric_keys)
bad_matrix = build_parameter_matrix(bad_obs, numeric_keys)
# Calculate covariance matrices
good_cov = calculate_covariance(good_matrix)
bad_cov = calculate_covariance(bad_matrix)
# Check for significant correlations
correlations = calculate_correlations(good_matrix)
has_correlations = check_significant_correlations(correlations, state.correlation_threshold)
if has_correlations do
# Use multivariate KDE
good_kde = build_multivariate_kde(good_matrix, good_cov, state)
bad_kde = build_multivariate_kde(bad_matrix, bad_cov, state)
# Generate candidates using multivariate sampling
candidates = generate_multivariate_candidates(
spec, numeric_keys, good_kde, state.n_candidates
)
# Score candidates using multivariate EI
best_candidate = select_best_multivariate(
candidates, good_kde, bad_kde, numeric_keys
)
# Add non-numeric parameters
full_params = add_non_numeric_params(best_candidate, spec, numeric_keys)
{full_params, state}
else
# No significant correlations, use univariate
base_state = Map.drop(state, [:consider_correlations, :correlation_threshold, :bw_method])
Scout.Sampler.TPE.next(space_fun, ix, history, base_state)
end
end
# Split observations into good and bad based on scores
defp split_observations(history, state) do
sorted = case state.goal do
:minimize -> Enum.sort_by(history, & &1.score, :asc)
_ -> Enum.sort_by(history, & &1.score, :desc)
end
n_good = max(trunc(state.gamma * length(sorted)), 1)
Enum.split(sorted, n_good)
end
# Build parameter matrix from observations
defp build_parameter_matrix(observations, keys) do
Enum.map(observations, fn obs ->
Enum.map(keys, fn k ->
normalize_value(obs.params[k], k)
end)
end)
end
# Normalize values for correlation calculation
defp normalize_value(value, _key) when is_number(value), do: value
defp normalize_value(value, _key) when is_integer(value), do: value * 1.0
defp normalize_value(_, _), do: 0.0
# Calculate covariance matrix
defp calculate_covariance(matrix) when length(matrix) < 2 do
# Return identity matrix for small samples
dim = length(hd(matrix))
Enum.map(1..dim, fn i ->
Enum.map(1..dim, fn j ->
if i == j, do: 1.0, else: 0.0
end)
end)
end
defp calculate_covariance(matrix) do
n = length(matrix)
dim = length(hd(matrix))
# Calculate means
means = Enum.map(0..(dim-1), fn i ->
sum = Enum.reduce(matrix, 0.0, fn row, acc ->
acc + Enum.at(row, i)
end)
sum / n
end)
# Calculate covariance
Enum.map(0..(dim-1), fn i ->
Enum.map(0..(dim-1), fn j ->
sum = Enum.reduce(matrix, 0.0, fn row, acc ->
xi = Enum.at(row, i) - Enum.at(means, i)
xj = Enum.at(row, j) - Enum.at(means, j)
acc + xi * xj
end)
sum / (n - 1)
end)
end)
end
# Calculate correlation matrix
defp calculate_correlations(matrix) do
cov = calculate_covariance(matrix)
dim = length(cov)
# Get standard deviations
stds = Enum.map(0..(dim-1), fn i ->
:math.sqrt(Enum.at(Enum.at(cov, i), i))
end)
# Calculate correlations
Enum.map(0..(dim-1), fn i ->
Enum.map(0..(dim-1), fn j ->
if Enum.at(stds, i) > 0 and Enum.at(stds, j) > 0 do
Enum.at(Enum.at(cov, i), j) / (Enum.at(stds, i) * Enum.at(stds, j))
else
if i == j, do: 1.0, else: 0.0
end
end)
end)
end
# Check if there are significant correlations
defp check_significant_correlations(corr_matrix, threshold) do
dim = length(corr_matrix)
Enum.any?(0..(dim-1), fn i ->
Enum.any?(0..(dim-1), fn j ->
i != j and abs(Enum.at(Enum.at(corr_matrix, i), j)) > threshold
end)
end)
end
# Build multivariate KDE
defp build_multivariate_kde(matrix, covariance, state) do
bandwidth = calculate_bandwidth(matrix, covariance, state)
%{
data: matrix,
covariance: covariance,
bandwidth: bandwidth,
inv_cov: matrix_inverse(scale_matrix(covariance, bandwidth))
}
end
# Calculate bandwidth using Scott's rule or Silverman's rule
defp calculate_bandwidth(matrix, _covariance, state) do
n = length(matrix)
d = length(hd(matrix))
case state.bw_method do
:scott ->
:math.pow(n, -1.0 / (d + 4))
:silverman ->
:math.pow(n * (d + 2) / 4, -1.0 / (d + 4))
_ ->
1.0
end
end
# Scale matrix by bandwidth
defp scale_matrix(matrix, scalar) do
Enum.map(matrix, fn row ->
Enum.map(row, fn val -> val * scalar * scalar end)
end)
end
# Simple matrix inverse for 2x2 (extend for larger)
defp matrix_inverse(matrix) when length(matrix) == 2 do
[[a, b], [c, d]] = matrix
det = a * d - b * c
if abs(det) < 1.0e-10 do
# Singular matrix, return identity
[[1.0, 0.0], [0.0, 1.0]]
else
[[d/det, -b/det], [-c/det, a/det]]
end
end
defp matrix_inverse(matrix) do
# For larger matrices, use identity as fallback
# In production, implement proper matrix inversion
dim = length(matrix)
Enum.map(0..(dim-1), fn i ->
Enum.map(0..(dim-1), fn j ->
if i == j, do: 1.0, else: 0.0
end)
end)
end
# Generate multivariate candidates
defp generate_multivariate_candidates(spec, keys, kde, n_candidates) do
Enum.map(1..n_candidates, fn _ ->
# Sample from multivariate KDE
sample_point = sample_from_kde(kde)
# Convert to parameter map
Enum.zip(keys, sample_point)
|> Enum.into(%{})
|> apply_bounds(spec, keys)
end)
end
# Sample from multivariate KDE
defp sample_from_kde(kde) do
# Select a random data point
base_point = Enum.random(kde.data)
dim = length(base_point)
# Add Gaussian noise based on bandwidth
Enum.map(base_point, fn val ->
val + :rand.normal() * kde.bandwidth
end)
end
# Apply parameter bounds
defp apply_bounds(params, spec, keys) do
Enum.reduce(keys, params, fn k, acc ->
case spec[k] do
{:uniform, min, max} ->
Map.put(acc, k, clamp(acc[k], min, max))
{:log_uniform, min, max} ->
val = clamp(acc[k], :math.log(min), :math.log(max))
Map.put(acc, k, :math.exp(val))
{:int, min, max} ->
Map.put(acc, k, round(clamp(acc[k], min, max)))
_ ->
acc
end
end)
end
defp clamp(x, min, max) when x < min, do: min
defp clamp(x, min, max) when x > max, do: max
defp clamp(x, _, _), do: x
# Select best candidate using multivariate EI
defp select_best_multivariate(candidates, good_kde, bad_kde, _keys) do
scored = Enum.map(candidates, fn cand ->
score = multivariate_ei_score(cand, good_kde, bad_kde)
{cand, score}
end)
{best, _} = Enum.max_by(scored, fn {_, s} -> s end)
best
end
# Calculate multivariate EI score
defp multivariate_ei_score(candidate, good_kde, bad_kde) do
point = Map.values(candidate)
# Calculate multivariate PDF for good and bad distributions
pg = multivariate_pdf(point, good_kde)
pb = multivariate_pdf(point, bad_kde)
# Return log ratio for numerical stability
:math.log(max(pg, 1.0e-12) / max(pb, 1.0e-12))
end
# Calculate multivariate PDF
defp multivariate_pdf(point, kde) do
n = length(kde.data)
dim = length(point)
# Sum over all kernel centers
sum = Enum.reduce(kde.data, 0.0, fn center, acc ->
diff = Enum.zip(point, center) |> Enum.map(fn {p, c} -> p - c end)
# Mahalanobis distance (simplified for diagonal covariance)
dist_sq = Enum.zip(diff, diff) |> Enum.reduce(0.0, fn {d, _}, acc ->
acc + d * d / (kde.bandwidth * kde.bandwidth)
end)
# Gaussian kernel
acc + :math.exp(-0.5 * dist_sq)
end)
# Normalize
normalizer = :math.pow(2 * :math.pi() * kde.bandwidth * kde.bandwidth, dim / 2)
sum / (n * normalizer)
end
# Add non-numeric parameters
defp add_non_numeric_params(params, spec, numeric_keys) do
non_numeric = Enum.reject(spec, fn {k, _} -> k in numeric_keys end)
Enum.reduce(non_numeric, params, fn {k, v}, acc ->
case v do
{:choice, choices} ->
Map.put(acc, k, Enum.random(choices))
_ ->
acc
end
end)
end
end