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

/**
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading