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
40 changes: 33 additions & 7 deletions docs/improvement-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// 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.
*
* 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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,14 +33,16 @@ 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),
numSeeds = options.get("seeds").map(_.toInt).getOrElse(DEFAULT_NUM_SEEDS),
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 {
Expand All @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading