From 371f117ced78e29b4b1a6f7e9d4a2534d9292df4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 04:37:49 -0700 Subject: [PATCH 1/2] Opt-in moves for iterated local search and hill climbing IteratedLocalSearchConfig.useMoves, HillClimbingConfig.useMoves and GlobalHillClimbingConfig.useMoves, all off by default. With them on, a try is a move scored with evaluateMove and a neighbor is built only if it improves. The price is that rejected neighbors are not remembered, so one can be evaluated again; the default keeps its promise of evaluating each distinct solution at most once. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0175jMLxrP7wZ3vfj6UfeDZx --- .../gradient/DiscreteImprovementFinder.scala | 36 ++++++- .../GlobalHillClimbingConfig.scala | 3 +- .../GlobalHillClimbingStrategy.scala | 1 + .../hillclimbing/HillClimbingConfig.scala | 12 +++ .../hillclimbing/HillClimbingStrategy.scala | 10 +- .../IteratedLocalSearchConfig.scala | 11 ++- .../IteratedLocalSearchStrategy.scala | 35 ++++++- .../hillclimbing/HillClimbingMovesSuite.scala | 89 +++++++++++++++++ .../IteratedLocalSearchMovesSuite.scala | 95 +++++++++++++++++++ 9 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 scala-source/com/barrybecker4/optimization/strategy/hillclimbing/HillClimbingConfig.scala create mode 100644 scala-test/com/barrybecker4/optimization/strategy/hillclimbing/HillClimbingMovesSuite.scala create mode 100644 scala-test/com/barrybecker4/optimization/strategy/iteratedlocal/IteratedLocalSearchMovesSuite.scala diff --git a/scala-source/com/barrybecker4/optimization/strategy/gradient/DiscreteImprovementFinder.scala b/scala-source/com/barrybecker4/optimization/strategy/gradient/DiscreteImprovementFinder.scala index e621509..0d2f96e 100644 --- a/scala-source/com/barrybecker4/optimization/strategy/gradient/DiscreteImprovementFinder.scala +++ b/scala-source/com/barrybecker4/optimization/strategy/gradient/DiscreteImprovementFinder.scala @@ -19,7 +19,8 @@ object DiscreteImprovementFinder { */ class DiscreteImprovementFinder(var startingParams: ParameterArrayWithFitness, baselineParams: ParameterArray, - trace: String => Unit = _ => ()) extends ImprovementFinder { + trace: String => Unit = _ => (), + useMoves: Boolean = false) extends ImprovementFinder { /** Try to find a parameterArray that is better than what we have now by evaluating using the optimizee passed in. * Try swapping parameters randomly until we find an improvement (if we can). @@ -31,6 +32,39 @@ class DiscreteImprovementFinder(var startingParams: ParameterArrayWithFitness, def findIncrementalImprovement(optimizee: Optimizee, lastImprovement: Option[Improvement], cache: mutable.Set[ParameterArray]): Improvement = { + val current = lastImprovement.map(_.parameters).getOrElse(startingParams) + if (useMoves && !optimizee.evaluateByComparison && current.pa.supportsMoves) + findImprovementByMoves(optimizee, lastImprovement, current) + else findImprovementByBuilding(optimizee, lastImprovement, cache) + } + + /** + * The same search with moves: each try is a move, and a neighbor is built only for the one that improves. The cache is + * not consulted, so a move that was tried before is tried again, which costs a delta and not a neighbor. + */ + private def findImprovementByMoves(optimizee: Optimizee, + lastImprovement: Option[Improvement], + current: ParameterArrayWithFitness): Improvement = { + var numTries = 0 + var fitnessDelta = 1.0 + var jumpSize = lastImprovement.map(_.jumpSize * JUMP_SIZE_DECREASE).getOrElse(INITIAL_JUMP_SIZE) + var improvement = Improvement(current, 0, jumpSize) + while (fitnessDelta >= 0 && numTries < DISCRETE_MAX_TRIES) { + val move = current.pa.proposeMove(jumpSize) + val fitness = optimizee.evaluateMove(current.pa, current.fitness, move) + fitnessDelta = fitness - current.fitness + if (fitnessDelta < 0) + improvement = Improvement(ParameterArrayWithFitness(current.pa.applyMove(move), fitness), fitnessDelta, jumpSize) + numTries += 1 + jumpSize *= JUMP_SIZE_INCREASE + } + trace("incremental improvement = " + improvement + " numTries=" + numTries) + improvement + } + + private def findImprovementByBuilding(optimizee: Optimizee, + lastImprovement: Option[Improvement], + cache: mutable.Set[ParameterArray]): Improvement = { var numTries = 0 var fitnessDelta = 1.0 var jumpSize = lastImprovement.map(_.jumpSize * JUMP_SIZE_DECREASE).getOrElse(INITIAL_JUMP_SIZE) diff --git a/scala-source/com/barrybecker4/optimization/strategy/hillclimbing/GlobalHillClimbingConfig.scala b/scala-source/com/barrybecker4/optimization/strategy/hillclimbing/GlobalHillClimbingConfig.scala index 58f66d7..4261b5f 100644 --- a/scala-source/com/barrybecker4/optimization/strategy/hillclimbing/GlobalHillClimbingConfig.scala +++ b/scala-source/com/barrybecker4/optimization/strategy/hillclimbing/GlobalHillClimbingConfig.scala @@ -13,7 +13,8 @@ case class GlobalHillClimbingConfig( globalSampleBudget: Int = GlobalHillClimbingStrategy.DEFAULT_GLOBAL_SAMPLE_BUDGET, seedSelection: SeedSelection = SeedSelection.defaultDiverseForMultistart( GlobalHillClimbingStrategy.DEFAULT_MULTISTART_COUNT - ) + ), + useMoves: Boolean = false ) { require(multistartCount >= 1, s"multistartCount must be at least 1, got $multistartCount") diff --git a/scala-source/com/barrybecker4/optimization/strategy/hillclimbing/GlobalHillClimbingStrategy.scala b/scala-source/com/barrybecker4/optimization/strategy/hillclimbing/GlobalHillClimbingStrategy.scala index fbc72eb..e274f65 100644 --- a/scala-source/com/barrybecker4/optimization/strategy/hillclimbing/GlobalHillClimbingStrategy.scala +++ b/scala-source/com/barrybecker4/optimization/strategy/hillclimbing/GlobalHillClimbingStrategy.scala @@ -108,6 +108,7 @@ class GlobalHillClimbingStrategy(optimizee: Optimizee) extends OptimizationStrat private def climbFrom(start: ParameterArrayWithFitness): ParameterArrayWithFitness = { val climber = new HillClimbingStrategy(optimizee) + climber.setConfig(HillClimbingConfig(useMoves = config.useMoves)) copyRunConfigurationTo(climber) try climber.climb(start, params) catch { diff --git a/scala-source/com/barrybecker4/optimization/strategy/hillclimbing/HillClimbingConfig.scala b/scala-source/com/barrybecker4/optimization/strategy/hillclimbing/HillClimbingConfig.scala new file mode 100644 index 0000000..d50225b --- /dev/null +++ b/scala-source/com/barrybecker4/optimization/strategy/hillclimbing/HillClimbingConfig.scala @@ -0,0 +1,12 @@ +// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT +package com.barrybecker4.optimization.strategy.hillclimbing + +/** + * @param useMoves on a discrete problem, ask the optimizee for the fitness after a move + * ([[com.barrybecker4.optimization.optimizee.Optimizee.evaluateMove]]) and build a neighbor only if + * it improves, instead of building and evaluating every try. This is faster when the optimizee works + * out the new fitness without a full evaluation, and the array supports moves. It has a price: the + * cache of tries is not used, so a neighbor that was tried before may be evaluated again, which the + * default avoids. Off by default. + */ +final case class HillClimbingConfig(useMoves: Boolean = false) diff --git a/scala-source/com/barrybecker4/optimization/strategy/hillclimbing/HillClimbingStrategy.scala b/scala-source/com/barrybecker4/optimization/strategy/hillclimbing/HillClimbingStrategy.scala index 43c9d13..31cd69f 100644 --- a/scala-source/com/barrybecker4/optimization/strategy/hillclimbing/HillClimbingStrategy.scala +++ b/scala-source/com/barrybecker4/optimization/strategy/hillclimbing/HillClimbingStrategy.scala @@ -17,6 +17,14 @@ import scala.collection.mutable */ class HillClimbingStrategy(optimizee: Optimizee) extends OptimizationStrategy(optimizee) { + private var config: HillClimbingConfig = HillClimbingConfig() + + def getConfig: HillClimbingConfig = config + + def setConfig(newConfig: HillClimbingConfig): Unit = { + config = newConfig + } + /** Finds a local minimum. * It is a bit like Newton's method, but in n dimensions. * If we make a jump and find that we are worse off than before, we will backtrack and reduce the stepsize so @@ -85,7 +93,7 @@ class HillClimbingStrategy(optimizee: Optimizee) extends OptimizationStrategy(op case _: NumericParameterArray => new NumericImprovementFinder(params, baseline, useCache, msg => trace(msg)) case _: PermutedParameterArray | _: VariableLengthIntSet => - new DiscreteImprovementFinder(params, baseline, msg => trace(msg)) + new DiscreteImprovementFinder(params, baseline, msg => trace(msg), config.useMoves) case _ => throw new IllegalArgumentException("Unexpected params type: " + params.pa.getClass.getName) } } diff --git a/scala-source/com/barrybecker4/optimization/strategy/iteratedlocal/IteratedLocalSearchConfig.scala b/scala-source/com/barrybecker4/optimization/strategy/iteratedlocal/IteratedLocalSearchConfig.scala index a988651..28e3438 100644 --- a/scala-source/com/barrybecker4/optimization/strategy/iteratedlocal/IteratedLocalSearchConfig.scala +++ b/scala-source/com/barrybecker4/optimization/strategy/iteratedlocal/IteratedLocalSearchConfig.scala @@ -45,6 +45,14 @@ enum LocalSearchAcceptance { * @param acceptance whether to move to a local optimum that is not strictly better. * @param restartAfterStagnantIterations after this many rounds without a new best solution, jump to a random * solution and run a local search from there (the best is kept); 0 disables. + * @param useMoves ask the optimizee for the fitness after a move + * ([[com.barrybecker4.optimization.optimizee.Optimizee.evaluateMove]]) and build a + * neighbor only if it is better, instead of building and evaluating every neighbor. + * This is faster when the optimizee works out the new fitness without a full + * evaluation, and the array supports moves. It has a price: the search no longer + * remembers the neighbors it rejected, so one may be evaluated more than once, which + * the default promises not to do. Solutions that are accepted, shaken to or restarted + * from are still remembered. Off by default. */ final case class IteratedLocalSearchConfig( maxIterations: Int = 200, @@ -53,7 +61,8 @@ final case class IteratedLocalSearchConfig( minShakeLevel: Int = 3, maxShakeLevel: Int = 3, acceptance: LocalSearchAcceptance = LocalSearchAcceptance.BetterOrEqual, - restartAfterStagnantIterations: Int = 50 + restartAfterStagnantIterations: Int = 50, + useMoves: Boolean = false ) { require(maxIterations >= 0, s"maxIterations must not be negative, got $maxIterations") require(localRadius > 0.0 && localRadius <= 1.5, diff --git a/scala-source/com/barrybecker4/optimization/strategy/iteratedlocal/IteratedLocalSearchStrategy.scala b/scala-source/com/barrybecker4/optimization/strategy/iteratedlocal/IteratedLocalSearchStrategy.scala index 125cd78..ab53302 100644 --- a/scala-source/com/barrybecker4/optimization/strategy/iteratedlocal/IteratedLocalSearchStrategy.scala +++ b/scala-source/com/barrybecker4/optimization/strategy/iteratedlocal/IteratedLocalSearchStrategy.scala @@ -50,7 +50,8 @@ object IteratedLocalSearchStrategy { * The parameter array only offers random neighbors, not an enumeration of all neighbors, so "local optimum" means * `maxStaleNeighbors` failed tries in a row; for a very large neighborhood, raise it. Every distinct solution is * evaluated at most once: a shake or neighbor that was already visited is skipped (which also stops a local search - * from walking back into a local optimum that was already explored). + * from walking back into a local optimum that was already explored). That promise is dropped when + * [[IteratedLocalSearchConfig.useMoves]] is on, which is off by default. * * '''Comparison mode (`evaluateByComparison == true`):''' like [[HillClimbingStrategy]], a neighbor is an improvement * when `compareFitness(neighbor, current) < 0`, and the reported fitness of a solution is @@ -108,7 +109,12 @@ class IteratedLocalSearchStrategy(optimizee: Optimizee, rnd: Random = MathUtil.R } /** First improvement descent over random neighbors; stops after `maxStaleNeighbors` failed tries in a row. */ - def localSearch(start: ParameterArrayWithFitness): ParameterArrayWithFitness = { + def localSearch(start: ParameterArrayWithFitness): ParameterArrayWithFitness = + if (cfg.useMoves && canUseMoves(start.pa)) localSearchByMoves(start) else localSearchByBuilding(start) + + def canUseMoves(pa: ParameterArray): Boolean = !optimizee.evaluateByComparison && pa.supportsMoves + + def localSearchByBuilding(start: ParameterArrayWithFitness): ParameterArrayWithFitness = { var current = start var staleNeighbors = 0 while (staleNeighbors < cfg.maxStaleNeighbors && !isOptimalFitnessReached(current)) { @@ -125,6 +131,31 @@ class IteratedLocalSearchStrategy(optimizee: Optimizee, rnd: Random = MathUtil.R current } + /** The same descent, trying moves. A neighbor is built, and remembered, only when it improves on the current one. */ + def localSearchByMoves(start: ParameterArrayWithFitness): ParameterArrayWithFitness = { + var current = start + var staleNeighbors = 0 + while (staleNeighbors < cfg.maxStaleNeighbors && !isOptimalFitnessReached(current)) { + val move = current.pa.proposeMove(cfg.localRadius) + val fitness = noNaN(optimizee.evaluateMove(current.pa, current.fitness, move)) + if (fitness < current.fitness) { + current = keepNeighbor(current.pa.applyMove(move), fitness) + staleNeighbors = 0 + } else staleNeighbors += 1 + } + current + } + + /** A NaN must never look like an improvement. */ + def noNaN(fitness: Double): Double = if (fitness.isNaN) Double.PositiveInfinity else fitness + + def keepNeighbor(neighbor: ParameterArray, fitness: Double): ParameterArrayWithFitness = { + visited += StateSpaceSearchStrategy.stateKey(neighbor) + val scored = ParameterArrayWithFitness(neighbor, fitness) + recordBest(scored) + scored + } + /** @return the evaluated shaken solution, or None if the shake landed on a solution that was already visited. */ def shake(from: ParameterArrayWithFitness, level: Int): Option[ParameterArrayWithFitness] = { var shaken = from.pa diff --git a/scala-test/com/barrybecker4/optimization/strategy/hillclimbing/HillClimbingMovesSuite.scala b/scala-test/com/barrybecker4/optimization/strategy/hillclimbing/HillClimbingMovesSuite.scala new file mode 100644 index 0000000..93dc34c --- /dev/null +++ b/scala-test/com/barrybecker4/optimization/strategy/hillclimbing/HillClimbingMovesSuite.scala @@ -0,0 +1,89 @@ +// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT +package com.barrybecker4.optimization.strategy.hillclimbing + +import com.barrybecker4.optimization.benchmark.RandomTourProblem +import com.barrybecker4.optimization.optimizee.{BudgetedOptimizee, DeltaCheckingOptimizee, EvaluationBudgetExceededException} +import com.barrybecker4.optimization.parameter.{Move, ParameterArray, PermutedParameterArray, PuzzleLikePath} +import org.scalatest.funsuite.AnyFunSuite + +import scala.util.Random + +/** With `useMoves`, each try is a move, and a neighbor is built only for the move that improves. Tries are not remembered. */ +class HillClimbingMovesSuite extends AnyFunSuite { + + private class CountingTour(cities: Int, seed: Long) extends RandomTourProblem(cities, seed) { + var moveEvaluations = 0 + override def evaluateMove(tour: ParameterArray, length: Double, move: Move): Double = { + moveEvaluations += 1 + super.evaluateMove(tour, length, move) + } + } + + private def start(problem: RandomTourProblem, seed: Long): ParameterArray = + problem.getInitialGuess.asInstanceOf[PermutedParameterArray].forkWithRnd(new Random(seed)) + + private def climb(optimizee: com.barrybecker4.optimization.optimizee.Optimizee, from: ParameterArray, useMoves: Boolean) = { + val strategy = new HillClimbingStrategy(optimizee) + strategy.setConfig(HillClimbingConfig(useMoves = useMoves)) + strategy.doOptimization(from, 100.0) + } + + test("moves are off by default") { + assert(!HillClimbingConfig().useMoves && !GlobalHillClimbingConfig().useMoves) + assert(!new HillClimbingStrategy(new RandomTourProblem(10, 1)).getConfig.useMoves) + } + + test("with moves the fitness returned is the length of the tour returned, with every delta checked") { + for (seed <- 1L to 4L) { + val problem = new RandomTourProblem(30, seed) + val result = climb(new DeltaCheckingOptimizee(problem), start(problem, seed), useMoves = true) + assert(Math.abs(result.fitness - problem.evaluateFitness(result.pa)) < 1e-9, s"seed $seed") + assert(result.fitness < problem.evaluateFitness(start(problem, seed))) + } + } + + test("with moves the climb asks for move evaluations") { + val problem = new CountingTour(40, 6) + climb(problem, start(problem, 6), useMoves = true) + assert(problem.moveEvaluations > 100) + } + + test("without moves it asks for none") { + val problem = new CountingTour(40, 6) + climb(problem, start(problem, 6), useMoves = false) + assertResult(0)(problem.moveEvaluations) + } + + test("with moves the climb ends at a tour about as good as without, over several seeds") { + def median(useMoves: Boolean) = { + val ratios = (1L to 7L).map { seed => + val problem = new RandomTourProblem(30, seed) + climb(problem, start(problem, seed), useMoves).fitness / problem.evaluateFitness(start(problem, seed)) + }.sorted + ratios(ratios.size / 2) + } + assert(median(useMoves = true) < median(useMoves = false) * 1.15) + } + + test("moves count against the evaluation budget") { + val problem = new RandomTourProblem(30, 2) + val budgeted = new BudgetedOptimizee(problem, maxEvaluations = 200) + intercept[EvaluationBudgetExceededException](climb(budgeted, start(problem, 2), useMoves = true)) + assertResult(200)(budgeted.evaluationsUsed) + } + + test("an array that picks its own neighbors keeps doing so even when moves are asked for") { + val problem = new RandomTourProblem(20, 4) + val path = new PuzzleLikePath(problem.getInitialGuess.asInstanceOf[PermutedParameterArray].params, "route", new Random(4)) + val result = climb(problem, path, useMoves = true) + assert(path.neighborsAsked > 20 && result.pa.isInstanceOf[PuzzleLikePath]) + } + + test("global hill climbing passes the setting to each climb") { + val problem = new CountingTour(30, 3) + val strategy = new GlobalHillClimbingStrategy(problem) + strategy.setConfig(GlobalHillClimbingConfig(multistartCount = 2, globalSampleBudget = 40, useMoves = true)) + strategy.doOptimization(start(problem, 3), 100.0) + assert(problem.moveEvaluations > 50) + } +} diff --git a/scala-test/com/barrybecker4/optimization/strategy/iteratedlocal/IteratedLocalSearchMovesSuite.scala b/scala-test/com/barrybecker4/optimization/strategy/iteratedlocal/IteratedLocalSearchMovesSuite.scala new file mode 100644 index 0000000..ceb2333 --- /dev/null +++ b/scala-test/com/barrybecker4/optimization/strategy/iteratedlocal/IteratedLocalSearchMovesSuite.scala @@ -0,0 +1,95 @@ +// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT +package com.barrybecker4.optimization.strategy.iteratedlocal + +import com.barrybecker4.optimization.benchmark.RandomTourProblem +import com.barrybecker4.optimization.optimizee.{BudgetedOptimizee, DeltaCheckingOptimizee, Optimizee} +import com.barrybecker4.optimization.parameter.{Move, ParameterArray, ParameterArrayWithFitness, PermutedParameterArray, PuzzleLikePath} +import org.scalatest.funsuite.AnyFunSuite + +import scala.util.Random + +/** + * With `useMoves`, a local search asks for the fitness after a move and builds a neighbor only if it is better. The + * price is that it no longer remembers every neighbor it rejected, so it may evaluate one again. + */ +class IteratedLocalSearchMovesSuite extends AnyFunSuite { + + private class CountingTour(cities: Int, seed: Long) extends RandomTourProblem(cities, seed) { + var moveEvaluations = 0 + val evaluated = scala.collection.mutable.ArrayBuffer[Seq[Int]]() + override def evaluateFitness(tour: ParameterArray): Double = { + evaluated += (0 until tour.size).map(tour.get(_).getValue.toInt) + super.evaluateFitness(tour) + } + override def evaluateMove(tour: ParameterArray, length: Double, move: Move): Double = { + moveEvaluations += 1 + super.evaluateMove(tour, length, move) + } + } + + private def start(problem: RandomTourProblem, seed: Long): ParameterArray = + problem.getInitialGuess.asInstanceOf[PermutedParameterArray].forkWithRnd(new Random(seed)) + + private def search(optimizee: Optimizee, from: ParameterArray, range: Double, config: IteratedLocalSearchConfig, + seed: Long = 1): ParameterArrayWithFitness = + IteratedLocalSearchStrategy.strategyFor(optimizee, config, new Random(seed)).doOptimization(from, range) + + private val quick = IteratedLocalSearchConfig(maxIterations = 30, maxStaleNeighbors = 40) + + test("moves are off by default, so a distinct solution is still evaluated at most once") { + assert(!IteratedLocalSearchConfig.Default.useMoves && !IteratedLocalSearchConfig.VariableNeighborhoodSearch.useMoves) + val problem = new CountingTour(12, 3) + search(problem, start(problem, 3), problem.getFitnessRange, quick) + assertResult(0)(problem.moveEvaluations) + assertResult(problem.evaluated.size)(problem.evaluated.distinct.size) + } + + test("with moves the fitness returned is the length of the tour returned, with every delta checked") { + for (seed <- 1L to 4L) { + val problem = new RandomTourProblem(25, seed) + val result = search(new DeltaCheckingOptimizee(problem), start(problem, seed), problem.getFitnessRange, + quick.copy(useMoves = true), seed) + assert(Math.abs(result.fitness - problem.evaluateFitness(result.pa)) < 1e-9, s"seed $seed") + assert(result.fitness < problem.evaluateFitness(start(problem, seed))) + } + } + + test("with moves the search asks for move evaluations rather than evaluating each neighbor") { + val problem = new CountingTour(40, 5) + search(problem, start(problem, 5), problem.getFitnessRange, quick.copy(useMoves = true), 5) + assert(problem.moveEvaluations > 200, s"${problem.moveEvaluations} move evaluations") + } + + test("with moves the quality is about what it is without, over several seeds") { + def median(useMoves: Boolean) = { + val lengths = (1L to 7L).map { seed => + val problem = new RandomTourProblem(30, seed) + search(problem, start(problem, seed), problem.getFitnessRange, quick.copy(useMoves = useMoves), seed).fitness / problem.evaluateFitness(start(problem, seed)) + }.sorted + lengths(lengths.size / 2) + } + assert(median(useMoves = true) < median(useMoves = false) * 1.15) + } + + test("moves count against the evaluation budget and the best so far comes back") { + val problem = new RandomTourProblem(30, 2) + val budgeted = new BudgetedOptimizee(problem, maxEvaluations = 300) + val result = search(budgeted, start(problem, 2), problem.getFitnessRange, quick.copy(useMoves = true), 2) + assert(budgeted.wasBudgetExhausted && budgeted.evaluationsUsed == 300) + assert(result.fitness <= problem.evaluateFitness(start(problem, 2))) + } + + test("an array that picks its own neighbors keeps doing so even when moves are asked for") { + val problem = new RandomTourProblem(20, 4) + val path = new PuzzleLikePath(problem.getInitialGuess.asInstanceOf[PermutedParameterArray].params, "route", new Random(4)) + val result = search(problem, path, problem.getFitnessRange, quick.copy(useMoves = true), 4) + assert(path.neighborsAsked > 50) + assert(result.pa.isInstanceOf[PuzzleLikePath]) + } + + test("an unvisited start and shakes are still not repeated: an accepted solution is remembered") { + val problem = new CountingTour(10, 8) + search(problem, start(problem, 8), problem.getFitnessRange, quick.copy(useMoves = true), 8) + assert(problem.evaluated.size < 50, "only the starting point, shakes and restarts are evaluated in full") + } +} From 68f1cfd0188832b2a2ce83c4a1c925ab6d34dffe Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 04:39:28 -0700 Subject: [PATCH 2/2] Benchmark option --moves, and record the opt-in moves results in the plan Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0175jMLxrP7wZ3vfj6UfeDZx --- docs/improvement-plan.md | 40 +++++++++++++++---- .../hillclimbing/HillClimbingConfig.scala | 4 ++ .../benchmark/BenchmarkConfig.scala | 11 +++-- .../benchmark/BenchmarkConfigSuite.scala | 7 ++++ .../optimization/benchmark/BenchmarkRun.scala | 17 +++++++- .../benchmark/OptimizationBenchmark.scala | 3 +- .../OptimizationBenchmarkSuite.scala | 2 +- 7 files changed, 70 insertions(+), 14 deletions(-) diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md index b923875..dba37e5 100644 --- a/docs/improvement-plan.md +++ b/docs/improvement-plan.md @@ -121,13 +121,39 @@ Choices worth knowing about: evaluation at 50 and 200 cities (it made genetic search about half as fast at 50), so it was removed. A `SwapSequence` falls back to the default. A problem with an expensive full evaluation may well want one. +Opt-in for iterated local search and hill climbing (`useMoves`, off by default): + +Iterated local search promises to evaluate every distinct solution at most once, which needs the neighbor's key, and +building the key costs as much as building the neighbor. Hill climbing has the same kind of cache. So these are +opt-in: `IteratedLocalSearchConfig.useMoves`, `HillClimbingConfig.useMoves` and `GlobalHillClimbingConfig.useMoves`. +With them on, a try is a move scored with `evaluateMove`, and a neighbor is built (and remembered) only when it +improves. Rejected neighbors are not remembered, so one can be evaluated again. On the benchmark tours that almost never +happened: the median fitness and the median evaluation count are the same with and without. The benchmark takes +`--moves=true` for this, and then writes no summary unless `--report` is given. + +Medians of 5 seeds, 5000 ms, evals/sec (fitness reached is the same in every pair): + +| Problem | Strategy | default | with moves | +| --- | --- | ---: | ---: | +| tour-50 | ITERATED_LOCAL_SEARCH | 529,391 | 949,426 | +| tour-50 | VARIABLE_NEIGHBORHOOD_SEARCH | 518,843 | 1,183,965 | +| tour-200 | ITERATED_LOCAL_SEARCH | 234,729 | 373,412 | +| tour-200 | VARIABLE_NEIGHBORHOOD_SEARCH | 254,829 | 338,822 | +| tour-50 | HILL_CLIMBING | 89,957 | 50,621 | +| tour-50 | GLOBAL_HILL_CLIMBING | 142,903 | 96,506 | +| tour-200 | HILL_CLIMBING | 34,072 | 34,627 | +| tour-200 | GLOBAL_HILL_CLIMBING | 46,735 | 53,741 | + +Iterated local search gains, even though its neighbors are about n/20 swaps and the tour problem has no delta for several +swaps: it skips the visited-key work and does not build neighbors it rejects. Hill climbing does not gain, and is slower on the +short tour. After the first few tries it widens its radius until each try is a shuffle, which as a move is a list of +n-1 swaps, more expensive to propose and apply than the direct shuffle, and no optimizee has a cheap delta for it. Turn +it on for hill climbing only if the optimizee has a delta for large moves. The benefit for iterated local search also +grows with how cheap the optimizee's delta for a few swaps is, which is where the Red puzzle should do better than +this tour. + Not done, and why: -- **Iterated local search and hill climbing.** Iterated local search promises to evaluate every distinct solution at - most once, which needs the neighbor's key, and building the key costs as much as building the neighbor. Hill climbing - has the same kind of cache, and most of its time is spent on huge-radius neighbors, which are shuffles rather than - swaps. Taking this on means choosing between that promise and speed (an opt-in setting that allows re-evaluation, or an - incrementally updated hash of the permutation). Left for a decision. - **Non-greedy genetic mutation and crossover.** These need the child array to remove duplicates, and crossover has no small move. - **Numeric and subset moves.** The `Move` trait allows them; only permutations are implemented. @@ -169,8 +195,8 @@ evaluations are crossover children, which have no delta. 1. Phase 0: benchmark harness, baseline numbers, this plan (done). 2. Phase 1: neighbor, identity and cache, crossover, `compareFitness` audit (done, one PR). -3. Phase 2: Move API and the permutation swap; simulated annealing; genetic greedy mutation (done, one PR). - Iterated local search and hill climbing are waiting on a decision, see Phase 2. +3. Phase 2: Move API and the permutation swap; simulated annealing; genetic greedy mutation (done, one PR); + opt-in moves for iterated local search and hill climbing (done, a second PR). 4. Phase 3: crossover hook; then repair; then guided moves. 5. Release, then the bb4-puzzles adoption PRs. diff --git a/scala-source/com/barrybecker4/optimization/strategy/hillclimbing/HillClimbingConfig.scala b/scala-source/com/barrybecker4/optimization/strategy/hillclimbing/HillClimbingConfig.scala index d50225b..2c53f0b 100644 --- a/scala-source/com/barrybecker4/optimization/strategy/hillclimbing/HillClimbingConfig.scala +++ b/scala-source/com/barrybecker4/optimization/strategy/hillclimbing/HillClimbingConfig.scala @@ -8,5 +8,9 @@ package com.barrybecker4.optimization.strategy.hillclimbing * out the new fitness without a full evaluation, and the array supports moves. It has a price: the * cache of tries is not used, so a neighbor that was tried before may be evaluated again, which the * default avoids. Off by default. + * + * On the benchmark tours this did not help hill climbing and was slower on short ones: after a few + * failed tries the radius is so large that a try is a shuffle, and no optimizee has a cheap delta + * for that. Turn it on only if yours has a delta for large moves. */ final case class HillClimbingConfig(useMoves: Boolean = false) diff --git a/scala-test/com/barrybecker4/optimization/benchmark/BenchmarkConfig.scala b/scala-test/com/barrybecker4/optimization/benchmark/BenchmarkConfig.scala index b31813d..1674bbd 100644 --- a/scala-test/com/barrybecker4/optimization/benchmark/BenchmarkConfig.scala +++ b/scala-test/com/barrybecker4/optimization/benchmark/BenchmarkConfig.scala @@ -20,7 +20,8 @@ object BenchmarkConfig { /** * Reads options of the form `--name=value`. The names are time-ms, max-evals, seeds, problems, strategies - * (the last two are comma separated lists), report (the Markdown summary file, or `none`) and out (a file to + * (the last two are comma separated lists), moves (true to turn on the opt-in moves of iterated local search and hill + * climbing, which also stops the summary being written unless `report` is given), report (the Markdown summary file, or `none`) and out (a file to * write the tab separated results of every run to). * * A full run (all problems and the default strategies) writes the summary to [[DEFAULT_REPORT_FILE]]. A run @@ -32,6 +33,7 @@ object BenchmarkConfig { val problemNames = options.get("problems").map(_.split(",").toSeq).getOrElse(Seq.empty) val strategies = options.get("strategies").map(_.split(",").toSeq.map(OptimizationStrategyType.valueOf)) .getOrElse(DEFAULT_STRATEGIES) + val useMoves = options.get("moves").exists(_.toBoolean) BenchmarkConfig( timeLimitMillis = options.get("time-ms").map(_.toLong).getOrElse(DEFAULT_TIME_LIMIT_MILLIS), maxEvaluations = options.get("max-evals").map(_.toLong).getOrElse(DEFAULT_MAX_EVALUATIONS), @@ -39,7 +41,8 @@ object BenchmarkConfig { problemNames = problemNames, strategies = strategies, outputFile = options.get("out"), - reportFile = reportFile(options.get("report"), isFullRun = problemNames.isEmpty && strategies == DEFAULT_STRATEGIES)) + reportFile = reportFile(options.get("report"), isFullRun = problemNames.isEmpty && strategies == DEFAULT_STRATEGIES && !useMoves), + useMoves = useMoves) } private def reportFile(requested: Option[String], isFullRun: Boolean): Option[String] = requested match { @@ -57,7 +60,9 @@ object BenchmarkConfig { * @param strategies the strategies to run * @param outputFile where to write the results of every run as tab separated values, if anywhere * @param reportFile where to write the summary as a Markdown document, if anywhere + * @param useMoves turn on the opt-in `useMoves` setting of iterated local search, hill climbing and global hill + * climbing (the other strategies use moves already, or do not support them) */ case class BenchmarkConfig(timeLimitMillis: Long, maxEvaluations: Long, numSeeds: Int, problemNames: Seq[String], strategies: Seq[OptimizationStrategyType], - outputFile: Option[String], reportFile: Option[String]) + outputFile: Option[String], reportFile: Option[String], useMoves: Boolean) diff --git a/scala-test/com/barrybecker4/optimization/benchmark/BenchmarkConfigSuite.scala b/scala-test/com/barrybecker4/optimization/benchmark/BenchmarkConfigSuite.scala index cd549fc..69fac6a 100644 --- a/scala-test/com/barrybecker4/optimization/benchmark/BenchmarkConfigSuite.scala +++ b/scala-test/com/barrybecker4/optimization/benchmark/BenchmarkConfigSuite.scala @@ -41,6 +41,13 @@ class BenchmarkConfigSuite extends AnyFunSuite { assertResult(Some("results.tsv"))(config.outputFile) } + test("moves are off unless asked for, and a run with them on writes no summary unless told to") { + assert(!BenchmarkConfig.parse(Seq.empty).useMoves) + val config = BenchmarkConfig.parse(Seq("--moves=true")) + assert(config.useMoves && config.reportFile.isEmpty) + assertResult(Some("moves.md"))(BenchmarkConfig.parse(Seq("--moves=true", "--report=moves.md")).reportFile) + } + test("anything that is not an option is ignored") { assertResult(BenchmarkConfig.DEFAULT_NUM_SEEDS)(BenchmarkConfig.parse(Seq("extra", "--unknown")).numSeeds) } diff --git a/scala-test/com/barrybecker4/optimization/benchmark/BenchmarkRun.scala b/scala-test/com/barrybecker4/optimization/benchmark/BenchmarkRun.scala index da0be8d..e41b008 100644 --- a/scala-test/com/barrybecker4/optimization/benchmark/BenchmarkRun.scala +++ b/scala-test/com/barrybecker4/optimization/benchmark/BenchmarkRun.scala @@ -3,7 +3,9 @@ package com.barrybecker4.optimization.benchmark import com.barrybecker4.math.MathUtil import com.barrybecker4.optimization.optimizee.EvaluationBudgetExceededException -import com.barrybecker4.optimization.strategy.OptimizationStrategyType +import com.barrybecker4.optimization.strategy.{OptimizationStrategy, OptimizationStrategyType} +import com.barrybecker4.optimization.strategy.hillclimbing.{GlobalHillClimbingStrategy, HillClimbingConfig, HillClimbingStrategy} +import com.barrybecker4.optimization.strategy.iteratedlocal.IteratedLocalSearchStrategy import scala.util.Random @@ -40,7 +42,7 @@ object BenchmarkRun { strategyType: OptimizationStrategyType, seed: Long, config: BenchmarkConfig): RunResult = { val start = System.nanoTime() val limited = new LimitedOptimizee(problem, config.maxEvaluations, start + config.timeLimitMillis * NANOS_PER_MILLI) - val strategy = strategyType.getStrategy(limited, problem.getFitnessRange, new Random(seed)) + val strategy = withMovesIfAsked(strategyType.getStrategy(limited, problem.getFitnessRange, new Random(seed)), config) val allocatedBefore = AllocationMeter.bytesAllocatedSoFar val fitness = bestFitness(strategy, initialGuess, problem.getFitnessRange) val millis = (System.nanoTime() - start) / NANOS_PER_MILLI.toDouble @@ -49,6 +51,17 @@ object BenchmarkRun { allocated.filter(_ => !MULTI_THREADED.contains(strategyType)), limited.stoppedBy) } + /** Turns on the opt-in moves of the strategies that have that setting, when the benchmark is asked to. */ + private def withMovesIfAsked(strategy: OptimizationStrategy, config: BenchmarkConfig): OptimizationStrategy = { + if (config.useMoves) strategy match { + case s: IteratedLocalSearchStrategy => s.setConfig(s.getConfig.copy(useMoves = true)) + case s: GlobalHillClimbingStrategy => s.setConfig(s.getConfig.copy(useMoves = true)) + case s: HillClimbingStrategy => s.setConfig(HillClimbingConfig(useMoves = true)) + case _ => () + } + strategy + } + /** The best fitness found, even when the search was stopped before it finished. */ private def bestFitness(strategy: com.barrybecker4.optimization.strategy.OptimizationStrategy, initialGuess: com.barrybecker4.optimization.parameter.ParameterArray, range: Double): Double = diff --git a/scala-test/com/barrybecker4/optimization/benchmark/OptimizationBenchmark.scala b/scala-test/com/barrybecker4/optimization/benchmark/OptimizationBenchmark.scala index d0428f4..100d2bb 100644 --- a/scala-test/com/barrybecker4/optimization/benchmark/OptimizationBenchmark.scala +++ b/scala-test/com/barrybecker4/optimization/benchmark/OptimizationBenchmark.scala @@ -53,5 +53,6 @@ object OptimizationBenchmark { def machineDescription(config: BenchmarkConfig): String = s"Java ${System.getProperty("java.version")}, ${Runtime.getRuntime.availableProcessors} processors, " + - s"${config.numSeeds} seeds, ${config.timeLimitMillis} ms or ${config.maxEvaluations} evaluations per run" + s"${config.numSeeds} seeds, ${config.timeLimitMillis} ms or ${config.maxEvaluations} evaluations per run" + + (if (config.useMoves) ", opt-in moves on" else "") } diff --git a/scala-test/com/barrybecker4/optimization/benchmark/OptimizationBenchmarkSuite.scala b/scala-test/com/barrybecker4/optimization/benchmark/OptimizationBenchmarkSuite.scala index c765f42..8b6248c 100644 --- a/scala-test/com/barrybecker4/optimization/benchmark/OptimizationBenchmarkSuite.scala +++ b/scala-test/com/barrybecker4/optimization/benchmark/OptimizationBenchmarkSuite.scala @@ -8,7 +8,7 @@ import org.scalatest.funsuite.AnyFunSuite class OptimizationBenchmarkSuite extends AnyFunSuite { private val quick = BenchmarkConfig(timeLimitMillis = 150, maxEvaluations = 200_000, numSeeds = 2, - problemNames = Seq("tour-50"), strategies = Seq(SIMULATED_ANNEALING, GENETIC_SEARCH), outputFile = None, reportFile = None) + problemNames = Seq("tour-50"), strategies = Seq(SIMULATED_ANNEALING, GENETIC_SEARCH), outputFile = None, reportFile = None, useMoves = false) test("every problem is a different name") { assertResult(BenchmarkProblems.ALL.size)(BenchmarkProblems.ALL.map(_.name).distinct.size)