diff --git a/scala-source/com/barrybecker4/optimization/strategy/tabu/TabuSearchConfig.scala b/scala-source/com/barrybecker4/optimization/strategy/tabu/TabuSearchConfig.scala new file mode 100644 index 0000000..1639a29 --- /dev/null +++ b/scala-source/com/barrybecker4/optimization/strategy/tabu/TabuSearchConfig.scala @@ -0,0 +1,42 @@ +// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT +package com.barrybecker4.optimization.strategy.tabu + +/** + * Tunables for [[TabuSearchStrategy]]. + * + * Every iteration looks at `candidateMoves` moves from the current solution and takes the best one that is not + * tabu, even when it is worse than where the search is now. Undoing a move is forbidden for a number of iterations + * (the tenure), which is what keeps the search from walking back into the local optimum it just left. + * + * @param maxIterations number of moves taken. + * @param candidateMoves moves looked at (and scored) per iteration. All are scored, so this is also + * the number of evaluations per iteration. + * @param tenureMin fewest iterations that undoing a move is forbidden. + * @param tenureMax most iterations; the tenure of each move is drawn from `tenureMin` to + * `tenureMax`, which avoids cycles of one fixed length. + * @param aspiration allow a tabu move when it gives a better solution than any seen so far. + * @param restartAfterStagnantIterations after this many iterations without a new best solution, jump to a random + * solution and forget the tabu list (the best is kept); 0 disables. + * @param fallbackRadius radius of the random neighbors used when the array has no local moves. + */ +final case class TabuSearchConfig( + maxIterations: Int = 10000, + candidateMoves: Int = 50, + tenureMin: Int = 5, + tenureMax: Int = 15, + aspiration: Boolean = true, + restartAfterStagnantIterations: Int = 500, + fallbackRadius: Double = 0.5 +) { + require(maxIterations >= 0, s"maxIterations must not be negative, got $maxIterations") + require(candidateMoves >= 1, s"candidateMoves must be at least 1, got $candidateMoves") + require(tenureMin >= 0, s"tenureMin must not be negative, got $tenureMin") + require(tenureMax >= tenureMin, s"tenureMax ($tenureMax) must not be less than tenureMin ($tenureMin)") + require(restartAfterStagnantIterations >= 0, + s"restartAfterStagnantIterations must not be negative, got $restartAfterStagnantIterations") + require(fallbackRadius > 0.0 && fallbackRadius <= 1.5, s"fallbackRadius must be in (0, 1.5], got $fallbackRadius") +} + +object TabuSearchConfig { + val Default: TabuSearchConfig = TabuSearchConfig() +} diff --git a/scala-source/com/barrybecker4/optimization/strategy/tabu/TabuSearchStrategy.scala b/scala-source/com/barrybecker4/optimization/strategy/tabu/TabuSearchStrategy.scala new file mode 100644 index 0000000..d2fb8b9 --- /dev/null +++ b/scala-source/com/barrybecker4/optimization/strategy/tabu/TabuSearchStrategy.scala @@ -0,0 +1,106 @@ +// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT +package com.barrybecker4.optimization.strategy.tabu + +import com.barrybecker4.math.MathUtil +import com.barrybecker4.optimization.optimizee.{EvaluationBudgetExceededException, Optimizee} +import com.barrybecker4.optimization.parameter.{ParameterArray, ParameterArrayWithFitness} +import com.barrybecker4.optimization.strategy.OptimizationStrategy +import com.barrybecker4.optimization.strategy.statespace.StateSpaceSearchStrategy + +import scala.collection.mutable +import scala.util.Random + +/** + * Tabu search for discrete problems. From the current solution it scores a sample of moves and takes the best one that + * is not tabu, even if that is worse than the current solution, so it climbs out of local optima. A move is tabu when it + * would put back something that a recent move took away (see [[com.barrybecker4.optimization.parameter.ParameterArray.createdAttributes]]); + * the things a move takes away stay forbidden for a tenure drawn from [[TabuSearchConfig]]. A tabu move is still + * allowed when it gives a new best solution (aspiration), and when every candidate is tabu the best of them is taken. + * + * It uses [[com.barrybecker4.optimization.parameter.ParameterArray.proposeLocalMove]] and [[Optimizee.evaluateMove]], + * so a problem that scores a move by a delta (as the N-queens benchmark does) makes it cheap. For an array without local moves it + * scores random neighbors instead and forbids returning to a solution it has just left; that works but evaluates a + * whole solution per candidate, so it is slow. + * + * Works with absolute fitness only. When the optimizee is wrapped with an evaluation budget the best solution found + * so far is returned when the budget is used up. + * + * @param optimizee the thing to be optimized. + * @param rnd chooses the moves and the tenures. + */ +class TabuSearchStrategy(optimizee: Optimizee, rnd: Random = MathUtil.RANDOM) extends OptimizationStrategy(optimizee) { + + private var config: TabuSearchConfig = TabuSearchConfig.Default + + /** What a candidate would change, scored but not yet built. */ + private case class Candidate(build: () => ParameterArray, fitness: Double, created: Seq[Any], destroyed: Seq[Any]) + + def getConfig: TabuSearchConfig = config + + def setConfig(config: TabuSearchConfig): Unit = { this.config = config } + + override def doOptimization(params: ParameterArray, fitnessRange: Double): ParameterArrayWithFitness = { + require(!optimizee.evaluateByComparison, "Tabu search needs absolute fitness, not comparison") + var best = ParameterArrayWithFitness(params, Double.PositiveInfinity) + try best = search(params) + catch { case _: EvaluationBudgetExceededException => best = bestSoFar.getOrElse(best) } + best + } + + private def search(start: ParameterArray): ParameterArrayWithFitness = { + val tabuUntil = mutable.HashMap[Any, Int]() + var current = scored(start) + var best = current + notifyOfChange(best) + var stagnant = 0 + var iteration = 0 + while (iteration < config.maxIterations && !isOptimalFitnessReached(best)) { + iteration += 1 + current = step(current, best, iteration, tabuUntil) + if (current.fitness < best.fitness) { best = current; stagnant = 0; notifyOfChange(best) } else stagnant += 1 + if (shouldRestart(stagnant)) { current = scored(best.pa.getRandomSample); tabuUntil.clear(); stagnant = 0 } + trace("iteration " + iteration + " current = " + current.fitness + " best = " + best.fitness) + } + best + } + + private def shouldRestart(stagnantIterations: Int): Boolean = + config.restartAfterStagnantIterations > 0 && stagnantIterations >= config.restartAfterStagnantIterations + + private def scored(array: ParameterArray): ParameterArrayWithFitness = ParameterArrayWithFitness(array, cleaned(optimizee.evaluateFitness(array))) + + /** A NaN must never look like an improvement. */ + private def cleaned(fitness: Double): Double = if (fitness.isNaN) Double.PositiveInfinity else fitness + + /** Takes the best allowed candidate, and forbids undoing it for a while. */ + private def step(current: ParameterArrayWithFitness, best: ParameterArrayWithFitness, iteration: Int, + tabuUntil: mutable.HashMap[Any, Int]): ParameterArrayWithFitness = { + val candidates = candidatesFrom(current) + val isTabu = (c: Candidate) => c.created.exists(attribute => tabuUntil.getOrElse(attribute, 0) > iteration) + val allowed = candidates.filter(c => !isTabu(c) || (config.aspiration && c.fitness < best.fitness)) + val chosen = bestOf(if (allowed.nonEmpty) allowed else candidates) + val tenure = config.tenureMin + rnd.nextInt(config.tenureMax - config.tenureMin + 1) + chosen.destroyed.foreach(attribute => tabuUntil(attribute) = iteration + tenure) + ParameterArrayWithFitness(chosen.build(), chosen.fitness) + } + + /** The lowest fitness, with the first one winning a tie; the candidates are in random order already. */ + private def bestOf(candidates: Seq[Candidate]): Candidate = candidates.minBy(_.fitness) + + private def candidatesFrom(current: ParameterArrayWithFitness): Seq[Candidate] = + if (current.pa.supportsLocalMoves) (1 to config.candidateMoves).map(_ => moveCandidate(current)) + else (1 to config.candidateMoves).map(_ => neighborCandidate(current)) + + private def moveCandidate(current: ParameterArrayWithFitness): Candidate = { + val move = current.pa.proposeLocalMove(rnd) + val fitness = cleaned(optimizee.evaluateMove(current.pa, current.fitness, move)) + Candidate(() => current.pa.applyMove(move), fitness, current.pa.createdAttributes(move), current.pa.destroyedAttributes(move)) + } + + /** Without moves the only thing to forbid is going back to the solution that was left. */ + private def neighborCandidate(current: ParameterArrayWithFitness): Candidate = { + val neighbor = current.pa.getRandomNeighbor(config.fallbackRadius) + val fitness = cleaned(optimizee.evaluateFitness(neighbor)) + Candidate(() => neighbor, fitness, Seq(StateSpaceSearchStrategy.stateKey(neighbor)), Seq(StateSpaceSearchStrategy.stateKey(current.pa))) + } +} diff --git a/scala-test/com/barrybecker4/optimization/strategy/tabu/TabuSearchStrategySuite.scala b/scala-test/com/barrybecker4/optimization/strategy/tabu/TabuSearchStrategySuite.scala new file mode 100644 index 0000000..6d7a8dc --- /dev/null +++ b/scala-test/com/barrybecker4/optimization/strategy/tabu/TabuSearchStrategySuite.scala @@ -0,0 +1,107 @@ +// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT +package com.barrybecker4.optimization.strategy.tabu + +import com.barrybecker4.optimization.benchmark.{GraphColoringProblem, NQueensProblem} +import com.barrybecker4.optimization.optimizee.BudgetedOptimizee +import com.barrybecker4.optimization.parameter.{Move, ParameterArray} +import com.barrybecker4.optimization.strategy.statespace.StateSpaceSearchStrategy +import org.scalatest.funsuite.AnyFunSuite + +import scala.util.Random + +class TabuSearchStrategySuite extends AnyFunSuite { + + private def strategyFor(problem: com.barrybecker4.optimization.optimizee.Optimizee, config: TabuSearchConfig, seed: Long = 1) = { + val strategy = new TabuSearchStrategy(problem, new Random(seed)) + strategy.setConfig(config) + strategy + } + + test("it solves a small N-queens problem") { + val problem = new NQueensProblem(30, 1) + val result = strategyFor(problem, TabuSearchConfig()).doOptimization(problem.getInitialGuess, problem.getFitnessRange) + assertResult(0.0)(result.fitness) + assertResult(0.0)(problem.evaluateFitness(result.pa)) + } + + test("it solves a small graph coloring problem, which needs moves of one position") { + val problem = new GraphColoringProblem(40, 4, 5.0, 2) + val result = strategyFor(problem, TabuSearchConfig()).doOptimization(problem.getInitialGuess, problem.getFitnessRange) + assertResult(0.0)(result.fitness) + } + + test("a seeded run repeats") { + def run() = { + val problem = new NQueensProblem(40, 3) + val config = TabuSearchConfig(maxIterations = 30) + strategyFor(problem, config, seed = 9).doOptimization(problem.getInitialGuess, 1.0).fitness + } + assertResult(run())(run()) + } + + test("the result is never worse than the starting solution") { + val problem = new NQueensProblem(40, 3) + val start = problem.getInitialGuess + val result = strategyFor(problem, TabuSearchConfig(maxIterations = 20)).doOptimization(start, 1.0) + assert(result.fitness <= problem.evaluateFitness(start)) + } + + test("a solved problem ends early, using few evaluations") { + val problem = new NQueensProblem(12, 1) + val budget = new BudgetedOptimizee(problem, Int.MaxValue) + strategyFor(budget, TabuSearchConfig(maxIterations = 100000)).doOptimization(problem.getInitialGuess, 1.0) + assert(budget.evaluationsUsed < 100000L * 50) + } + + test("when the budget is used up the best so far is returned") { + val problem = new NQueensProblem(40, 3) + val budget = new BudgetedOptimizee(problem, 500) + val result = strategyFor(budget, TabuSearchConfig()).doOptimization(problem.getInitialGuess, 1.0) + assert(budget.wasBudgetExhausted) + assert(result.fitness < Double.PositiveInfinity) + assertResult(result.fitness)(problem.evaluateFitness(result.pa)) + } + + test("each iteration scores as many moves as the config asks for") { + val problem = new NQueensProblem(40, 3) + val budget = new BudgetedOptimizee(problem, Int.MaxValue) + strategyFor(budget, TabuSearchConfig(maxIterations = 10, candidateMoves = 7, restartAfterStagnantIterations = 0)) + .doOptimization(problem.getInitialGuess, 1.0) + assertResult(1 + 10 * 7)(budget.evaluationsUsed) + } + + test("with a tenure longer than the run and no aspiration, the search never returns to a solution it has left") { + val problem = new NQueensProblem(40, 5) + val currents = scala.collection.mutable.ArrayBuffer[Any]() + val recording = new NQueensProblem(40, 5) { + override def evaluateMove(c: ParameterArray, f: Double, m: Move): Double = { + currents += StateSpaceSearchStrategy.stateKey(c) + super.evaluateMove(c, f, m) + } + } + val config = TabuSearchConfig(maxIterations = 50, candidateMoves = 7, tenureMin = 1000, tenureMax = 1000, + aspiration = false, restartAfterStagnantIterations = 0) + strategyFor(recording, config).doOptimization(problem.getInitialGuess, 1.0) + assertResult(50)(currents.distinct.size) + } + + test("an array without local moves is searched through random neighbors") { + val problem = new NQueensProblem(30, 5) + val plain = new com.barrybecker4.optimization.parameter.PuzzleLikePath(problem.getInitialGuess.asInstanceOf[com.barrybecker4.optimization.parameter.PermutedParameterArray].params, "path") + assert(!plain.supportsLocalMoves) + val result = strategyFor(problem, TabuSearchConfig(maxIterations = 20, candidateMoves = 5)).doOptimization(plain, 1.0) + assert(result.fitness <= problem.evaluateFitness(plain)) + } + + test("comparison of fitness is refused") { + val problem = new NQueensProblem(30, 1) { override def evaluateByComparison: Boolean = true } + intercept[IllegalArgumentException](strategyFor(problem, TabuSearchConfig()).doOptimization(problem.getInitialGuess, 1.0)) + } + + test("the configuration refuses values that make no sense") { + intercept[IllegalArgumentException](TabuSearchConfig(candidateMoves = 0)) + intercept[IllegalArgumentException](TabuSearchConfig(tenureMin = 5, tenureMax = 4)) + intercept[IllegalArgumentException](TabuSearchConfig(maxIterations = -1)) + intercept[IllegalArgumentException](TabuSearchConfig(fallbackRadius = 0.0)) + } +}