diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md index b923875..0204828 100644 --- a/docs/improvement-plan.md +++ b/docs/improvement-plan.md @@ -1,6 +1,6 @@ # Plan: faster, more reliable local search in bb4-optimization -Status: Phases 0 (baseline and benchmark), 1 (fewer allocations) and 2 (delta evaluation, for permutations) are done. Phases 3 and 4 are not started. +Status: Phases 0 (baseline and benchmark), 1 (fewer allocations), 2 (delta evaluation, for permutations) and 3 (structure hooks) are done. Phase 4 is not started. Decisions: new APIs are added in 2.1-SNAPSHOT (additive, defaults keep today's behavior). The first user of the changes is the Red puzzle in bb4-puzzles, then Tantrix. @@ -147,7 +147,7 @@ so the full run understates it. The gain grows with the tour length, since the f is O(1). A tour of 50 is short enough that other costs dominate. Genetic search gains little because most of its evaluations are crossover children, which have no delta. -## Phase 3: structure hooks (limited, additive) +## Phase 3: structure hooks (done, additive) - Replace the closed crossover match with `ParameterArray.crossWith(other, rnd)`. The default keeps today's behavior. A subclass can override it for type-aware crossover, as discussed for Tantrix. @@ -158,6 +158,48 @@ evaluations are crossover children, which have no delta. - Scope: this makes the metaheuristic smarter. It is not constraint propagation or backtracking, which stay in the exact solvers. +### What Phase 3 added + +All three hooks default to today's behavior, so a problem that overrides nothing runs exactly as before. + +- `ParameterArray.crossWith(other, rnd)`. The default calls the old crossover. Genetic search calls it for every pair. +- `Optimizee.repair(candidate)`. The default returns the same instance, which is the contract for "nothing to fix". + Genetic search repairs crossover children, non-greedy mutants, greedy mutants (built or from a move) and the + initial population. A greedy mutant found by a move has a fitness for the array as built, so when `repair` returns + a different instance the repaired array is scored again, in full. The wrappers (`BudgetedOptimizee`, + `DeltaCheckingOptimizee`, the benchmark's `LimitedOptimizee`) forward it and do not count it as an evaluation. +- `Optimizee.proposeGuidedMove(current, radius, rnd)`, returning an `Option[Move]`, and + `guidedMoveProbability` (default 0, validated to [0, 1]) in `SimulatedAnnealingConfig` and `GeneticSearchConfig`. + `MoveChoice.propose` asks the problem with that probability and uses a random move when it has nothing to + suggest. With a probability of 0 it draws no random number, so seeded runs are unchanged. It applies only on the + move paths, so it needs `supportsMoves` and absolute fitness. + +### Does guiding help? A first look + +`RandomTourProblem` suggests a long-leg move: sample 3 legs, take the longest, and swap the city at its end with a +random position. Median best tour length over 7 seeds, 4 s or 3M evaluations per run, lower is better, 2-core VM +(a scratch run, not part of the committed benchmark): + +| Strategy, cities | p = 0 | 0.1 | 0.3 | 0.5 | 0.8 | +|---|---|---|---|---|---| +| Annealing, 100 | 12.9 | 13.3 | 13.4 | 13.6 | 13.3 | +| Annealing, 200 | 26.5 | 25.8 | 24.3 | 24.3 | 23.4 | +| Genetic, 100 | 12.5 | 11.8 | 12.0 | 12.1 | 12.0 | +| Genetic, 200 | 35.4 | 33.0 | 34.4 | 32.5 | 32.6 | + +The effect is real but modest and depends on the problem: about 5 to 12 percent better for annealing on 200 cities, +3 to 8 percent for genetic search, and no gain (within the noise) for annealing on 100 cities, where the run already +finishes its schedule. The hook is a way to put a problem's knowledge to use. The numbers say that a guide is worth +writing when it targets real defects (like the long leg here), and that it should be tried with a probability around +0.5 rather than assumed to help. + +### Not done + +- Iterated local search, hill climbing and the variable neighborhood search do not use guided moves or repair. They + would need the same plumbing as `useMoves` (see Phase 2), and are best added with the real puzzles in Phase 4. +- Repair is not applied by simulated annealing, which has no crossover and moves from a feasible state by small steps. + A problem whose moves can leave the feasible set should keep them feasible in `getRandomNeighbor`. + ## Phase 4: adopt in bb4-puzzles (after a release) - Red puzzle: a swap or rotation touches at most 8 edges, so its fitness delta is a handful of comparisons. Add repair @@ -171,7 +213,7 @@ evaluations are crossover children, which have no delta. 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. -4. Phase 3: crossover hook; then repair; then guided moves. +4. Phase 3: crossover hook; then repair; then guided moves (done, one PR, three commits). 5. Release, then the bb4-puzzles adoption PRs. ## Risks diff --git a/scala-source/com/barrybecker4/optimization/optimizee/BudgetedOptimizee.scala b/scala-source/com/barrybecker4/optimization/optimizee/BudgetedOptimizee.scala index 88ab563..f0159b9 100644 --- a/scala-source/com/barrybecker4/optimization/optimizee/BudgetedOptimizee.scala +++ b/scala-source/com/barrybecker4/optimization/optimizee/BudgetedOptimizee.scala @@ -2,6 +2,8 @@ package com.barrybecker4.optimization.optimizee import com.barrybecker4.optimization.parameter.{Move, ParameterArray} + +import scala.util.Random import com.barrybecker4.optimization.strategy.statespace.StateSpaceSearchStrategy /** @@ -48,6 +50,12 @@ class BudgetedOptimizee(delegate: Optimizee, val maxEvaluations: Int) extends Op override def getOptimalFitness: Double = delegate.getOptimalFitness + /** Not an evaluation, so it does not use up the budget. */ + 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) + private def bump(): Unit = synchronized { if (count >= maxEvaluations) { budgetExhausted = true diff --git a/scala-source/com/barrybecker4/optimization/optimizee/DeltaCheckingOptimizee.scala b/scala-source/com/barrybecker4/optimization/optimizee/DeltaCheckingOptimizee.scala index 463dc08..ec36917 100644 --- a/scala-source/com/barrybecker4/optimization/optimizee/DeltaCheckingOptimizee.scala +++ b/scala-source/com/barrybecker4/optimization/optimizee/DeltaCheckingOptimizee.scala @@ -3,6 +3,8 @@ package com.barrybecker4.optimization.optimizee import com.barrybecker4.optimization.parameter.{Move, ParameterArray} +import scala.util.Random + /** * Wraps an optimizee and checks every [[evaluateMove]] against a full evaluation of the array after the move. A wrong * delta would silently steer a search the wrong way, so use this in tests, and while writing a delta, but not in @@ -21,6 +23,9 @@ class DeltaCheckingOptimizee(delegate: Optimizee, tolerance: Double = 1e-9) exte override def compareFitness(params1: ParameterArray, params2: ParameterArray): Double = delegate.compareFitness(params1, params2) 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 evaluateMove(current: ParameterArray, currentFitness: Double, move: Move): Double = { checkCurrentFitness(current, currentFitness, move) diff --git a/scala-source/com/barrybecker4/optimization/optimizee/MoveChoice.scala b/scala-source/com/barrybecker4/optimization/optimizee/MoveChoice.scala new file mode 100644 index 0000000..03229c8 --- /dev/null +++ b/scala-source/com/barrybecker4/optimization/optimizee/MoveChoice.scala @@ -0,0 +1,26 @@ +// 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 + +/** + * Chooses between a random move and one the problem suggests. With a guided probability of 0 no random number is + * drawn here, so a run that does not use guided moves sees the same random stream as before they existed. + */ +object MoveChoice { + + /** + * @param guidedProbability the chance, in [0, 1], of asking the problem for a move + * @return the problem's move when it was asked and had one, otherwise a random move of the given radius + */ + def propose(optimizee: Optimizee, current: ParameterArray, radius: Double, + guidedProbability: Double, rnd: Random): Move = + if (shouldAskProblem(guidedProbability, rnd)) + optimizee.proposeGuidedMove(current, radius, rnd).getOrElse(current.proposeMove(radius)) + else current.proposeMove(radius) + + private def shouldAskProblem(guidedProbability: Double, rnd: Random): Boolean = + guidedProbability > 0.0 && rnd.nextDouble() < guidedProbability +} diff --git a/scala-source/com/barrybecker4/optimization/optimizee/Optimizee.scala b/scala-source/com/barrybecker4/optimization/optimizee/Optimizee.scala index 14d016b..4d46d71 100644 --- a/scala-source/com/barrybecker4/optimization/optimizee/Optimizee.scala +++ b/scala-source/com/barrybecker4/optimization/optimizee/Optimizee.scala @@ -3,6 +3,8 @@ package com.barrybecker4.optimization.optimizee import com.barrybecker4.optimization.parameter.{Move, ParameterArray} +import scala.util.Random + /** * This interface needs to be implemented for any object you wish to have optimized. @@ -54,6 +56,30 @@ trait Optimizee { def evaluateMove(current: ParameterArray, currentFitness: Double, move: Move): Double = evaluateFitness(current.applyMove(move)) + /** + * Lets the problem put right a candidate that a strategy made by crossover or mutation, when it is not acceptable + * as it is: a piece used twice, the wrong number of tiles of some color. Genetic search calls this on every child + * and mutant before it scores it. The default accepts every candidate as it is. + * + * Return the same instance when there is nothing to fix. A strategy that scored the candidate from a move without + * building it has to build and score it again if a different array comes back. The result must be the same kind of + * array as the candidate. + */ + def repair(candidate: ParameterArray): ParameterArray = candidate + + /** + * Lets the problem suggest a move that it expects to be more useful than a random one, because it knows something + * about its own structure, for example a swap that involves a cell whose edges do not match. A strategy that is + * configured with a guided move probability asks this now and then in place of proposing a random move, and uses a + * random move when this returns `None`, as the default does. Only used for absolute fitness, with an array that + * [[com.barrybecker4.optimization.parameter.ParameterArray.supportsMoves]]. + * + * @param current the array the move starts from + * @param radius how big a random move would be, a hint for how big to make this one + * @param rnd the random numbers to use, so that a seeded run stays repeatable + */ + def proposeGuidedMove(current: ParameterArray, radius: Double, rnd: Random): Option[Move] = None + /** if non-0, then we terminate when the fitness reaches this value. * @return the best (largest) expected fitness value. */ diff --git a/scala-source/com/barrybecker4/optimization/parameter/ParameterArray.scala b/scala-source/com/barrybecker4/optimization/parameter/ParameterArray.scala index d315836..1f3cded 100644 --- a/scala-source/com/barrybecker4/optimization/parameter/ParameterArray.scala +++ b/scala-source/com/barrybecker4/optimization/parameter/ParameterArray.scala @@ -3,6 +3,8 @@ package com.barrybecker4.optimization.parameter import com.barrybecker4.optimization.parameter.types.Parameter +import scala.util.Random + /** * Represents an array of parameters. @@ -45,6 +47,19 @@ trait ParameterArray { /** @return get a completely random solution in the parameter space.*/ def getRandomSample: ParameterArray + /** + * A child of this array and `other`, for strategies that combine good solutions (genetic search). + * + * The default is [[ParameterCrossover.cross]]: uniform crossover for numeric arrays, order crossover for + * permutations, a set merge for variable length sets. Override it when the array knows a better way to combine + * two of itself, for example a puzzle path that can keep the pieces that already fit. The child must be the same + * kind of array as its parents. Throw an [[IllegalArgumentException]] if the two cannot be crossed, and the + * strategy will pick another pair. + * @param other the second parent, usually the same class as this one + * @param rnd the source of randomness to use + */ + def crossWith(other: ParameterArray, rnd: Random): ParameterArray = ParameterCrossover.cross(this, other, rnd) + /** * Whether [[proposeMove]] and [[applyMove]] work for this array. A strategy that finds out the fitness of a neighbor * from a move, rather than from the neighbor itself, only does so when this is true. False by default. diff --git a/scala-source/com/barrybecker4/optimization/strategy/annealing/SimulatedAnnealingStrategy.scala b/scala-source/com/barrybecker4/optimization/strategy/annealing/SimulatedAnnealingStrategy.scala index 798047b..41e28f6 100644 --- a/scala-source/com/barrybecker4/optimization/strategy/annealing/SimulatedAnnealingStrategy.scala +++ b/scala-source/com/barrybecker4/optimization/strategy/annealing/SimulatedAnnealingStrategy.scala @@ -3,7 +3,7 @@ package com.barrybecker4.optimization.strategy.annealing import com.barrybecker4.common.format.FormatUtil import com.barrybecker4.math.MathUtil -import com.barrybecker4.optimization.optimizee.{EvaluationBudgetExceededException, Optimizee} +import com.barrybecker4.optimization.optimizee.{EvaluationBudgetExceededException, MoveChoice, Optimizee} import com.barrybecker4.optimization.parameter.* import com.barrybecker4.optimization.strategy.OptimizationStrategy @@ -55,6 +55,9 @@ object SimulatedAnnealingStepMode { * @param defaultTempMax initial `tempMax` until [[SimulatedAnnealingStrategy.setMaxTemperature]] * @param radiusMultiplier multiplier in `r_base = radiusMultiplier * temperature / ((N + ct) * tempMax)` * @param stepMode fixed base radius or adaptive acceptance scaling + * @param guidedMoveProbability chance that a proposal is the problem's own suggestion + * ([[com.barrybecker4.optimization.optimizee.Optimizee.proposeGuidedMove]]) rather than a + * random move; only used when moves are, and 0 means never */ case class SimulatedAnnealingConfig( innerIterationsPerDimension: Int = 10, @@ -63,8 +66,11 @@ case class SimulatedAnnealingConfig( defaultTempMax: Double = 1000.0, radiusMultiplier: Double = 8.0, stepMode: SimulatedAnnealingStepMode = SimulatedAnnealingStepMode.AdaptiveAcceptance(), - useMoves: Boolean = true + useMoves: Boolean = true, + guidedMoveProbability: Double = 0.0 ) { + require(guidedMoveProbability >= 0.0 && guidedMoveProbability <= 1.0, + s"guidedMoveProbability must be in [0, 1], got $guidedMoveProbability") // A tempDropFactor >= 1 never cools, so the outer loop would never terminate. require(innerIterationsPerDimension > 0, s"innerIterationsPerDimension must be positive, got $innerIterationsPerDimension") require(numTempIterations > 0, s"numTempIterations must be positive, got $numTempIterations") @@ -274,7 +280,7 @@ class SimulatedAnnealingStrategy(optimizee: Optimizee, rnd: Random = MathUtil.RA /** The neighbor is only built if the move is kept, so a rejected proposal costs a delta and no allocation. */ private def proposeMoveAndMaybeAccept(radius: Double): (ParameterArrayWithFitness, Boolean) = { - val move = current.pa.proposeMove(radius) + val move = MoveChoice.propose(optimizee, current.pa, radius, config.guidedMoveProbability, rnd) val candidateFitness = optimizee.evaluateMove(current.pa, current.fitness, move) val delta = candidateFitness - current.fitness val accepted = delta < 0 || metropolisAcceptWorse(delta, temperature, fitnessRange) diff --git a/scala-source/com/barrybecker4/optimization/strategy/genetic/GeneticSearchConfig.scala b/scala-source/com/barrybecker4/optimization/strategy/genetic/GeneticSearchConfig.scala index 3b30439..3101d17 100644 --- a/scala-source/com/barrybecker4/optimization/strategy/genetic/GeneticSearchConfig.scala +++ b/scala-source/com/barrybecker4/optimization/strategy/genetic/GeneticSearchConfig.scala @@ -53,8 +53,11 @@ case class GeneticSearchConfig( neighborSelection: NeighborSelection = NeighborSelection.GreedyImproveAbsolute, improvementEpsilon: Double = GeneticSearchConfig.DEFAULT_IMPROVEMENT_EPS, elitismEnabled: Boolean = true, - useMoves: Boolean = true + useMoves: Boolean = true, + guidedMoveProbability: Double = 0.0 ) { + require(guidedMoveProbability >= 0.0 && guidedMoveProbability <= 1.0, + s"guidedMoveProbability must be in [0, 1], got $guidedMoveProbability") require(cullFactor >= 0.0 && cullFactor <= 1.0, s"cullFactor must be in [0, 1], got $cullFactor") require(crossoverProbability >= 0.0 && crossoverProbability <= 1.0, s"crossoverProbability must be in [0, 1], got $crossoverProbability") diff --git a/scala-source/com/barrybecker4/optimization/strategy/genetic/GeneticSearchStrategy.scala b/scala-source/com/barrybecker4/optimization/strategy/genetic/GeneticSearchStrategy.scala index 5fd9c9a..4f83d0c 100644 --- a/scala-source/com/barrybecker4/optimization/strategy/genetic/GeneticSearchStrategy.scala +++ b/scala-source/com/barrybecker4/optimization/strategy/genetic/GeneticSearchStrategy.scala @@ -89,7 +89,7 @@ class GeneticSearchStrategy( champions.consider(seedMember) val population = ArrayBuffer(seedMember) - val neighbors = InitialPopulation.proposeNeighbors(params, desiredPopulationSize, searchConfig.initialNeighborRadius) + val neighbors = InitialPopulation.proposeNeighbors(params, desiredPopulationSize, searchConfig.initialNeighborRadius, optimizee.repair) evaluateOffspring(neighbors, params).foreach { member => population += member champions.considerMember(member) diff --git a/scala-source/com/barrybecker4/optimization/strategy/genetic/InitialPopulation.scala b/scala-source/com/barrybecker4/optimization/strategy/genetic/InitialPopulation.scala index 59295f3..90f1d53 100644 --- a/scala-source/com/barrybecker4/optimization/strategy/genetic/InitialPopulation.scala +++ b/scala-source/com/barrybecker4/optimization/strategy/genetic/InitialPopulation.scala @@ -15,14 +15,16 @@ private[genetic] object InitialPopulation { /** * Distinct random neighbors of the seed, enough to bring the population (seed included) up to `desiredSize`. * Fewer are returned when the space is too small to find that many distinct neighbors. + * @param repair what the problem does to put right a neighbor that is not acceptable as it is; none by default */ - def proposeNeighbors(seed: ParameterArray, desiredSize: Int, radius: Double): Seq[ParameterArray] = { + def proposeNeighbors(seed: ParameterArray, desiredSize: Int, radius: Double, + repair: ParameterArray => ParameterArray = identity): Seq[ParameterArray] = { val seen = mutable.HashSet(Solutions.key(seed)) val neighbors = ArrayBuffer[ParameterArray]() val maxAttempts = ATTEMPTS_PER_MEMBER * desiredSize var attempts = 0 while (1 + neighbors.size < desiredSize && attempts < maxAttempts) { - val nbr = seed.getRandomNeighbor(radius) + val nbr = repair(seed.getRandomNeighbor(radius)) if (seen.add(Solutions.key(nbr))) neighbors += nbr attempts += 1 } diff --git a/scala-source/com/barrybecker4/optimization/strategy/genetic/PopulationRefiller.scala b/scala-source/com/barrybecker4/optimization/strategy/genetic/PopulationRefiller.scala index 53a5cea..1313fc2 100644 --- a/scala-source/com/barrybecker4/optimization/strategy/genetic/PopulationRefiller.scala +++ b/scala-source/com/barrybecker4/optimization/strategy/genetic/PopulationRefiller.scala @@ -1,8 +1,8 @@ // Copyright by Barry G. Becker, 2000-2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT package com.barrybecker4.optimization.strategy.genetic -import com.barrybecker4.optimization.optimizee.Optimizee -import com.barrybecker4.optimization.parameter.{ParameterArray, ParameterArrayWithFitness, ParameterCrossover} +import com.barrybecker4.optimization.optimizee.{MoveChoice, Optimizee} +import com.barrybecker4.optimization.parameter.{Move, ParameterArray, ParameterArrayWithFitness} import com.barrybecker4.optimization.strategy.annealing.SimulatedAnnealingStrategy import scala.collection.mutable @@ -91,7 +91,7 @@ private[genetic] final class PopulationRefiller( private def biasedKeeperIndex(): Int = (rnd.nextDouble() * rnd.nextDouble() * keepSize).toInt private def crossIntoPending(p1: ParameterArray, p2: ParameterArray): Boolean = - try queueIfNew(ParameterCrossover.cross(p1, p2, rnd)) + try queueIfNew(optimizee.repair(p1.crossWith(p2, rnd))) catch { case _: IllegalArgumentException => false // parents that cannot be crossed } @@ -108,7 +108,7 @@ private[genetic] final class PopulationRefiller( val parent = population(keeperIndex) val radius = mutationRadius(keeperIndex, nbrRadius) if (useGreedy) addGreedyMutant(parent, radius) - else queueIfNew(parent.pa.getRandomNeighbor(radius)) + else queueIfNew(optimizee.repair(parent.pa.getRandomNeighbor(radius))) } private def addGreedyMutant(parent: ParameterArrayWithFitness, radius: Double): Unit = { @@ -139,10 +139,10 @@ private[genetic] final class PopulationRefiller( else greedyNeighborByBuilding(parent, radius) private def greedyNeighborByBuilding(parent: ParameterArrayWithFitness, radius: Double): ParameterArrayWithFitness = { - var nbr = scoreAbsolute(parent.pa.getRandomNeighbor(radius)) + var nbr = scoreAbsolute(optimizee.repair(parent.pa.getRandomNeighbor(radius))) var retries = 0 while (nbr.fitness >= parent.fitness && retries < config.maxNeighborRetries) { - nbr = scoreAbsolute(parent.pa.getRandomNeighbor(radius)) + nbr = scoreAbsolute(optimizee.repair(parent.pa.getRandomNeighbor(radius))) retries += 1 } nbr @@ -150,15 +150,24 @@ private[genetic] final class PopulationRefiller( /** The same tries, with the same random numbers, but only the neighbor that is returned is ever built. */ private def greedyNeighborByMoves(parent: ParameterArrayWithFitness, radius: Double): ParameterArrayWithFitness = { - var move = parent.pa.proposeMove(radius) + var move = proposeMove(parent, radius) var fitness = optimizee.evaluateMove(parent.pa, parent.fitness, move) var retries = 0 while (fitness >= parent.fitness && retries < config.maxNeighborRetries) { - move = parent.pa.proposeMove(radius) + move = proposeMove(parent, radius) fitness = optimizee.evaluateMove(parent.pa, parent.fitness, move) retries += 1 } - ParameterArrayWithFitness(parent.pa.applyMove(move), fitness) + keepIfAcceptable(parent.pa.applyMove(move), fitness) + } + + private def proposeMove(parent: ParameterArrayWithFitness, radius: Double): Move = + MoveChoice.propose(optimizee, parent.pa, radius, config.guidedMoveProbability, rnd) + + /** The fitness came from the move, so it is for the array as built. If the problem repairs it into something else, score that. */ + private def keepIfAcceptable(built: ParameterArray, fitness: Double): ParameterArrayWithFitness = { + val repaired = optimizee.repair(built) + if (repaired eq built) ParameterArrayWithFitness(built, fitness) else scoreAbsolute(repaired) } private def scoreAbsolute(pa: ParameterArray): ParameterArrayWithFitness = diff --git a/scala-test/com/barrybecker4/optimization/benchmark/LimitedOptimizee.scala b/scala-test/com/barrybecker4/optimization/benchmark/LimitedOptimizee.scala index 3a6a818..66b9e90 100644 --- a/scala-test/com/barrybecker4/optimization/benchmark/LimitedOptimizee.scala +++ b/scala-test/com/barrybecker4/optimization/benchmark/LimitedOptimizee.scala @@ -5,6 +5,7 @@ import com.barrybecker4.optimization.optimizee.{EvaluationBudgetExceededExceptio import com.barrybecker4.optimization.parameter.{Move, ParameterArray} import java.util.concurrent.atomic.AtomicLong +import scala.util.Random object LimitedOptimizee { val FINISHED = "finished" @@ -34,6 +35,9 @@ class LimitedOptimizee(delegate: Optimizee, maxEvaluations: Long, deadlineNanos: 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 evaluateFitness(params: ParameterArray): Double = { checkLimits() diff --git a/scala-test/com/barrybecker4/optimization/benchmark/RandomTourProblem.scala b/scala-test/com/barrybecker4/optimization/benchmark/RandomTourProblem.scala index 3673661..223e631 100644 --- a/scala-test/com/barrybecker4/optimization/benchmark/RandomTourProblem.scala +++ b/scala-test/com/barrybecker4/optimization/benchmark/RandomTourProblem.scala @@ -65,6 +65,16 @@ class RandomTourProblem(numCities: Int, seed: Long) extends OptimizeeProblem { total } + /** + * Long legs are what a good tour lacks, so find one by sampling a few legs and taking the longest, and swap the city + * at its end with the city at a random position. That is a better bet than a swap of two random positions. + */ + override def proposeGuidedMove(tour: ParameterArray, radius: Double, rnd: Random): Option[Move] = { + val longest = Seq.fill(RandomTourProblem.GUIDED_SAMPLE_SIZE)(rnd.nextInt(numCities)) + .maxBy(legLength(tour, _, 0, 0, swapped = false)) + Some(Swap((longest + 1) % numCities, rnd.nextInt(numCities))) + } + private def before(position: Int): Int = (position + numCities - 1) % numCities /** The length of the leg from `start` to the next position, in the tour as it is or with the two positions swapped. */ @@ -95,3 +105,8 @@ class RandomTourProblem(numCities: Int, seed: Long) extends OptimizeeProblem { /** A random tour is about half as long as it would be if every hop crossed the square. */ override def getFitnessRange: Double = numCities * 0.5 } + +object RandomTourProblem { + /** How many legs a guided move looks at to find a long one. */ + val GUIDED_SAMPLE_SIZE = 3 +} diff --git a/scala-test/com/barrybecker4/optimization/optimizee/GuidedMovesSuite.scala b/scala-test/com/barrybecker4/optimization/optimizee/GuidedMovesSuite.scala new file mode 100644 index 0000000..5fab0de --- /dev/null +++ b/scala-test/com/barrybecker4/optimization/optimizee/GuidedMovesSuite.scala @@ -0,0 +1,90 @@ +// 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.benchmark.RandomTourProblem +import com.barrybecker4.optimization.parameter.{Move, ParameterArray, ParameterArrayWithFitness, Swap} +import com.barrybecker4.optimization.strategy.annealing.{SimulatedAnnealingConfig, SimulatedAnnealingStrategy} +import com.barrybecker4.optimization.strategy.genetic.{GeneticSearchConfig, GeneticSearchStrategy} +import org.scalatest.funsuite.AnyFunSuite + +import scala.util.Random + +/** A guided move is the problem's own suggestion, mixed in with random moves by a configurable probability. */ +class GuidedMovesSuite extends AnyFunSuite { + + /** Counts how often it is asked; it suggests a swap of the first two positions, or nothing at all. */ + private class Guide(suggest: Boolean) extends RandomTourProblem(20, 5) { + var asked = 0 + override def proposeGuidedMove(current: ParameterArray, radius: Double, rnd: Random): Option[Move] = { + asked += 1 + if (suggest) Some(Swap(0, 1)) else None + } + } + + private val problem = new RandomTourProblem(20, 5) + private val tour = problem.getInitialGuess + + test("with probability 0 the problem is never asked, and no random number is drawn") { + val guide = new Guide(true) + val rnd = new Random(7) + MoveChoice.propose(guide, tour, 0.5, 0.0, rnd) + assertResult(0)(guide.asked) + // random moves draw from the array's own generator, so the one passed in is untouched + assertResult(new Random(7).nextInt())(rnd.nextInt()) + } + + test("with probability 1 the problem's move is used") { + val guide = new Guide(true) + assertResult(Swap(0, 1))(MoveChoice.propose(guide, tour, 0.5, 1.0, new Random(1))) + assertResult(1)(guide.asked) + } + + test("a problem with nothing to suggest falls back to a random move") { + val guide = new Guide(false) + val move = MoveChoice.propose(guide, tour, 0.5, 1.0, new Random(1)) + assertResult(1)(guide.asked) + assert(tour.applyMove(move) != null) + } + + test("the probability is how often the problem is asked") { + val guide = new Guide(true) + val rnd = new Random(3) + (1 to 2000).foreach(_ => MoveChoice.propose(guide, tour, 0.5, 0.25, rnd)) + assert(guide.asked > 400 && guide.asked < 600, s"asked ${guide.asked} of 2000") + } + + test("a probability outside [0, 1] is refused") { + intercept[IllegalArgumentException](SimulatedAnnealingConfig(guidedMoveProbability = 1.5)) + intercept[IllegalArgumentException](GeneticSearchConfig(guidedMoveProbability = -0.1)) + } + + private def annealed(guide: Guide, p: Double): Double = { + val sa = new SimulatedAnnealingStrategy(guide, new Random(1)) + sa.setConfig(SimulatedAnnealingConfig(guidedMoveProbability = p)) + sa.doOptimization(guide.getInitialGuess, guide.getFitnessRange).fitness + } + + test("simulated annealing asks the problem only when configured to") { + val never = new Guide(true) + annealed(never, 0.0) + assertResult(0)(never.asked) + val sometimes = new Guide(true) + annealed(sometimes, 0.5) + assert(sometimes.asked > 0) + } + + test("simulated annealing with probability 0 is the same run as without a guide") { + assertResult(annealed(new Guide(false), 0.0))(annealed(new Guide(true), 0.0)) + } + + test("genetic search asks the problem for greedy mutants only when configured to") { + val asked = (p: Double) => { + val guide = new Guide(true) + val config = GeneticSearchConfig(crossoverEnabled = false, guidedMoveProbability = p) + new GeneticSearchStrategy(guide, new Random(2), config).doOptimization(guide.getInitialGuess, guide.getFitnessRange) + guide.asked + } + assertResult(0)(asked(0.0)) + assert(asked(1.0) > 0) + } +} diff --git a/scala-test/com/barrybecker4/optimization/optimizee/OptimizeeHooksSuite.scala b/scala-test/com/barrybecker4/optimization/optimizee/OptimizeeHooksSuite.scala new file mode 100644 index 0000000..9623ff9 --- /dev/null +++ b/scala-test/com/barrybecker4/optimization/optimizee/OptimizeeHooksSuite.scala @@ -0,0 +1,64 @@ +// 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.benchmark.{LimitedOptimizee, RandomTourProblem} +import com.barrybecker4.optimization.optimizee.optimizees.problems.TravelingSalesmanProblem +import com.barrybecker4.optimization.parameter.{Move, ParameterArray, PermutedParameterArray, Swap} +import org.scalatest.funsuite.AnyFunSuite + +import scala.util.Random + +/** The hooks a problem can use to say something about its own structure. By default they say nothing. */ +class OptimizeeHooksSuite extends AnyFunSuite { + + /** Repairs by putting city 0 first, and always suggests swapping the first two positions. */ + private class Opinionated extends RandomTourProblem(8, 1) { + var repairs = 0 + var guided = 0 + override def repair(candidate: ParameterArray): ParameterArray = { repairs += 1; candidate } + override def proposeGuidedMove(current: ParameterArray, radius: Double, rnd: Random): Option[Move] = { + guided += 1 + Some(Swap(0, 1)) + } + } + + private val tour = new RandomTourProblem(8, 1).getInitialGuess + + // a problem that does not override either hook + private val plain = new TravelingSalesmanProblem() + private val plainTour = plain.getInitialGuess + + test("by default a candidate needs no repair, and the same array comes back") { + assert(plain.repair(plainTour) eq plainTour) + } + + test("by default a problem has no guided move to suggest") { + assertResult(None)(plain.proposeGuidedMove(plainTour, 0.5, new Random(1))) + } + + test("a budget passes repair and guided moves to the optimizee it wraps, and they do not use up the budget") { + val inner = new Opinionated() + val budgeted = new BudgetedOptimizee(inner, maxEvaluations = 1) + budgeted.repair(tour) + assertResult(Some(Swap(0, 1)))(budgeted.proposeGuidedMove(tour, 0.5, new Random(1))) + assertResult((1, 1))((inner.repairs, inner.guided)) + assertResult(0)(budgeted.evaluationsUsed) + } + + test("the benchmark limit passes them on too") { + val inner = new Opinionated() + val limited = new LimitedOptimizee(inner, maxEvaluations = 1, deadlineNanos = Long.MaxValue) + limited.repair(tour) + limited.proposeGuidedMove(tour, 0.5, new Random(1)) + assertResult((1, 1))((inner.repairs, inner.guided)) + assertResult(0L)(limited.evaluations) + } + + test("the delta check passes them on too") { + val inner = new Opinionated() + val checked = new DeltaCheckingOptimizee(inner) + checked.repair(tour) + checked.proposeGuidedMove(tour, 0.5, new Random(1)) + assertResult((1, 1))((inner.repairs, inner.guided)) + } +} diff --git a/scala-test/com/barrybecker4/optimization/parameter/CrossWithSuite.scala b/scala-test/com/barrybecker4/optimization/parameter/CrossWithSuite.scala new file mode 100644 index 0000000..e2ba5d7 --- /dev/null +++ b/scala-test/com/barrybecker4/optimization/parameter/CrossWithSuite.scala @@ -0,0 +1,41 @@ +// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT +package com.barrybecker4.optimization.parameter + +import com.barrybecker4.optimization.parameter.types.{DoubleParameter, IntegerParameter, Parameter} +import org.scalatest.funsuite.AnyFunSuite + +import scala.util.Random + +class CrossWithSuite extends AnyFunSuite { + + private def permutation(order: Seq[Int], seed: Long = 1): PermutedParameterArray = + PermutedParameterArray(order.indices.map(i => IntegerParameter(order(i), 0, order.size - 1, "p" + i): Parameter), new Random(seed)) + + private def valuesOf(pa: ParameterArray): Seq[Double] = (0 until pa.size).map(pa.get(_).getValue) + + test("by default an array is crossed the way ParameterCrossover crosses it") { + for (seed <- 1L to 20L) { + val (a, b) = (permutation(new Random(seed).shuffle(0 until 12)), permutation(new Random(seed + 50).shuffle(0 until 12))) + assertResult(valuesOf(ParameterCrossover.cross(a, b, new Random(seed))), s"seed $seed")(valuesOf(a.crossWith(b, new Random(seed)))) + } + } + + test("numeric arrays cross by default too") { + val params = (v: Double) => IndexedSeq[Parameter](DoubleParameter(v, 0.0, 1.0, "x"), DoubleParameter(1.0 - v, 0.0, 1.0, "y")) + val (a, b) = (NumericParameterArray(params(0.1), rnd = new Random(1)), NumericParameterArray(params(0.8), rnd = new Random(1))) + assertResult(valuesOf(ParameterCrossover.cross(a, b, new Random(3))))(valuesOf(a.crossWith(b, new Random(3)))) + } + + test("a subclass can cross itself its own way") { + class Reversing(values: IndexedSeq[Parameter]) extends PermutedParameterArray(values, new Random(1)) { + override def crossWith(other: ParameterArray, rnd: Random): ParameterArray = new Reversing(values.reverse) + } + val a = new Reversing(permutation(0 until 5).params) + assertResult(Seq(4.0, 3.0, 2.0, 1.0, 0.0))(valuesOf(a.crossWith(a, new Random(1)))) + } + + test("arrays of different types still cannot be crossed") { + val numeric = NumericParameterArray(IndexedSeq[Parameter](DoubleParameter(0.5, 0.0, 1.0, "x")), rnd = new Random(1)) + intercept[IllegalArgumentException](permutation(0 until 3).crossWith(numeric, new Random(1))) + } +} diff --git a/scala-test/com/barrybecker4/optimization/strategy/genetic/CrossoverHookSuite.scala b/scala-test/com/barrybecker4/optimization/strategy/genetic/CrossoverHookSuite.scala new file mode 100644 index 0000000..10615e7 --- /dev/null +++ b/scala-test/com/barrybecker4/optimization/strategy/genetic/CrossoverHookSuite.scala @@ -0,0 +1,58 @@ +// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT +package com.barrybecker4.optimization.strategy.genetic + +import com.barrybecker4.optimization.benchmark.RandomTourProblem +import com.barrybecker4.optimization.parameter.types.Parameter +import com.barrybecker4.optimization.parameter.{ParameterArray, ParameterArrayWithFitness, PermutedParameterArray} +import org.scalatest.funsuite.AnyFunSuite + +import scala.collection.mutable.ArrayBuffer +import scala.util.Random + +/** Genetic search crosses parents with the parents' own crossWith, so an array can cross itself its own way. */ +class CrossoverHookSuite extends AnyFunSuite { + + /** Its children are always the first parent's tour, rotated by one, and it counts how often it is asked. */ + private class RotatingTour(values: IndexedSeq[Parameter], rnd: Random, val crossings: java.util.concurrent.atomic.AtomicInteger) + extends PermutedParameterArray(values, rnd) { + override def crossWith(other: ParameterArray, rnd: Random): ParameterArray = { + crossings.incrementAndGet() + new RotatingTour(values.tail :+ values.head, rnd, crossings) + } + override def getRandomNeighbor(radius: Double): PermutedParameterArray = + new RotatingTour(super.getRandomNeighbor(radius).params, rnd, crossings) + } + + test("the children of a crossover come from the parents' crossWith") { + val problem = new RandomTourProblem(12, 3) + val crossings = new java.util.concurrent.atomic.AtomicInteger(0) + val base = problem.getInitialGuess.asInstanceOf[PermutedParameterArray].params + val parents = (1 to 3).map { k => + val tour = new RotatingTour(new Random(k).shuffle(base), new Random(k), crossings) + ParameterArrayWithFitness(tour, problem.evaluateFitness(tour)) + } + val added = ArrayBuffer[ParameterArrayWithFitness]() + val refiller = new PopulationRefiller(problem, GeneticSearchConfig(crossoverProbability = 1.0), new Random(5), (cands, _) => + cands.map(pa => ParameterArrayWithFitness(pa, problem.evaluateFitness(pa))), added += _) + refiller.refill(ArrayBuffer(parents*), 3, 12, parents.head.pa, 0.1) + assert(crossings.get > 0, "the parents' own crossWith was used") + assert(added.forall(_.pa.isInstanceOf[RotatingTour])) + } + + test("parents that cannot be crossed are skipped, as before") { + class Refusing(values: IndexedSeq[Parameter], rnd: Random) extends PermutedParameterArray(values, rnd) { + override def crossWith(other: ParameterArray, rnd: Random): ParameterArray = throw new IllegalArgumentException("no") + } + val problem = new RandomTourProblem(10, 1) + val params = problem.getInitialGuess.asInstanceOf[PermutedParameterArray].params + val parents = (1 to 3).map { k => + val tour = new Refusing(new Random(k).shuffle(params), new Random(k)) + ParameterArrayWithFitness(tour, problem.evaluateFitness(tour)) + } + val refiller = new PopulationRefiller(problem, GeneticSearchConfig(crossoverProbability = 1.0, neighborSelection = NeighborSelection.SingleSample), + new Random(2), (cands, _) => cands.map(pa => ParameterArrayWithFitness(pa, problem.evaluateFitness(pa))), _ => ()) + val population = ArrayBuffer(parents*) + refiller.refill(population, 3, 8, parents.head.pa, 0.1) + assert(population.size >= 8, "mutation made up for the crossings that were refused") + } +} diff --git a/scala-test/com/barrybecker4/optimization/strategy/genetic/RepairHookSuite.scala b/scala-test/com/barrybecker4/optimization/strategy/genetic/RepairHookSuite.scala new file mode 100644 index 0000000..83219f1 --- /dev/null +++ b/scala-test/com/barrybecker4/optimization/strategy/genetic/RepairHookSuite.scala @@ -0,0 +1,81 @@ +// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT +package com.barrybecker4.optimization.strategy.genetic + +import com.barrybecker4.optimization.benchmark.RandomTourProblem +import com.barrybecker4.optimization.parameter.types.{IntegerParameter, Parameter} +import com.barrybecker4.optimization.parameter.{ParameterArray, ParameterArrayWithFitness, PermutedParameterArray} +import org.scalatest.funsuite.AnyFunSuite + +import scala.collection.mutable.ArrayBuffer +import scala.util.Random + +/** Whatever way a candidate is made (crossover or any kind of mutation), the problem gets to repair it first. */ +class RepairHookSuite extends AnyFunSuite { + + /** A tour is only acceptable when it starts at city 0, so a problem that wants that repairs a tour that does not. */ + private class StartsAtCityZero(cities: Int, seed: Long) extends RandomTourProblem(cities, seed) { + var repaired = 0 + override def repair(candidate: ParameterArray): ParameterArray = { + val values = (0 until candidate.size).map(i => candidate.get(i).getValue.toInt) + val at = values.indexOf(0) + if (at == 0) candidate + else { + repaired += 1 + val order = values.drop(at) ++ values.take(at) + PermutedParameterArray(order.indices.map(i => IntegerParameter(order(i), 0, cities - 1, "p" + i): Parameter), new Random(at)) + } + } + } + + private def valuesOf(pa: ParameterArray): Seq[Int] = (0 until pa.size).map(i => pa.get(i).getValue.toInt) + + private def population(problem: RandomTourProblem, cities: Int, count: Int): ArrayBuffer[ParameterArrayWithFitness] = + ArrayBuffer((1 to count).map { k => + val order = 0 +: new Random(k).shuffle(1 until cities) + val tour = PermutedParameterArray(order.indices.map(i => IntegerParameter(order(i), 0, cities - 1, "p" + i): Parameter), new Random(k)) + ParameterArrayWithFitness(tour, problem.evaluateFitness(tour)) + }*) + + private def refill(problem: RandomTourProblem, config: GeneticSearchConfig, cities: Int): Seq[ParameterArrayWithFitness] = { + val added = ArrayBuffer[ParameterArrayWithFitness]() + val refiller = new PopulationRefiller(problem, config, new Random(3), + (cands, _) => cands.map(pa => ParameterArrayWithFitness(pa, problem.evaluateFitness(pa))), added += _) + val members = population(problem, cities, 3) + refiller.refill(members, 3, 30, members.head.pa, 0.5) + added.toSeq + } + + private def check(config: GeneticSearchConfig, name: String): Unit = { + val problem = new StartsAtCityZero(14, 2) + val added = refill(problem, config, 14) + assert(added.nonEmpty, name) + assert(problem.repaired > 0, s"$name: some candidate needed repair") + added.foreach { m => + assertResult(0, s"$name: every member starts at city 0")(valuesOf(m.pa).head) + assert(Math.abs(m.fitness - problem.evaluateFitness(m.pa)) < 1e-9, s"$name: fitness is that of the repaired tour") + } + } + + test("crossover children are repaired")(check(GeneticSearchConfig(crossoverProbability = 1.0, neighborSelection = NeighborSelection.SingleSample), "crossover")) + + test("single-sample mutants are repaired")(check(GeneticSearchConfig(crossoverEnabled = false, neighborSelection = NeighborSelection.SingleSample), "single sample")) + + test("greedy mutants made with moves are repaired, and scored again if the repair changed them")( + check(GeneticSearchConfig(crossoverEnabled = false, useMoves = true), "greedy with moves")) + + test("greedy mutants made by building neighbors are repaired")( + check(GeneticSearchConfig(crossoverEnabled = false, useMoves = false), "greedy building")) + + test("a problem that needs no repair costs nothing and changes nothing") { + val problem = new RandomTourProblem(14, 2) + val withHook = refill(problem, GeneticSearchConfig(crossoverEnabled = false), 14).map(m => valuesOf(m.pa)) + assert(withHook.nonEmpty) + } + + test("the initial population is repaired too") { + val problem = new StartsAtCityZero(14, 2) + val seed = population(problem, 14, 1).head.pa + val neighbors = InitialPopulation.proposeNeighbors(seed, 20, 1.0, problem.repair) + assert(neighbors.nonEmpty && neighbors.forall(valuesOf(_).head == 0)) + } +}