Wilcoxon pruner
WilcoxonPruner is built for objectives that average many independent evaluations — a mean over a
dataset of problem instances, cross-validation folds, or Monte-Carlo samples. It watches the
per-instance results a trial reports and, using a
Wilcoxon signed-rank test, prunes the trial
once it is statistically worse than the best trial so far — without waiting for every instance.
This recipe tunes a Simulated Annealing solver for the Traveling Salesman Problem (TSP), whose
objective is the mean tour cost over 50 random instances. The SA/TSP code below is ordinary user
objective code, so it uses System.Random; the sampler's own randomness is NumSharp-backed (that is
what the seeded parity guarantee rests on).
The TSP solver
using System;
using System.Collections.Generic;
using System.Linq;
public readonly record struct SAOptions(int MaxIter, double T0, double Alpha, int Patience);
static class Tsp
{
static double Dist(double[] a, double[] b) => Math.Sqrt((a[0]-b[0])*(a[0]-b[0]) + (a[1]-b[1])*(a[1]-b[1]));
// Total length of the closed tour visiting vertices in `idxs` order.
public static double Cost(double[][] v, int[] idxs)
{
int n = idxs.Length;
double sum = 0;
for (int i = 0; i < n; i++)
sum += Dist(v[idxs[i]], v[idxs[(i - 1 + n) % n]]);
return sum;
}
// Nearest-neighbor construction, used as the SA starting point.
public static int[] Greedy(double[][] v)
{
int n = v.Length;
var idxs = new List<int> { 0 };
var visited = new bool[n]; visited[0] = true;
for (int k = 0; k < n - 1; k++)
{
int last = idxs[^1], best = -1; double bestD = double.PositiveInfinity;
for (int j = 0; j < n; j++)
if (!visited[j] && Dist(v[last], v[j]) < bestD) { bestD = Dist(v[last], v[j]); best = j; }
visited[best] = true; idxs.Add(best);
}
return idxs.ToArray();
}
// 2-opt Simulated Annealing.
public static int[] SimulatedAnnealing(double[][] v, SAOptions o, Random rng)
{
double Temperature(double t) => o.T0 * Math.Pow(1 - t, o.Alpha);
int n = v.Length;
int[] idxs = Greedy(v);
double cost = Cost(v, idxs);
int[] best = (int[])idxs.Clone(); double bestCost = cost;
int patienceLeft = o.Patience;
for (int iter = 0; iter < o.MaxIter; iter++)
{
int i = rng.Next(0, n);
int j = (i + 2 + rng.Next(0, n - 3)) % n;
(i, j) = (Math.Min(i, j), Math.Max(i, j)); // reverse the segment (i, j]
// Cost change of the 2-opt reversal — computable from just the four affected edges.
double delta =
-Dist(v[idxs[(i + 1) % n]], v[idxs[i]]) - Dist(v[idxs[j]], v[idxs[(j + 1) % n]])
+Dist(v[idxs[i]], v[idxs[j]]) + Dist(v[idxs[(i + 1) % n]], v[idxs[(j + 1) % n]]);
double temp = Temperature((double)iter / o.MaxIter);
if (delta <= 0.0 || rng.NextDouble() < Math.Exp(-delta / temp))
{
cost += delta;
Array.Reverse(idxs, i + 1, j - i); // apply the reversal
if (cost < bestCost) { Array.Copy(idxs, best, n); bestCost = cost; patienceLeft = o.Patience; }
}
if (cost > bestCost && --patienceLeft == 0) // roll back to best after too long without improvement
{
Array.Copy(best, idxs, n); cost = bestCost; patienceLeft = o.Patience;
}
}
return best;
}
}
A random dataset of 50 TSP instances, each 100 vertices in the unit square:
static double[][][] MakeDataset(int numVertex, int numProblem, int seed = 0)
{
var rng = new Random(seed);
return Enumerable.Range(0, numProblem)
.Select(_ => Enumerable.Range(0, numVertex)
.Select(_ => new[] { rng.NextDouble(), rng.NextDouble() }).ToArray())
.ToArray();
}
double[][][] dataset = MakeDataset(numVertex: 100, numProblem: 50);
const int nSaIter = 10_000; // small, for demonstration; use ~1_000_000 in practice
var objRng = new Random(0);
The objective: report each instance, prune early
The objective solves every instance, reports the cost per instance, and averages them. The key
move: when ShouldPrune() fires, return the running mean instead of throwing TrialPruned — this
hands the sampler an estimate of the final value for the pruned trial, which most samplers use more
effectively than a bare prune.
using OptunaSharp;
double Objective(Trial trial)
{
var options = new SAOptions(
MaxIter: nSaIter,
T0: trial.SuggestFloat("T0", 0.01, 10.0, log: true),
Alpha: trial.SuggestFloat("alpha", 1.0, 10.0, log: true),
Patience: (int)trial.SuggestInt("patience", 10, 1000, log: true));
var results = new List<double>();
// Shuffle the evaluation order each trial for a fairer paired comparison.
int[] order = Enumerable.Range(0, dataset.Length).OrderBy(_ => objRng.Next()).ToArray();
foreach (int instanceId in order)
{
int[] tour = Tsp.SimulatedAnnealing(dataset[instanceId], options, objRng);
double cost = Tsp.Cost(dataset[instanceId], tour);
results.Add(cost);
trial.Report(cost, instanceId);
if (trial.ShouldPrune())
return results.Average(); // workaround: report the running estimate, don't raise
}
return results.Average();
}
Run it
Pair TPESampler with WilcoxonPruner, and enqueue a sensible default configuration first:
using OptunaSharp.Samplers;
using OptunaSharp.Pruners;
using OptunaSharp.Collections;
var sampler = new TPESampler(seed: 1);
var pruner = new WilcoxonPruner(pThreshold: 0.1); // prune when p < 0.1 that this trial is better
var study = Optuna.CreateStudy(direction: StudyDirection.Minimize, sampler: sampler, pruner: pruner);
study.EnqueueTrial(new OrderedDict<string, object?> { ["T0"] = 1.0, ["alpha"] = 2.0, ["patience"] = 50 });
study.Optimize(Objective, nTrials: 50);
Console.WriteLine($"Best value: {study.BestValue} (params: T0={study.BestParams["T0"]}, " +
$"alpha={study.BestParams["alpha"]}, patience={study.BestParams["patience"]})");
Because pruned trials stop after only a handful of instances, the total number of SA runs is far below
50 trials × 50 instances — that saving is the point of the pruner. You can chart the run with
PlotlyVisualization.PlotOptimizationHistory(study); it shows completed and
pruned trials alike.
Relationship to Optuna
| Optuna (Python) | OptunaSharp |
|---|---|
optuna.pruners.WilcoxonPruner(p_threshold=0.1) |
new WilcoxonPruner(pThreshold: 0.1) |
optuna.samplers.TPESampler(seed=1) |
new TPESampler(seed: 1) |
trial.report(cost, instance_id) |
trial.Report(cost, instanceId) |
if trial.should_prune(): return sum(results)/len(results) |
if (trial.ShouldPrune()) return results.Average(); |
study.enqueue_trial({"T0": 1.0, …}) |
study.EnqueueTrial(new OrderedDict<string, object?> { ["T0"] = 1.0, … }) |
See also
Pruners · Wilcoxon pruner method page · User-defined pruner · Visualization · the API reference.