From 56c79a302e35244755ab3eb0fefda0b8525acdde Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 07:59:59 -0700 Subject: [PATCH] Add RestartingStrategy with Luby budgets and perturbed restarts Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0175jMLxrP7wZ3vfj6UfeDZx --- .../ExhaustionWatchingOptimizee.scala | 43 +++++ .../strategy/restart/RestartingConfig.scala | 37 +++++ .../strategy/restart/RestartingStrategy.scala | 105 ++++++++++++ .../restart/RestartingStrategySuite.scala | 150 ++++++++++++++++++ 4 files changed, 335 insertions(+) create mode 100644 scala-source/com/barrybecker4/optimization/optimizee/ExhaustionWatchingOptimizee.scala create mode 100644 scala-source/com/barrybecker4/optimization/strategy/restart/RestartingConfig.scala create mode 100644 scala-source/com/barrybecker4/optimization/strategy/restart/RestartingStrategy.scala create mode 100644 scala-test/com/barrybecker4/optimization/strategy/restart/RestartingStrategySuite.scala diff --git a/scala-source/com/barrybecker4/optimization/optimizee/ExhaustionWatchingOptimizee.scala b/scala-source/com/barrybecker4/optimization/optimizee/ExhaustionWatchingOptimizee.scala new file mode 100644 index 0000000..4630a49 --- /dev/null +++ b/scala-source/com/barrybecker4/optimization/optimizee/ExhaustionWatchingOptimizee.scala @@ -0,0 +1,43 @@ +// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT +package com.barrybecker4.optimization.optimizee + +import com.barrybecker4.optimization.parameter.{Move, ParameterArray} + +import scala.util.Random + +/** + * Passes every call to `delegate` and notes when the delegate threw [[EvaluationBudgetExceededException]]. + * A strategy that catches that exception returns its best so far, so the caller could not tell otherwise + * that a budget further out (not the one it set itself) is used up. + */ +class ExhaustionWatchingOptimizee(delegate: Optimizee) extends Optimizee { + + @volatile private var exhausted = false + + /** True once a call to the delegate was rejected because its evaluation limit was reached. */ + def sawExhaustion: Boolean = exhausted + + override def getName: String = delegate.getName + override def evaluateByComparison: Boolean = delegate.evaluateByComparison + override def getOptimalFitness: Double = delegate.getOptimalFitness + override def repair(candidate: ParameterArray): ParameterArray = delegate.repair(candidate) + override def proposeGuidedMove(current: ParameterArray, radius: Double, rnd: Random): Option[Move] = + delegate.proposeGuidedMove(current, radius, rnd) + override def conflictedPositions(current: ParameterArray): Seq[Int] = delegate.conflictedPositions(current) + + override def evaluateFitness(params: ParameterArray): Double = watching(delegate.evaluateFitness(params)) + + override def compareFitness(params1: ParameterArray, params2: ParameterArray): Double = + watching(delegate.compareFitness(params1, params2)) + + override def evaluateMove(current: ParameterArray, currentFitness: Double, move: Move): Double = + watching(delegate.evaluateMove(current, currentFitness, move)) + + private def watching(call: => Double): Double = + try call + catch { + case e: EvaluationBudgetExceededException => + exhausted = true + throw e + } +} diff --git a/scala-source/com/barrybecker4/optimization/strategy/restart/RestartingConfig.scala b/scala-source/com/barrybecker4/optimization/strategy/restart/RestartingConfig.scala new file mode 100644 index 0000000..2629e77 --- /dev/null +++ b/scala-source/com/barrybecker4/optimization/strategy/restart/RestartingConfig.scala @@ -0,0 +1,37 @@ +// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT +package com.barrybecker4.optimization.strategy.restart + +/** + * Tunables for [[RestartingStrategy]]. + * + * Run number `i` (counting from 1) may use `luby(i) * baseBudget` evaluations, where `luby` is the sequence + * 1 1 2 1 1 2 4 1 1 2 1 1 2 4 8 ... that is known to be within a log factor of the best fixed restart schedule + * when the best budget is not known. + * + * @param maxRestarts maximum number of runs of the inner strategy. + * @param baseBudget evaluations in a run of Luby length 1. + * @param totalBudget evaluations across all the runs. + * @param adaptBaseBudget when the first run ends by itself inside its budget, use the evaluations it took as + * the base budget from then on, so the Luby lengths count natural runs of the strategy. + * @param perturbRadius radius of the neighbor taken from the best solution when a restart is a perturbation. + * @param perturbationStep added to the chance that a restart perturbs the best solution, for each restart + * in a row that did not beat it (fresh random starts that fail suggest the best is close). + * @param maxPerturbationProbability limit of that chance; the rest of the restarts start from a random sample. + */ +final case class RestartingConfig( + maxRestarts: Int = 1000, + baseBudget: Int = 2000, + totalBudget: Int = Int.MaxValue, + adaptBaseBudget: Boolean = true, + perturbRadius: Double = 1.0, + perturbationStep: Double = 0.1, + maxPerturbationProbability: Double = 0.5 +) { + require(maxRestarts >= 1, s"maxRestarts must be at least 1, got $maxRestarts") + require(baseBudget >= 1, s"baseBudget must be at least 1, got $baseBudget") + require(totalBudget >= 1, s"totalBudget must be at least 1, got $totalBudget") + require(perturbRadius > 0.0 && perturbRadius <= 1.5, s"perturbRadius must be in (0, 1.5], got $perturbRadius") + require(perturbationStep >= 0.0, s"perturbationStep must not be negative, got $perturbationStep") + require(maxPerturbationProbability >= 0.0 && maxPerturbationProbability <= 1.0, + s"maxPerturbationProbability must be in [0, 1], got $maxPerturbationProbability") +} diff --git a/scala-source/com/barrybecker4/optimization/strategy/restart/RestartingStrategy.scala b/scala-source/com/barrybecker4/optimization/strategy/restart/RestartingStrategy.scala new file mode 100644 index 0000000..2d21a8d --- /dev/null +++ b/scala-source/com/barrybecker4/optimization/strategy/restart/RestartingStrategy.scala @@ -0,0 +1,105 @@ +// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT +package com.barrybecker4.optimization.strategy.restart + +import com.barrybecker4.optimization.optimizee.{BudgetedOptimizee, EvaluationBudgetExceededException, ExhaustionWatchingOptimizee, Optimizee} +import com.barrybecker4.optimization.parameter.{ParameterArray, ParameterArrayWithFitness} +import com.barrybecker4.optimization.strategy.OptimizationStrategy + +import scala.util.Random + +object RestartingStrategy { + + /** The i'th term (counting from 1) of the Luby sequence 1 1 2 1 1 2 4 1 1 2 1 1 2 4 8 ... */ + def luby(i: Int): Int = { + require(i >= 1, s"The Luby sequence starts at 1, got $i") + var k = 1 + while ((1L << k) - 1 < i) k += 1 + if ((1L << k) - 1 == i) 1 << (k - 1) else luby(i - (1 << (k - 1)) + 1) + } +} + +/** + * Runs another strategy again and again, each time with a limited number of evaluations, and keeps the best result. + * It helps with a problem where the inner strategy either finds a solution soon or gets stuck for good, which is the + * case for many constraint problems. The budget of run `i` is `luby(i) * baseBudget` (see [[RestartingConfig]]). + * + * The first run starts from the initial guess. After that a run starts from a random sample, or, with a chance that + * grows while restarts keep failing to beat the best, from a perturbed copy of the best solution. + * The search stops when the goal fitness is reached, when the restarts or the total budget are used up, or when an + * evaluation limit set outside this strategy (a [[BudgetedOptimizee]] around it) is reached. + * + * Works with absolute fitness only: the results of separate runs cannot be compared when fitness is relative to each + * run's own starting point. The inner strategy must report its progress with `notifyOfChange`, as the built-in + * ones do, so that the best of a run that was cut off is not lost. + * + * @param optimizee the thing to be optimized. + * @param innerFactory builds the strategy for one run, given the optimizee that limits its evaluations. + * @param rnd chooses between a random and a perturbed start. + */ +class RestartingStrategy(optimizee: Optimizee, innerFactory: Optimizee => OptimizationStrategy, rnd: Random = new Random(1)) + extends OptimizationStrategy(optimizee) { + + private var config: RestartingConfig = RestartingConfig() + private var restarts = 0 + private var evaluations = 0 + + def getConfig: RestartingConfig = config + + def setConfig(config: RestartingConfig): Unit = { this.config = config } + + /** Number of runs of the inner strategy in the last call to `doOptimization`. */ + def runCount: Int = restarts + + /** Evaluations used by all the runs of the last call to `doOptimization`. */ + def evaluationsUsed: Int = evaluations + + override def doOptimization(params: ParameterArray, fitnessRange: Double): ParameterArrayWithFitness = { + require(!optimizee.evaluateByComparison, "Restarting needs absolute fitness, not comparison") + val outerLimit = new ExhaustionWatchingOptimizee(optimizee) + var best = ParameterArrayWithFitness(params, Double.MaxValue) + var base = config.baseBudget + var failedInARow = 0 + restarts = 0 + evaluations = 0 + + while (restarts < config.maxRestarts && evaluations < config.totalBudget + && !isOptimalFitnessReached(best) && !outerLimit.sawExhaustion) { + restarts += 1 + val budget = budgetOfRun(restarts, base) + val start = if (restarts == 1) params else nextStart(best, failedInARow) + val (result, finishedInBudget) = runInner(outerLimit, start, budget, fitnessRange) + if (restarts == 1 && finishedInBudget && config.adaptBaseBudget) base = math.max(1, evaluations) + failedInARow = if (result.fitness < best.fitness) 0 else failedInARow + 1 + if (result.fitness < best.fitness) best = result + log(restarts, best, budget.toDouble, 0.0, "restart " + restarts) + trace("restart " + restarts + " budget " + budget + " best fitness = " + best.fitness) + } + best + } + + /** The Luby length times the base, but no more than what is left of the total budget. */ + private def budgetOfRun(run: Int, base: Int): Int = { + val wanted = RestartingStrategy.luby(run).toLong * base + math.max(1L, math.min(wanted, config.totalBudget.toLong - evaluations)).toInt + } + + /** @return the best of the run, and whether the strategy ended by itself rather than by running out of budget. */ + private def runInner(outerLimit: Optimizee, start: ParameterArray, budget: Int, fitnessRange: Double) + : (ParameterArrayWithFitness, Boolean) = { + val limited = new BudgetedOptimizee(outerLimit, budget) + val inner = innerFactory(limited) + copyRunConfigurationTo(inner) + val result = + try inner.doOptimization(start, fitnessRange) + catch { case _: EvaluationBudgetExceededException => inner.bestSoFar.getOrElse(ParameterArrayWithFitness(start, Double.MaxValue)) } + evaluations += limited.evaluationsUsed + inner.bestSoFar.foreach(notifyOfChange) + (result, !limited.wasBudgetExhausted) + } + + /** A random sample, or, with a chance that rises with each failed restart, a perturbation of the best. */ + private def nextStart(best: ParameterArrayWithFitness, failedInARow: Int): ParameterArray = { + val chance = math.min(config.maxPerturbationProbability, config.perturbationStep * failedInARow) + if (rnd.nextDouble() < chance) best.pa.getRandomNeighbor(config.perturbRadius) else best.pa.getRandomSample + } +} diff --git a/scala-test/com/barrybecker4/optimization/strategy/restart/RestartingStrategySuite.scala b/scala-test/com/barrybecker4/optimization/strategy/restart/RestartingStrategySuite.scala new file mode 100644 index 0000000..8de28d7 --- /dev/null +++ b/scala-test/com/barrybecker4/optimization/strategy/restart/RestartingStrategySuite.scala @@ -0,0 +1,150 @@ +// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT +package com.barrybecker4.optimization.strategy.restart + +import com.barrybecker4.optimization.benchmark.NQueensProblem +import com.barrybecker4.optimization.optimizee.{BudgetedOptimizee, EvaluationBudgetExceededException, Optimizee} +import com.barrybecker4.optimization.parameter.{ParameterArray, ParameterArrayWithFitness} +import com.barrybecker4.optimization.strategy.OptimizationStrategy +import org.scalatest.funsuite.AnyFunSuite + +import scala.collection.mutable.ArrayBuffer +import scala.util.Random + +class RestartingStrategySuite extends AnyFunSuite { + + private val Size = 30 + + /** Evaluates random samples until it has made `evaluationLimit` of them, or until the budget stops it. */ + private class Sampler(optimizee: Optimizee, evaluationLimit: Int, starts: ArrayBuffer[ParameterArray], + results: ArrayBuffer[Double], catchBudget: Boolean = false) extends OptimizationStrategy(optimizee) { + override def doOptimization(start: ParameterArray, fitnessRange: Double): ParameterArrayWithFitness = { + starts += start + var best = ParameterArrayWithFitness(start, optimizee.evaluateFitness(start)) + notifyOfChange(best) + try { + for (_ <- 1 until evaluationLimit) { + val sample = start.getRandomSample + val scored = ParameterArrayWithFitness(sample, optimizee.evaluateFitness(sample)) + if (scored.fitness < best.fitness) { best = scored; notifyOfChange(best) } + } + } catch { case e: EvaluationBudgetExceededException => if (!catchBudget) throw e } + results += best.fitness + best + } + } + + private def queens(seed: Long = 1) = new NQueensProblem(Size, seed) + + private def strategyOf(problem: NQueensProblem, evaluationLimit: Int, starts: ArrayBuffer[ParameterArray] = ArrayBuffer(), + results: ArrayBuffer[Double] = ArrayBuffer(), catchBudget: Boolean = false, optimizee: Optimizee = null) = + new RestartingStrategy(if (optimizee == null) problem else optimizee, + limited => new Sampler(limited, evaluationLimit, starts, results, catchBudget)) + + test("the Luby sequence") { + assertResult(List(1, 1, 2, 1, 1, 2, 4, 1, 1, 2, 1, 1, 2, 4, 8, 1))((1 to 16).map(RestartingStrategy.luby)) + } + + test("the Luby sequence starts at 1") { + intercept[IllegalArgumentException](RestartingStrategy.luby(0)) + } + + test("each run gets the Luby multiple of the base budget, and a run that wants more is cut off") { + val problem = queens() + val strategy = strategyOf(problem, evaluationLimit = Int.MaxValue) + strategy.setConfig(RestartingConfig(maxRestarts = 7, baseBudget = 100, adaptBaseBudget = false)) + strategy.doOptimization(problem.getInitialGuess, problem.getFitnessRange) + assertResult(7)(strategy.runCount) + assertResult(1200)(strategy.evaluationsUsed) + } + + test("the evaluations of all the runs never pass the total budget") { + val problem = queens() + val strategy = strategyOf(problem, evaluationLimit = Int.MaxValue) + strategy.setConfig(RestartingConfig(baseBudget = 400, totalBudget = 1000)) + strategy.doOptimization(problem.getInitialGuess, problem.getFitnessRange) + assertResult(1000)(strategy.evaluationsUsed) + } + + test("when a budget that was set outside is used up the search ends and returns the best so far") { + val problem = queens() + val outer = new BudgetedOptimizee(problem, 250) + val strategy = strategyOf(problem, Int.MaxValue, catchBudget = true, optimizee = outer) + strategy.setConfig(RestartingConfig(baseBudget = 100)) + val result = strategy.doOptimization(problem.getInitialGuess, problem.getFitnessRange) + assertResult(250)(outer.evaluationsUsed) + assert(result.fitness < Double.MaxValue) + assert(strategy.runCount <= 4) + } + + test("the first run starts from the initial guess and the best of all runs is returned") { + val problem = queens() + val starts = ArrayBuffer[ParameterArray]() + val results = ArrayBuffer[Double]() + val strategy = strategyOf(problem, evaluationLimit = 20, starts, results) + strategy.setConfig(RestartingConfig(maxRestarts = 6, baseBudget = 20, adaptBaseBudget = false)) + val initial = problem.getInitialGuess + val result = strategy.doOptimization(initial, problem.getFitnessRange) + assert(starts.head eq initial) + assertResult(6)(results.size) + assertResult(results.min)(result.fitness) + } + + test("the search stops when a run reaches the goal fitness") { + val problem = queens() + var runs = 0 + val strategy = new RestartingStrategy(problem, _ => new OptimizationStrategy(problem) { + override def doOptimization(start: ParameterArray, range: Double): ParameterArrayWithFitness = { + runs += 1 + if (runs == 3) problem.getExactSolution else ParameterArrayWithFitness(start, 5.0) + } + }) + val result = strategy.doOptimization(problem.getInitialGuess, problem.getFitnessRange) + assertResult(0.0)(result.fitness) + assertResult(3)(strategy.runCount) + } + + test("with the base budget adapted, a first run that ends by itself sets the length of the runs after it") { + val problem = queens() + val strategy = strategyOf(problem, evaluationLimit = 60) + strategy.setConfig(RestartingConfig(maxRestarts = 3, baseBudget = 1000)) + strategy.doOptimization(problem.getInitialGuess, problem.getFitnessRange) + assertResult(60 + 60 + 60)(strategy.evaluationsUsed) + val unadapted = strategyOf(problem, evaluationLimit = Int.MaxValue) + unadapted.setConfig(RestartingConfig(maxRestarts = 3, baseBudget = 1000, adaptBaseBudget = false)) + unadapted.doOptimization(problem.getInitialGuess, problem.getFitnessRange) + assertResult(4000)(unadapted.evaluationsUsed) + } + + test("a restart after failures is a small change of the best when the chance of perturbing is certain") { + val problem = queens() + val starts = ArrayBuffer[ParameterArray]() + val strategy = strategyOf(problem, evaluationLimit = 1, starts) + strategy.setConfig(RestartingConfig(maxRestarts = 30, baseBudget = 1, perturbationStep = 1.0, maxPerturbationProbability = 1.0)) + strategy.doOptimization(problem.getInitialGuess, problem.getFitnessRange) + val differing = starts.tail.map(s => (0 until Size).count(i => s.get(i).getValue != starts.head.get(i).getValue)) + // The best may change along the way, but a start is always a few swaps from some best; no start is a fresh shuffle. + assert(differing.count(_ > Size * 2 / 3) < starts.size / 2) + } + + test("two seeded runs make the same result") { + def run() = { + val problem = queens(4) + val strategy = strategyOf(problem, evaluationLimit = 30) + strategy.setConfig(RestartingConfig(maxRestarts = 5, baseBudget = 30)) + (strategy.doOptimization(problem.getInitialGuess, problem.getFitnessRange).fitness, strategy.evaluationsUsed) + } + assertResult(run())(run()) + } + + test("comparison of fitness is refused") { + val problem = new NQueensProblem(Size, 1) { override def evaluateByComparison: Boolean = true } + intercept[IllegalArgumentException](strategyOf(problem, 10).doOptimization(problem.getInitialGuess, 1.0)) + } + + test("the configuration refuses values that make no sense") { + intercept[IllegalArgumentException](RestartingConfig(maxRestarts = 0)) + intercept[IllegalArgumentException](RestartingConfig(baseBudget = 0)) + intercept[IllegalArgumentException](RestartingConfig(perturbRadius = 2.0)) + intercept[IllegalArgumentException](RestartingConfig(maxPerturbationProbability = 1.5)) + } +}