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
4 changes: 2 additions & 2 deletions docs/new-strategies-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,13 @@ an optimization problem, so neither adds information.

### Phase B: shared pieces (additive, no behavior change)

- `Move.attributes` and a `SetValue(index, value)` move.
- `SetValue(index, value)` and `Placement(position, value)` moves. Instead of `Move.attributes`, `ParameterArray.createdAttributes(move)` and `destroyedAttributes(move)` return the placements a move makes and undoes (Phase B).
- Moves for `NumericParameterArray` when every parameter is an integer (and for boolean parameters), with
`proposeMove`, `applyMove` and `supportsMoves`, consistent with `getRandomNeighbor`: applying the proposed move
gives the same array as `getRandomNeighbor` for the same random numbers (the rule used for permutations).
- `Optimizee.conflictedPositions` with a default of empty, forwarded by the wrappers (`BudgetedOptimizee`,
`DeltaCheckingOptimizee`, `LimitedOptimizee`) as `repair` and `proposeGuidedMove` are.
- `ParameterArray.enumerateMoves(limit)` for permutations, with a default of "unsupported".
- `ParameterArray.supportsLocalMoves`, `movesAt(position)` and `proposeLocalMove(rnd)` (swaps for permutations, `SetValue` for integer arrays; capped at 64 sampled values per position), replacing `enumerateMoves`.
- `DeltaCheckingOptimizee` already checks any move against a full evaluation, so the new move types are covered by it.

### Phase C: adaptive restarts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ class BudgetedOptimizee(delegate: Optimizee, val maxEvaluations: Int) extends Op
override def proposeGuidedMove(current: ParameterArray, radius: Double, rnd: Random): Option[Move] =
delegate.proposeGuidedMove(current, radius, rnd)

override def conflictedPositions(current: ParameterArray): Seq[Int] = delegate.conflictedPositions(current)

private def bump(): Unit = synchronized {
if (count >= maxEvaluations) {
budgetExhausted = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class DeltaCheckingOptimizee(delegate: Optimizee, tolerance: Double = 1e-9) exte
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 conflictedPositions(current: ParameterArray): Seq[Int] = delegate.conflictedPositions(current)

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
Expand Up @@ -80,6 +80,16 @@ trait Optimizee {
*/
def proposeGuidedMove(current: ParameterArray, radius: Double, rnd: Random): Option[Move] = None

/**
* The positions of the array (indices of its parameters) that take part in a violated constraint: the queens that
* are attacked, the nodes with a neighbor of the same color. Min-conflicts picks the position to change from these,
* and so does any strategy that wants to focus on what is wrong. An empty result means "not known", not "nothing is
* wrong", so a strategy then picks a position at random, which is the default. Only used for absolute fitness.
*
* @param current the array to look at
*/
def conflictedPositions(current: ParameterArray): Seq[Int] = Seq.empty

/** 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 @@ -17,3 +17,12 @@ final case class Swap(first: Int, second: Int) extends Move

/** Several swaps, applied one after the other (a later swap sees the result of the earlier ones). */
final case class SwapSequence(swaps: IndexedSeq[Swap]) extends Move

/** Set the parameter at `index` to `value`, for an array of integers (or booleans, which are 0 and 1). */
final case class SetValue(index: Int, value: Double) extends Move

/**
* A value at a position. It is what a move creates or destroys, as far as tabu search is concerned: after a swap,
* two values are in new positions, and putting either back would undo it. See [[ParameterArray.createdAttributes]].
*/
final case class Placement(position: Int, value: Double)
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ object NumericParameterArray {
/** default number of steps to go from the min to the max */
private val DEFAULT_NUM_STEPS = 10

/** The most values that [[NumericParameterArray.movesAt]] offers for one position; a bigger range is sampled. */
val MAX_VALUES_PER_POSITION = 64

def createParams(vals: IndexedSeq[Double], minVals: IndexedSeq[Double], maxVals: IndexedSeq[Double],
names: IndexedSeq[String]): IndexedSeq[Parameter] = {
val len = vals.length
Expand Down Expand Up @@ -120,6 +123,44 @@ case class NumericParameterArray(params: IndexedSeq[Parameter],
override def getRandomNeighbor(radius: Double): NumericParameterArray =
NumericParameterArray(this.params.map(p => p.tweakValue(radius, rnd)), numSteps, rnd)

/** Local moves set one integer parameter to another value. Arrays with real numbers do not have them. */
override def supportsLocalMoves: Boolean = params.forall(_.isIntegerOnly)

/** Setting the parameter to each other value in its range, or to a sample of them when the range is large. */
override def movesAt(position: Int): Iterator[Move] =
otherValues(position, rnd).iterator.map(SetValue(position, _))

/** Set one random parameter to a different value in its range, if it has one. */
override def proposeLocalMove(random: Random): Move = {
val position = random.nextInt(size)
val values = otherValues(position, random)
SetValue(position, if (values.isEmpty) get(position).getValue else values(random.nextInt(values.size)))
}

override def applyMove(move: Move): NumericParameterArray = move match {
case SetValue(index, value) => NumericParameterArray(params.updated(index, params(index).setValue(value)), numSteps, rnd)
case other => throw new UnsupportedOperationException("A numeric array does not understand the move " + other)
}

override def createdAttributes(move: Move): Seq[Any] = move match {
case SetValue(index, value) => Seq(Placement(index, value))
case other => super.createdAttributes(other)
}

override def destroyedAttributes(move: Move): Seq[Any] = move match {
case SetValue(index, _) => Seq(Placement(index, get(index).getValue))
case other => super.destroyedAttributes(other)
}

/** The integer values in the range of the parameter other than its own, or at most `MAX_VALUES_PER_POSITION` of them. */
private def otherValues(position: Int, random: Random): IndexedSeq[Double] = {
val parameter = get(position)
val (low, high, current) = (parameter.minValue.round.toInt, parameter.maxValue.round.toInt, parameter.getValue.round.toInt)
val all = (low to high).filter(_ != current)
val sampled = if (all.size <= NumericParameterArray.MAX_VALUES_PER_POSITION) all else random.shuffle(all).take(NumericParameterArray.MAX_VALUES_PER_POSITION)
sampled.map(_.toDouble)
}

/** @return get a completely random solution in the parameter space.*/
override def getRandomSample: NumericParameterArray = {
val newParams =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,41 @@ trait ParameterArray {
def applyMove(move: Move): ParameterArray =
throw new UnsupportedOperationException(getClass.getName + " does not support moves")

/**
* Whether this array can describe moves that each change one position, so that [[movesAt]], [[proposeLocalMove]]
* and [[applyMove]] work for them. Strategies that look at many such moves (tabu search, min-conflicts) need this.
* False by default.
*
* It is separate from [[supportsMoves]], which promises that a move is the neighbor that [[getRandomNeighbor]] would
* have made. For an array of integers the random neighbor changes every position, and a local move only one.
*/
def supportsLocalMoves: Boolean = false

/**
* Every move that changes the given position: a swap with each other position for a permutation, or setting the
* position to each other value for integers. When there are too many values to list, a sample of them.
* @param position the index of the parameter to change
*/
def movesAt(position: Int): Iterator[Move] =
throw new UnsupportedOperationException(getClass.getName + " does not support local moves")

/**
* A random move that changes one position, which the move that [[proposeMove]] gives does not necessarily do.
* @param rnd the source of randomness, so that a seeded search repeats
*/
def proposeLocalMove(rnd: Random): Move =
throw new UnsupportedOperationException(getClass.getName + " does not support local moves")

/**
* What taking the move creates, for the tabu list: the values it puts at positions, as [[Placement]]s. A move
* that creates something that was recently destroyed is tabu. The default is the move itself, which makes only
* repeating the same move tabu.
*/
def createdAttributes(move: Move): Seq[Any] = Seq(move)

/** What taking the move destroys, which is what the tabu list holds once the move is made. See [[createdAttributes]]. */
def destroyedAttributes(move: Move): Seq[Any] = Seq(move)

/**
* What makes two arrays of the same class the same candidate solution. Strategies that avoid evaluating or keeping
* duplicates (such as genetic search) compare this, so equal identities must mean equal candidates.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,41 @@ case class PermutedParameterArray(params: IndexedSeq[Parameter], rnd: Random)
case other => throw new UnsupportedOperationException("A permutation does not understand the move " + other)
}

/** Local moves are swaps, for the same arrays that have moves: a subclass that builds its own neighbors has not opted in. */
override def supportsLocalMoves: Boolean = supportsMoves

override def movesAt(position: Int): Iterator[Move] = {
requireLocalMoves()
(0 until size).iterator.filter(_ != position).map(other => Swap(position, other))
}

private def requireLocalMoves(): Unit =
if (!supportsLocalMoves) throw new UnsupportedOperationException("This array makes its own neighbors, so it has no local moves")

/** A swap of two different positions (of the same one, if there is only one position). */
override def proposeLocalMove(rnd: Random): Move = {
requireLocalMoves()
if (size < 2) Swap(0, 0)
else {
val first = rnd.nextInt(size)
Swap(first, (first + 1 + rnd.nextInt(size - 1)) % size)
}
}

/** A swap puts each value where the other was. */
override def createdAttributes(move: Move): Seq[Any] = move match {
case Swap(first, second) => Seq(Placement(first, valueAt(second)), Placement(second, valueAt(first)))
case other => super.createdAttributes(other)
}

/** A swap takes each value from where it was. */
override def destroyedAttributes(move: Move): Seq[Any] = move match {
case Swap(first, second) => Seq(Placement(first, valueAt(first)), Placement(second, valueAt(second)))
case other => super.destroyedAttributes(other)
}

private def valueAt(position: Int): Double = get(position).getValue

/** Replace only the parameters that change when a few positions are swapped, or work on the values if there are many. */
private def swapPositions(swaps: IndexedSeq[Swap]): PermutedParameterArray =
if (swaps.size * PermutedParameterArray.FEW_SWAPS_DIVISOR <= size) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,19 @@ class AllIntervalProblem(size: Int, seed: Long) extends OptimizeeProblem {
/** The number of differences from 1 to n - 1 that are missing. */
override def evaluateFitness(series: ParameterArray): Double = {
val present = new Array[Boolean](size)
(1 until size).foreach(i => present(Math.abs(numberAt(series, i) - numberAt(series, i - 1))) = true)
(1 until size).foreach(i => present(difference(series, i)) = true)
(1 until size).count(!present(_)).toDouble
}

/** The neighbors whose difference is also the difference of another pair of neighbors, both of them. */
override def conflictedPositions(series: ParameterArray): Seq[Int] = {
val timesSeen = new Array[Int](size)
(1 until size).foreach(i => timesSeen(difference(series, i)) += 1)
(1 until size).filter(i => timesSeen(difference(series, i)) > 1).flatMap(i => Seq(i - 1, i)).distinct
}

private def difference(series: ParameterArray, position: Int): Int = Math.abs(numberAt(series, position) - numberAt(series, position - 1))

private def numberAt(series: ParameterArray, position: Int): Int = series.get(position).getValue.toInt

override def getInitialGuess: ParameterArray = seriesOf(new Random(seed + 1).shuffle((0 until size).toList), seed + 2)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT
package com.barrybecker4.optimization.benchmark

import com.barrybecker4.optimization.optimizee.DeltaCheckingOptimizee
import com.barrybecker4.optimization.parameter.{ParameterArray, SetValue}
import org.scalatest.funsuite.AnyFunSuite

import scala.util.Random

/** The constraint problems tell which positions are in conflict, and score a change of one position by its delta. */
class ConstraintHooksSuite extends AnyFunSuite {

private def valuesOf(pa: ParameterArray): Seq[Int] = (0 until pa.size).map(i => pa.get(i).getValue.toInt)

test("the conflicted queens are exactly the ones that another queen attacks") {
val problem = new NQueensProblem(12, 3)
val board = problem.getInitialGuess
val columns = valuesOf(board)
val attacked = (0 until 12).filter(r => (0 until 12).exists(o => o != r && Math.abs(o - r) == Math.abs(columns(o) - columns(r))))
assertResult(attacked)(problem.conflictedPositions(board))
assert(attacked.nonEmpty)
}

test("a solved board has no conflicted queens") {
assertResult(Seq.empty)(new NQueensProblem(10, 1).conflictedPositions(new NQueensProblem(10, 1).getExactSolution.pa))
}

test("the conflicted nodes are the ones with a neighbor of their color, and there are none in the solution") {
val problem = new GraphColoringProblem(40, 3, 5.0, 1)
val guess = problem.getInitialGuess
val conflicted = problem.conflictedPositions(guess)
assert(conflicted.nonEmpty && conflicted.size < 40)
assertResult(Seq.empty)(problem.conflictedPositions(problem.getExactSolution.pa))
assertResult(0.0)(problem.evaluateFitness(problem.getExactSolution.pa))
}

test("a node with an edge that clashes is reported, and recoloring all the reported nodes can fix the clash count to zero") {
val problem = new GraphColoringProblem(30, 3, 4.0, 2)
val guess = problem.getInitialGuess
assert(problem.evaluateFitness(guess) > 0 && problem.conflictedPositions(guess).nonEmpty)
}

test("the clashes after recoloring a node from evaluateMove are those of the recolored graph, for every node and color") {
val problem = new GraphColoringProblem(30, 4, 5.0, 3)
val coloring = problem.getInitialGuess
val clashes = problem.evaluateFitness(coloring)
for (node <- 0 until 30; color <- 0 until 4) {
val move = SetValue(node, color)
assertResult(problem.evaluateFitness(coloring.applyMove(move)), s"node $node color $color")(problem.evaluateMove(coloring, clashes, move))
}
}

test("a delta checking wrapper accepts every recoloring that coloring scores by delta") {
val problem = new DeltaCheckingOptimizee(new GraphColoringProblem(30, 4, 5.0, 4))
val coloring = new GraphColoringProblem(30, 4, 5.0, 4).getInitialGuess
val start = problem.evaluateFitness(coloring)
val random = new Random(1)
(1 to 100).foreach(_ => problem.evaluateMove(coloring, start, coloring.proposeLocalMove(random)))
}

test("the conflicted positions of the all-interval series are the ends of repeated differences") {
val problem = new AllIntervalProblem(8, 1)
val series = problem.getInitialGuess
val values = valuesOf(series)
val differences = (1 until 8).map(i => Math.abs(values(i) - values(i - 1)))
val expected = (1 until 8).filter(i => differences.count(_ == differences(i - 1)) > 1).flatMap(i => Seq(i - 1, i)).distinct
assertResult(expected)(problem.conflictedPositions(series))
}

test("a solved series has no conflicted positions") {
val problem = new AllIntervalProblem(14, 1)
assertResult(Seq.empty)(problem.conflictedPositions(problem.getExactSolution.pa))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package com.barrybecker4.optimization.benchmark

import com.barrybecker4.optimization.optimizee.optimizees.OptimizeeProblem
import com.barrybecker4.optimization.parameter.types.{IntegerParameter, Parameter}
import com.barrybecker4.optimization.parameter.{NumericParameterArray, ParameterArray, ParameterArrayWithFitness}
import com.barrybecker4.optimization.parameter.{Move, NumericParameterArray, ParameterArray, ParameterArrayWithFitness, SetValue}

import scala.util.Random

Expand All @@ -28,6 +28,7 @@ class GraphColoringProblem(numNodes: Int, numColors: Int, averageDegree: Double,
private val random = new Random(seed)
private val hiddenColoring: IndexedSeq[Int] = IndexedSeq.fill(numNodes)(random.nextInt(numColors))
private val edges: IndexedSeq[(Int, Int)] = createEdges()
private val neighbors: IndexedSeq[Array[Int]] = neighborsOfEachNode()

override def getName: String = s"Coloring $numNodes nodes with $numColors colors"

Expand All @@ -37,6 +38,17 @@ class GraphColoringProblem(numNodes: Int, numColors: Int, averageDegree: Double,
override def evaluateFitness(coloring: ParameterArray): Double =
edges.count { case (a, b) => colorOf(coloring, a) == colorOf(coloring, b) }.toDouble

/** The nodes that share a color with a node they are joined to. */
override def conflictedPositions(coloring: ParameterArray): Seq[Int] =
(0 until numNodes).filter(node => neighbors(node).exists(colorOf(coloring, _) == colorOf(coloring, node)))

/** Giving a node another color only changes its own edges: the ones that now clash less the ones that clashed. */
override def evaluateMove(coloring: ParameterArray, clashes: Double, move: Move): Double = move match {
case SetValue(node, color) =>
clashes + neighbors(node).count(colorOf(coloring, _) == color.toInt) - neighbors(node).count(colorOf(coloring, _) == colorOf(coloring, node))
case _ => super.evaluateMove(coloring, clashes, move)
}

private def colorOf(coloring: ParameterArray, node: Int): Int = coloring.get(node).getValue.toInt

override def getInitialGuess: ParameterArray = {
Expand All @@ -59,6 +71,11 @@ class GraphColoringProblem(numNodes: Int, numColors: Int, averageDegree: Double,
NumericParameterArray(params, rnd = new Random(randomSeed))
}

private def neighborsOfEachNode(): IndexedSeq[Array[Int]] =
(0 until numNodes).map { node =>
edges.collect { case (a, b) if a == node => b; case (a, b) if b == node => a }.toArray
}

/** Random pairs of nodes with different hidden colors, until there are enough edges; the same edge is added once. */
private def createEdges(): IndexedSeq[(Int, Int)] = {
val wanted = (numNodes * averageDegree / 2).toInt
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class LimitedOptimizee(delegate: Optimizee, maxEvaluations: Long, deadlineNanos:
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 conflictedPositions(current: ParameterArray): Seq[Int] = delegate.conflictedPositions(current)

override def evaluateFitness(params: ParameterArray): Double = {
checkLimits()
Expand Down
Loading
Loading