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,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()
}
Original file line number Diff line number Diff line change
@@ -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)))
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading