Local search solver

Overview

In number of possible solutions for a planning problem can be mind blowing. For example:

  • 4 queens has 256 possible solutions (n ^ n) and 2 optimal solutions.

  • 5 queens has 3125 possible solutions (n ^ n) and 1 optimal solution.

  • 8 queens has 16777216 possible solutions (n ^ n) and 92 optimal solutions.

  • Most real-life planning problems have an incredible number of possible solutions and only 1 optimal solution.

An algorithm that checks every possible solution (even with pruning) can easily run for a couple of years on a single real-life planning problem. Most of the time, we are happy with a feasible solution found in a limited amount of time. Local search tends to find a feasible solution relatively fast. Because it acts very much like a human, it is also pretty natural to program.

Local search solves a problem making a move on the current solution to change it into a better solution. It does that number of times till it is satisfied with the solution. It starts with the starting solution.

A local search algorithm and the drools rule engine turn out to be a really nice combination, because:

  • A rule engine such as Drools is great for calculating the score of a solution of a planning problem. It make it easy to add additional soft or hard constraints such as "a teacher shouldn't teach more then 7 hours a day". However it tends to be too complex to use to actually find new solutions.

  • A local search algorithm is great at finding new improving solutions for a planning problem, without brute-forcing every possibility. However it needs to know the score of a solution and normally offers no support in calculating that score.

Drools-solver's local search implementation combines both. On top of that, it also offers additional support for benchmarking etc.

A move

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:

Figure 1.12. A single move (4 queens example)

A single move (4 queens example)

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(EvaluationHandler evaluationHandler);

    Move createUndoMove(EvaluationHandler evaluationHandler);

    void doMove(EvaluationHandler evaluationHandler);

}

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

public class YChangeMove implements Move {

    private Queen queen;
    private int toY;

    public YChangeMove(Queen queen, int toY) {
        this.queen = queen;
        this.toY = toY;
    }

    // ... see below

}

An instance of YChangeMove moves a queen from it's current y to a different y.

Drool-solver calls the doMove(WorkingMemory) method to do a move. The Move implementation must notify the working memory of any changes it does on the solution facts:

    public void doMove(WorkingMemory workingMemory) {
        FactHandle queenHandle = workingMemory.getFactHandle(queen);
        workingMemory.modifyRetract(queenHandle); // before changes are made
        queen.setY(toY);
        workingMemory.modifyInsert(queenHandle, queen); // after changes are made
    }

Drools-solver disables shadow facts for increased performance, so you cannot use the workingMemory.update(FactHandle, Object) method, instead you need to call the workingMemory.modifyRetract(FactHandle) method before modifying the fact and the workingMemory.modifyInsert(FactHandle, Object) method after modifying the fact. Note that you can alter multiple facts in a single move and effectively create a big move (also known as a coarse-grained move).

Drools-solver automatically filters out non doable moves by calling the isDoable(WorkingMemory) 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.

  • 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 it's current row to the same row isn't doable:

    public boolean isMoveDoable(WorkingMemory workingMemory) {
        int fromY = queen.getY();
        return fromY != toY;
    }

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 can become doable on a later solution.

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

    public Move createUndoMove(WorkingMemory workingMemory) {
        return new YChangeMove(queen, queen.getY());
    }

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 can do and undo a move more than once, even on different (successive) solutions.

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

    public boolean equals(Object o) {
        if (this == o) {
            return true;
        } else if (o instanceof YChangeMove) {
            YChangeMove other = (YChangeMove) o;
            return new EqualsBuilder()
                    .append(queen, other.queen)
                    .append(toY, other.toY)
                    .isEquals();
        } else {
            return false;
        }
    }

    public int hashCode() {
        return new HashCodeBuilder()
                .append(queen)
                .append(toY)
                .toHashCode();
    }

In the above example, the Queen class uses the default Object equal() and hashcode() implementations. Notice that it checks if the other move is an instance of the same move type. This 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 drools-solver's logging more easily:

    public String toString() {
        return queen + " => " + toY;
    }

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

Move generation

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:

Figure 1.13. Possible moves at step 0 (4 queens example)

Possible moves at step 0 (4 queens example)

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 NQueensMoveFactory extends CachedMoveListMoveFactory {

    public List<Move> createMoveList(Solution solution) {
        NQueens nQueens = (NQueens) solution;
        List<Move> moveList = new ArrayList<Move>();
        for (Queen queen : nQueens.getQueenList()) {
            for (int n : nQueens.createNList()) {
                moveList.add(new YChangeMove(queen, n));
            }
        }
        return moveList;
    }

}

But we'll be making move generation part of the drl's soon.

A step

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:

Figure 1.14. Decide the next step at step 0 (4 queens example)

Decide the next step at step 0 (4 queens example)

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:

Figure 1.15. All steps (4 queens example)

All steps (4 queens example)

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 INFO logging, this is reflected into the logging:

INFO  Solving with random seed (0).
INFO  Initial score (-6.0) is starting best score. Updating best solution and best score.
INFO  Step (0), time spend (0) doing next step ([Queen-1] 1 @ 0 => 3).
INFO  New score (-3.0) is better then last best score (-6.0). Updating best solution and best score.
INFO  Step (1), time spend (0) doing next step ([Queen-3] 3 @ 0 => 2).
INFO  New score (-1.0) is better then last best score (-3.0). Updating best solution and best score.
INFO  Step (2), time spend (15) doing next step ([Queen-0] 0 @ 0 => 1).
INFO  New score (0.0) is better then last best score (-1.0). Updating best solution and best score.
INFO  Solved in 3 steps and 15 time millis spend.

Notice that the logging used the toString() method from our Move implementation: [Queen-1] 1 @ 0 => 3.

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.

Getting stuck in local optima

A simple local search always takes improving moves. This may seem like a good thing, but it's not. It suffers from a number of problems:

  • It can get stuck in a local optimum. For example if it reaches a solution X with a score -1 and there is no improving move, it is forced to take a next step that leads to a solution Y with score -2, after that however, it's very real that it will pick the step back to solution X with score -1. It will then start looping between solution X and Y.

  • It can start walking in it's own footsteps, picking the same next step at every step.

Of course drools-solver implements better local searches, such tabu search and simulated annealing which can avoid these problems. It's recommended to never use a simple local search, unless you're absolutely sure there are no local optima in your planning problem.

Deciding the next step

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

  • A selector which selects (or generates) the possible moves of the current solution.

  • An accepter which filters out unacceptable moves. It can also weigh a move it accepts.

  • A forager which gathers all accepted moves and picks the next step from them.

Figure 1.16. Decide the next step at step 0 (4 queens example)

Decide the next step at step 0 (4 queens example)

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

If we turn on DEBUG logging, we can see the decision making in the log:

INFO  Solving with random seed (0).
INFO  Initial score (-6.0) is starting best score. Updating best solution and best score.
DEBUG     Move ([Queen-0] 0 @ 0 => 0) ignored because not doable.
DEBUG     Move ([Queen-0] 0 @ 1 => 1) with score (-4.0) and acceptChance (1.0).
DEBUG     Move ([Queen-0] 0 @ 2 => 2) with score (-4.0) and acceptChance (1.0).
...
DEBUG     Move ([Queen-1] 1 @ 3 => 3) with score (-3.0) and acceptChance (1.0).
...
DEBUG     Move ([Queen-3] 3 @ 3 => 3) with score (-4.0) and acceptChance (1.0).
INFO  Step (0), time spend (0) doing next step ([Queen-1] 1 @ 0 => 3).
INFO  New score (-3.0) is better then last best score (-6.0). Updating best solution and best score.
...

Selector

A selector is currently based on a MoveFactory. We're working on improving this.

    <selector>
        <moveFactoryClass>org.drools.solver.examples.nqueens.solver.NQueensMoveFactory</moveFactoryClass>
    </selector>

You're not obligated to generate the same stable set of moves at each step. You could start with generating only big moves initially, and gradually switch to small moves. There's no build-in support for this yet though.

Accepter

An accepter is used (together with a forager) to active tabu search, simulated annealing, great deluge, ... For each move it generates an accept chance. If a move is rejected it is given an accept chance of 0.0.

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

Tabu search accepter

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-solver implements several tabu types:

  • Solution tabu makes recently visited solutions tabu. It does not accept a move that leads to one of those solutions. If you can spare the memory, don't be cheap on the tabu size. We recommend this type of tabu because it tends to give the best results and requires little or no tweaking.

        <accepter>
            <completeSolutionTabuSize>1000</completeSolutionTabuSize>
        </accepter>
  • Move tabu makes recent steps tabu. It does not accept a move equal to one of those steps.

        <accepter>
            <completeMoveTabuSize>1000</completeMoveTabuSize>
        </accepter>
  • Undo move tabu makes the undo move of recent steps tabu.

        <accepter>
            <completeUndoMoveTabuSize>1000</completeUndoMoveTabuSize>
        </accepter>
  • Property tabu makes a property of recent steps tabu. For example, it can make the queen tabu, so that a recently moved queen can't be moved.

        <accepter>
            <completePropertyTabuSize>1000</completePropertyTabuSize>
        </accepter>

    To use property tabu, your moves must implement the TabuPropertyEnabled interface, for example:

    public class YChangeMove implements Move, TabuPropertyEnabled {
    
        private Queen queen;
        private int toY;
    
        // ...
    
        public List<? extends Object> getTabuPropertyList() {
            return Collections.singletonList(queen);
        }
    
    }

You can even combine tabu types:

    <accepter>
        <completeSolutionTabuSize>1000</completeSolutionTabuSize>
        <completeUndoMoveTabuSize>10</completeUndoMoveTabuSize>
    </accepter>

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.

A tabu search accepter should be combined with a maximum score of all or first best score improving forager.

Simulated annealing accepter

Simulated annealing does not pick the move with the highest score, neither does it evaluate all moves. At least at first.

It gives unimproving moves a chance, depending on it's score and the temperature. The temperature is relative to how long it has been solving. In the end, it gradually turns into a simple local search, only accepting improving moves.

A simulated annealing accepter should be combined with a first randomly accepted forager.

Forager

A forager gathers all accepted moves and picks the move which is the next step. A forager can choose to allow only a subset of all selected moves to be evaluated, by quitting early if a suitable move has been accepted.

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

Maximum score of all forager

Allows all selected moves to be evaluated and picks the accepted move with the highest score. If several accepted moves have the highest score, one is picked randomly, weighted on their accept chance.

    <forager>
        <foragerType>MAX_SCORE_OF_ALL</foragerType>
    </forager>
First best score improving forager

Picks the first accepted move that improves the best score. If none improve the best score, it behaves exactly like the maximum score of all forager.

    <forager>
        <foragerType>FIRST_BEST_SCORE_IMPROVING</foragerType>
    </forager>
First last step score improving forager

Picks the first accepted move that improves the last step score. If none improve the last step score, it behaves exactly like the maximum score of all forager.

    <forager>
        <foragerType>FIRST_BEST_SCORE_IMPROVING</foragerType>
    </forager>
First randomly accepted forager

Generates a random number for each accepted move and if the move's accept chance is higher, it picks that move as the next move.

    <forager>
        <foragerType>FIRST_RANDOMLY_ACCEPTED</foragerType>
    </forager>

Best solution

Because the current solution can degrade (especially in tabu search and simulated annealing), the local search 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.

Finish

Sooner or later the local search solver will have to stop solving. This can be because of a number of reasons: the time is up, the perfect score has been reached, ... The only thing you can't depend on is on finding the optimal solution (unless you know the optimal score), because a local search solver doesn't know that when it finds the optimal solution. For real-life problems this doesn't turn out to be much of a problem, because finding the optimal solution would take years, so you 'll want to finish early anyway.

You can configure when a local search solver needs to stop by configuring a Finish. You can implement your own Finish, although the build-in finishes should suffice for most needs.

TimeMillisSpendFinish

Finishes when an amount of time has been reached:

    <finish>
        <maximumMinutesSpend>2</maximumMinutesSpend>
    </finish>

or

    <finish>
        <maximumHouresSpend>1</maximumHouresSpend>
    </finish>

Note that the time taken by a StartingSolutionInitializer also is taken into account by this finish. So if you give the solver 2 minutes to solve something, but the initializer takes 1 minute, the local search solver will only have a minute left.

Note that if you use this finish, you will most likely sacrifice reproducability. The best solution will depend on available CPU time, not only because it influences the amount of steps taken, but also because time gradient based algorithms (such as simulated annealing) will probably act differently on each run.

StepCountFinish

Finishes when an amount of steps has been reached:

    <finish>
        <maximumStepCount>100</maximumStepCount>
    </finish>

FeasableScoreFinish

Finishes when a feasible score has been reached. You can also use this finish if you know the perfect score, for example for 4 queens:

    <finish>
        <feasableScore>0.0</feasableScore>
    </finish>

UnimprovedStepCountFinish

Finishes when the best score hasn't improved in a number of steps:

    <finish>
        <maximumUnimprovedStepCount>100</maximumUnimprovedStepCount>
    </finish>

If it hasn't improved recently, it's probably not going to improve soon anyway and it's not worth the effort to continue. We have observed that once a new best solution is found (even after a long time of no improvement on the best solution), the next few step tend to improve the best solution too.

Combining finishes

Finishes can be combined, for example: finish after 100 steps or if a score of 0.0 has been reached:

    <finish>
        <finishCompositionStyle>OR</finishCompositionStyle>
        <maximumStepCount>100</maximumStepCount>
        <feasableScore>0.0</feasableScore>
    </finish>

Alternatively you can use AND, for example: finish after reaching a feasible score of at least -100 and no improvements in 5 steps:

    <finish>
        <finishCompositionStyle>AND</finishCompositionStyle>
        <maximumUnimprovedStepCount>5</maximumUnimprovedStepCount>
        <feasableScore>-100.0</feasableScore>
    </finish>

This ensures it doesn't just finish after finding a feasible solution, but also makes any obvious improvements on that solution before finishing.