JBoss.orgCommunity Documentation

Chapter 9. Local search

9.1. Overview
9.2. Hill climbing (simple local search)
9.2.1. Algorithm description
9.3. Tabu search
9.3.1. Algorithm description
9.4. Simulated annealing
9.4.1. Algorithm description
9.5. About neighborhoods, moves and steps
9.5.1. A move
9.5.2. Move generation
9.5.3. Generic MoveFactory
9.5.4. A step
9.5.5. Getting stuck in local optima
9.6. Deciding the next step
9.6.1. Selector
9.6.2. Acceptor
9.6.3. Forager
9.7. Using a custom Selector, Acceptor, Forager or Termination

Local search starts from an initial solution and evolves that single solution into a mostly better and better solution. It uses a single search path of solutions, not a search tree. At each solution in this path it evaluates a number of moves on the solution and applies the most suitable move to take the step to the next solution. It does that for high number of iterations until its terminated (usually because its time has run out).

Local search acts a lot like a human planner: it uses a single search path and moves facts around to find a good feasible solution. Therefore it's pretty natural to implement.

Local search needs to start from an initialized solution, therefor it's recommended to configure a construction heuristic solver phase before it.

A move is the change from a solution A to a solution B. For example, below you can see a single move on the starting solution of 4 queens that moves a single queen to another row:


A move can have a small or large impact. In the above example, the move of queen C0 to C2 is a small move. Some moves are the same move type. These are some possibilities for move types in n queens:

  • Move a single queen to another row. This is a small move. For example, move queen C0 to C2.

  • Move all queens a number of rows down or up. This a big move.

  • Move a single queen to another column. This is a small move. For example, move queen C2 to A0 (placing it on top of queen A0).

  • Add a queen to the board at a certain row and column.

  • Remove a queen from the board.

Because we have decided that all queens will be on the board at all times and each queen has an appointed column (for performance reasons), only the first 2 move types are usable in our example. Furthermore, we 're only using the first move type in the example because we think it gives the best performance, but you are welcome to prove us wrong.

Each of your move types will be an implementation of the Move interface:

public interface Move {


    boolean isMoveDoable(ScoreDirector scoreDirector);
    Move createUndoMove(ScoreDirector scoreDirector);
    void doMove(ScoreDirector scoreDirector);
}

Let's take a look at the Move implementation for 4 queens which moves a queen to a different row:

public class RowChangeMove implements Move {


    private Queen queen;
    private Row toRow;
    public RowChangeMove(Queen queen, Row toRow) {
        this.queen = queen;
        this.toRow = toRow;
    }
    // ... see below
}

An instance of RowChangeMove moves a queen from its current row to a different row.

Planner calls the doMove(ScoreDirector) method to do a move. The Move implementation must notify the ScoreDirector of any changes it make to the planning entities's variables:

    public void doMove(ScoreDirector scoreDirector) {

        scoreDirector.beforeVariableChanged(queen, "row"); // before changes are made
        queen.setRow(toRow);
        scoreDirector.afterVariableChanged(queen, "row"); // after changes are made
    }

You need to call the methods scoreDirector.beforeVariableChanged(Object, String) and scoreDirector.afterVariableChanged(Object, String) before and after modifying the entity. Alternatively, you can also call the methods scoreDirector.beforeAllVariablesChanged(Object) and scoreDirector.afterAllVariablesChanged(Object).

Note

You can alter multiple entities in a single move and effectively create a big move (also known as a coarse-grained move). A move cannot change any of the problem facts.

Planner automatically filters out non doable moves by calling the isDoable(ScoreDirector) method on a move. A non doable move is:

  • A move that changes nothing on the current solution. For example, moving queen B0 to row 0 is not doable, because it is already there.

  • A move that is impossible to do on the current solution. For example, moving queen B0 to row 10 is not doable because it would move it outside the board limits.

In the n queens example, a move which moves the queen from its current row to the same row isn't doable:

    public boolean isMoveDoable(ScoreDirector scoreDirector) {

        return !ObjectUtils.equals(queen.getRow(), toRow);
    }

Because we won't generate a move which can move a queen outside the board limits, we don't need to check it. A move that is currently not doable could become doable on the working Solution of a later step.

Each move has an undo move: a move (normally of the same type) which does the exact opposite. In the example above the undo move of C0 to C2 would be the move C2 to C0. An undo move is created from a Move, before the Move has been done on the current solution.

    public Move createUndoMove(ScoreDirector scoreDirector) {

        return new RowChangeMove(queen, queen.getRow());
    }

Notice that if C0 would have already been moved to C2, the undo move would create the move C2 to C2, instead of the move C2 to C0.

The local search solver phase might do and undo the same Move more than once, even on different (successive) solutions.

A Move must implement the getPlanningEntities() and getPlanningValues() methods. They are used by entity tabu and value tabu respectively. When they are called, the Move has already been done.

    public List<? extends Object> getPlanningEntities() {

        return Collections.singletonList(queen);
    }
    public Collection<? extends Object> getPlanningValues() {
        return Collections.singletonList(toRow);
    }

If your Move changes multiple planning entities, return all them in getPlanningEntities() and return all their values (to which they are changing) in getPlanningValues().

    public Collection<? extends Object> getPlanningEntities() {

        return Arrays.asList(leftCloudProcess, rightCloudProcess);
    }
    public Collection<? extends Object> getPlanningValues() {
        return Arrays.asList(leftCloudProcess.getComputer(), rightCloudProcess.getComputer());
    }

A Move must implement the equals() and hashCode() methods. 2 moves which make the same change on a solution, should be equal.

    public boolean equals(Object o) {

        if (this == o) {
            return true;
        } else if (instanceof RowChangeMove) {
            RowChangeMove other = (RowChangeMove) o;
            return new EqualsBuilder()
                    .append(queen, other.queen)
                    .append(toRow, other.toRow)
                    .isEquals();
        } else {
            return false;
        }
    }
    public int hashCode() {
        return new HashCodeBuilder()
                .append(queen)
                .append(toRow)
                .toHashCode();
    }

In the above example, the Queen class uses the default Object equals() and hashCode() implementations. Notice that it checks if the other move is an instance of the same move type. This instanceof check is important because a move will be compared to a move with another move type if you're using more then 1 move type.

It's also recommended to implement the toString() method as it allows you to read Planner's logging more easily:

    public String toString() {

        return queen + " => " + toRow;
    }

Now that we can make a single move, let's take a look at generating moves.

At each solution, local search will try all possible moves and pick the best move to change to the next solution. It's up to you to generate those moves. Let's take a look at all the possible moves on the starting solution of 4 queens:


As you can see, not all the moves are doable. At the starting solution we have 12 doable moves (n * (n - 1)), one of which will be move which changes the starting solution into the next solution. Notice that the number of possible solutions is 256 (n ^ n), much more that the amount of doable moves. Don't create a move to every possible solution. Instead use moves which can be sequentially combined to reach every possible solution.

It's highly recommended that you verify all solutions are connected by your move set. This means that by combining a finite number of moves you can reach any solution from any solution. Otherwise you're already excluding solutions at the start. Especially if you're using only big moves, you should check it. Just because big moves outperform small moves in a short test run, it doesn't mean that they will outperform them in a long test run.

You can mix different move types. Usually you're better off preferring small (fine-grained) moves over big (course-grained) moves because the score delta calculation will pay off more. However, as the traveling tournament example proves, if you can remove a hard constraint by using a certain set of big moves, you can win performance and scalability. Try it yourself: run both the simple (small moves) and the smart (big moves) version of the traveling tournament example. The smart version evaluates a lot less unfeasible solutions, which enables it to outperform and outscale the simple version.

Move generation currently happens with a MoveFactory:

public class RowChangeMoveFactory extends CachedMoveListMoveFactory {


    public List<Move> createMoveList(Solution solution) {
        NQueens nQueens = (NQueens) solution;
        List<Move> moveList = new ArrayList<Move>();
        for (Queen queen : nQueens.getQueenList()) {
            for (Row toRow : nQueens.getRowList()) {
                moveList.add(new RowChangeMove(queen, toRow));
            }
        }
        return moveList;
    }
}

Future versions might also support move generation by DRL.

To get started quickly, Planner comes with a few build-in MoveFactory implementations:

To use one or multiple build-in MoveFactory implementations, configure it as a Selector:


  <localSearch>
    <selector>
      <selector>
        <moveFactoryClass>org.drools.planner.core.move.generic.GenericChangeMoveFactory</moveFactoryClass>
      </selector>
      <selector>
        <moveFactoryClass>org.drools.planner.core.move.generic.GenericSwapMoveFactory</moveFactoryClass>
      </selector>
    </selector>
    ...
  </localSearch>

They are slightly slower than a custom implementation, but equally scalable.

A step is the winning move. The local search solver tries every move on the current solution and picks the best accepted move as the step:


Because the move B0 to B3 has the highest score (-3), it is picked as the next step. Notice that C0 to C3 (not shown) could also have been picked because it also has the score -3. If multiple moves have the same highest score, one is picked randomly, in this case B0 to B3.

The step is made and from that new solution, the local search solver tries all the possible moves again, to decide the next step after that. It continually does this in a loop, and we get something like this:


Notice that the local search solver doesn't use a search tree, but a search path. The search path is highlighted by the green arrows. At each step it tries all possible moves, but unless it's the step, it doesn't investigate that solution further. This is one of the reasons why local search is very scalable.

As you can see, the local search solver solves the 4 queens problem by starting with the starting solution and make the following steps sequentially:

  1. B0 to B3

  2. D0 to B2

  3. A0 to B1

If we turn on DEBUG logging for the category org.drools.planner, then those steps are shown into the log:

INFO  Solving started: time spend (0), score (-6), new best score (-6), random seed (0).
DEBUG     Step index (0), time spend (20), score (-3), new best score (-3), accepted move size (12) for picked step (col1@row0 => row3).
DEBUG     Step index (1), time spend (31), score (-1), new best score (-1), accepted move size (12) for picked step (col0@row0 => row1).
DEBUG     Step index (2), time spend (40), score (0), new best score (0), accepted move size (12) for picked step (col3@row0 => row2).
INFO  Phase localSearch ended: step total (3), time spend (41), best score (0).
INFO  Solving ended: time spend (41), best score (0), average calculate count per second (1780).

Notice that the logging uses the toString() method of our Move implementation: col1@row0 => row3.

The local search solver solves the 4 queens problem in 3 steps, by evaluating only 37 possible solutions (3 steps with 12 moves each + 1 starting solution), which is only fraction of all 256 possible solutions. It solves 16 queens in 31 steps, by evaluating only 7441 out of 18446744073709551616 possible solutions. Note: with construction heuristics it's even a lot more efficient.

The local search solver decides the next step with the aid of 3 configurable components:


In the above example the selector generated the moves shown with the blue lines, the acceptor accepted all of them and the forager picked the move B0 to B3.

If we turn on TRACE logging for the category org.drools.planner, then the decision making is shown in the log:

INFO  Solver started: time spend (0), score (-6), new best score (-6), random seed (0).
TRACE         Ignoring not doable move (col0@row0 => row0).
TRACE         Move score (-4), accepted (true) for move (col0@row0 => row1).
TRACE         Move score (-4), accepted (true) for move (col0@row0 => row2).
TRACE         Move score (-4), accepted (true) for move (col0@row0 => row3).
...
TRACE         Move score (-3), accepted (true) for move (col1@row0 => row3).
...
TRACE         Move score (-3), accepted (true) for move (col2@row0 => row3).
...
TRACE         Move score (-4), accepted (true) for move (col3@row0 => row3).
DEBUG     Step index (0), time spend (6), score (-3), new best score (-3), accepted move size (12) for picked step (col1@row0 => row3).
...

Because the last solution can degrade (especially in tabu search and simulated annealing), the Solver remembers the best solution it has encountered through the entire search path. Each time the current solution is better than the last best solution, the current solution is cloned and referenced as the new best solution.

An acceptor is used (together with a forager) to active tabu search, simulated annealing, great deluge, ... For each move it checks whether it is accepted or not.

You can implement your own Acceptor, although the build-in acceptors should suffice for most needs. You can also combine multiple acceptors.

When tabu search takes steps it creates tabu's. It does not accept a move as the next step if that move breaks tabu. Drools Planner implements several tabu types:

You can even combine tabu types:


    <acceptor>
        <solutionTabuSize>1000</solutionTabuSize>
        <moveTabuSize>7</moveTabuSize>
    </acceptor>

If you pick a too small tabu size, your solver can still get stuck in a local optimum. On the other hand, with the exception of solution tabu, if you pick a too large tabu size, your solver can get stuck by bouncing of the walls. Use the benchmarker to fine tweak your configuration. Experiments teach us that it is generally best to use a prime number for the move tabu, undo move tabu, entity tabu or value tabu size.

A tabu search acceptor should be combined with a high or no subset selection.

Simulated annealing does not always pick the move with the highest score, neither does it evaluate many moves per step. At least at first. Instead, it gives unimproving moves also a chance to be picked, depending on its score and the time gradient of the Termination. In the end, it gradually turns into a hill climber, only accepting improving moves.

In many use cases, simulated annealing surpasses tabu search. By changing a few lines of configuration, you can easily switch from tabu search to simulated annealing and back.

Start with a simulatedAnnealingStartingTemperature set to the maximum score delta a single move can cause. Use the Benchmarker to tweak the value.


    <acceptor>
      <simulatedAnnealingStartingTemperature>2hard/100soft</simulatedAnnealingStartingTemperature>
    </acceptor>
    <forager>
        <minimalAcceptedSelection>4</minimalAcceptedSelection>
    </forager>

A simulated annealing acceptor should be combined with a low subset selection. The classic algorithm uses a minimalAcceptedSelection of 1, but usually 4 performs better.

You can even combine it with a tabu acceptor at the same time. Use a lower tabu size than in a pure tabu search configuration.


    <acceptor>
      <simulatedAnnealingStartingTemperature>10.0</simulatedAnnealingStartingTemperature>
      <planningEntityTabuSize>5</planningEntityTabuSize>
    </acceptor>
    <forager>
        <minimalAcceptedSelection>4</minimalAcceptedSelection>
    </forager>

This differs from phasing, another powerful technique, where first simulated annealing is used, followed by tabu search.

A forager gathers all accepted moves and picks the move which is the next step. Normally it picks the accepted move with the highest score. If several accepted moves have the highest score, one is picked randomly.

You can implement your own Forager, although the build-in forager should suffice for most needs.

You can plug in a custom Selector, Acceptor, Forager or Termination by extending the abstract class and also the *Config class.

For example, to use a custom Selector, extend the AbstractSelector class (see AllMovesOfOneExamSelector), extend the SelectorConfig class (see AllMovesOfOneExamSelectorConfig) and configure it in the solver configuration:


    <selector class="org.drools.planner.examples.examination.solver.selector.AllMovesOfOneExamSelectorConfig"/>

If you build a better implementation that's not domain specific, consider contributing it back as a pull request on github and we'll take it along in future refactors and optimize it.