Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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")
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading