Current section
Files
Jump to
Current section
Files
lib/pruner/wilcoxon.ex
defmodule Scout.Pruner.WilcoxonPruner do
@moduledoc """
Prunes trials using the Wilcoxon signed-rank test for statistical significance.
Equivalent to Optuna's WilcoxonPruner. Uses non-parametric statistical testing
to determine if a trial is significantly worse than the best trial.
"""
@behaviour Scout.Pruner
# Default implementation for missing callbacks
def assign_bracket(_trial_index, state), do: {0, state}
def keep?(_study_id, _trial_id, _bracket, _step, state), do: {true, state}
@default_p_threshold 0.1
@default_n_startup_trials 10
@default_n_min_trials 4
def init(opts \\ %{}) do
%{
p_threshold: Map.get(opts, :p_threshold, @default_p_threshold),
n_startup_trials: Map.get(opts, :n_startup_trials, @default_n_startup_trials),
n_min_trials: Map.get(opts, :n_min_trials, @default_n_min_trials)
}
end
def should_prune?(study_id, _trial_id, step, value, state) do
# Get completed trials
completed_trials = Scout.Store.list_trials(study_id)
|> Enum.filter(&(&1.status == :completed))
# Don't prune if not enough startup trials
if length(completed_trials) < state.n_startup_trials do
{false, state}
else
# Find the best completed trial
best_trial = find_best_trial(completed_trials)
if best_trial == nil do
{false, state}
else
# Get intermediate values for best trial up to current step
best_values = get_intermediate_values_up_to(best_trial, step)
# Get intermediate values for current trial up to current step
current_trial = %{intermediate_values: %{step => value}}
current_values = get_intermediate_values_up_to(current_trial, step)
# Need minimum number of observations for statistical test
if length(best_values) < state.n_min_trials or length(current_values) < state.n_min_trials do
{false, state}
else
# Perform Wilcoxon signed-rank test
p_value = wilcoxon_signed_rank_test(current_values, best_values)
# Prune if current is significantly worse than best
should_prune = p_value < state.p_threshold and mean(current_values) > mean(best_values)
{should_prune, state}
end
end
end
end
defp find_best_trial(trials) do
trials
|> Enum.filter(&(&1.score != nil))
|> Enum.min_by(&(&1.score), fn -> nil end)
end
defp get_intermediate_values_up_to(trial, max_step) do
trial.intermediate_values
|> Enum.filter(fn {step, _value} -> step <= max_step end)
|> Enum.sort_by(&elem(&1, 0))
|> Enum.map(&elem(&1, 1))
end
defp mean(values) do
if length(values) == 0 do
0
else
Enum.sum(values) / length(values)
end
end
@doc """
Implements the Wilcoxon signed-rank test.
Returns p-value for the null hypothesis that the two samples come from
the same distribution.
"""
def wilcoxon_signed_rank_test(sample1, sample2) when length(sample1) != length(sample2) do
# Samples must have same length for paired test
# Pad shorter sample with its mean
n1 = length(sample1)
n2 = length(sample2)
{s1, s2} = cond do
n1 < n2 ->
mean1 = mean(sample1)
padded = sample1 ++ List.duplicate(mean1, n2 - n1)
{padded, sample2}
n1 > n2 ->
mean2 = mean(sample2)
padded = sample2 ++ List.duplicate(mean2, n1 - n2)
{sample1, padded}
true ->
{sample1, sample2}
end
wilcoxon_signed_rank_test_impl(s1, s2)
end
def wilcoxon_signed_rank_test(sample1, sample2) do
wilcoxon_signed_rank_test_impl(sample1, sample2)
end
defp wilcoxon_signed_rank_test_impl(sample1, sample2) do
# Calculate differences
differences = Enum.zip(sample1, sample2)
|> Enum.map(fn {x, y} -> x - y end)
|> Enum.filter(&(&1 != 0)) # Remove zero differences
n = length(differences)
if n == 0 do
# No differences, samples are identical
1.0
else
# Rank absolute differences
ranked = differences
|> Enum.map(&{&1, abs(&1)})
|> Enum.sort_by(&elem(&1, 1))
|> Enum.with_index(1)
|> Enum.map(fn {{diff, _abs}, rank} -> {diff, rank} end)
# Handle ties by averaging ranks
ranked_with_ties = handle_ties(ranked)
# Calculate W+ (sum of positive ranks) and W- (sum of negative ranks)
{w_plus, w_minus} = ranked_with_ties
|> Enum.reduce({0, 0}, fn {diff, rank}, {wp, wm} ->
if diff > 0 do
{wp + rank, wm}
else
{wp, wm + rank}
end
end)
# Use smaller of W+ and W-
w = min(w_plus, w_minus)
# Calculate z-score for normal approximation (valid for n > 10)
if n > 10 do
# Normal approximation
mean_w = n * (n + 1) / 4
var_w = n * (n + 1) * (2 * n + 1) / 24
# Continuity correction
z = (w + 0.5 - mean_w) / :math.sqrt(var_w)
# Two-tailed p-value using normal CDF approximation
p_value = 2 * normal_cdf(-abs(z))
p_value
else
# For small samples, use exact critical values
# This is a simplified approximation
critical_value = get_wilcoxon_critical_value(n)
if w <= critical_value do
0.05 # Significant at 5% level
else
0.5 # Not significant
end
end
end
end
defp handle_ties(ranked_diffs) do
# Group by absolute value to find ties
grouped = Enum.group_by(ranked_diffs, fn {diff, _rank} -> abs(diff) end)
Enum.flat_map(grouped, fn {_abs_val, group} ->
if length(group) > 1 do
# Average ranks for tied values
ranks = Enum.map(group, &elem(&1, 1))
avg_rank = Enum.sum(ranks) / length(ranks)
Enum.map(group, fn {diff, _} -> {diff, avg_rank} end)
else
group
end
end)
end
defp normal_cdf(z) do
# Approximation of normal CDF using error function
0.5 * (1 + erf(z / :math.sqrt(2)))
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
defp get_wilcoxon_critical_value(n) do
# Critical values for Wilcoxon test at 5% significance level (two-tailed)
# These are approximate values for small samples
critical_values = %{
5 => 0,
6 => 2,
7 => 3,
8 => 5,
9 => 8,
10 => 10
}
Map.get(critical_values, n, div(n * (n + 1), 4) - 1.96 * :math.sqrt(n * (n + 1) * (2 * n + 1) / 24))
end
@doc """
Performs a simple rank-sum test as an alternative.
"""
def rank_sum_test(sample1, sample2) do
# Combine samples with labels
combined = (Enum.map(sample1, &{&1, :sample1}) ++ Enum.map(sample2, &{&1, :sample2}))
|> Enum.sort_by(&elem(&1, 0))
|> Enum.with_index(1)
# Calculate rank sum for sample1
rank_sum1 = combined
|> Enum.filter(fn {{_, label}, _} -> label == :sample1 end)
|> Enum.map(&elem(&1, 1))
|> Enum.sum()
n1 = length(sample1)
n2 = length(sample2)
# Calculate U statistic
u1 = rank_sum1 - n1 * (n1 + 1) / 2
u2 = n1 * n2 - u1
u = min(u1, u2)
# Normal approximation for p-value
mean_u = n1 * n2 / 2
var_u = n1 * n2 * (n1 + n2 + 1) / 12
z = (u - mean_u) / :math.sqrt(var_u)
2 * normal_cdf(-abs(z))
end
end