2.5 使用Solver求解器

1,481 阅读10分钟

这是我参与更文挑战的第8天,活动详情查看: 更文挑战

内容概要

上前几个篇章,我们学习了OptaPlanner关键的几个概念,也搞学会如何创建一个规划问题求解类,那么今天我们来学习,如何使用OptaPlanner来进行求解。

Solver接口

求解器接口

public interface Solver<Solution_> {

    Solution_ solve(Solution_ problem);

    ...
}

一个Solver实例求解器一次只能解决一个规划问题实例。它是由一个SolverFactory构建的,不需要自己去实现它。 除了那些在javadoc中被明确记载为线程安全的方法外,一个Solver只能从一个线程中访问。

solve()方法占用了当前线程。这可能会导致REST服务的HTTP超时,并且需要额外的代码来并行解决多个数据集。为了避免这些问题,可以使用SolverManager来代替。

问题求解

求解一个问题是相当容易的:

  • 一个由求解器配置构建的求解器
  • 一个代表规划问题实例的@PlanningSolution 只要将规划问题作为参数提供给solve()方法,它就会返回找到的最佳解决方案。
    NQueens problem = ...;
    NQueens bestSolution = solver.solve(problem);

例如,在n个皇后中,solve()方法将返回一个NQueens实例,每个皇后被分配到一个行。

image.png

四个皇后之谜的最佳解法

solve(Solution)方法可能需要很长的时间(取决于问题的大小和求解器的配置)。求解器智能地访问可能的解决方案的搜索空间,并记住它在解算过程中遇到的最佳解决方案。取决于许多因素(包括问题大小、求解器有多少时间、求解器配置......),这个最佳解可能是,也可能不是一个最佳解。

调用方法solve(solution)的解决方案实例是由Solver改变的,但不要以为它是最佳解决方案,因为问题大小、求解配置、约束规则的编写等等原因,有可能不是一个最优解。

环境配置

环境模式允许检测实现中的常见错误,可以在求解器配置的XML文件中设置环境模式:

<solver xmlns="https://www.optaplanner.org/xsd/solver" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="https://www.optaplanner.org/xsd/solver https://www.optaplanner.org/xsd/solver/solver.xsd">
  <environmentMode>FAST_ASSERT</environmentMode>
  ...</solver>

一个求解器有一个单一的随机实例。一些求解器的配置比其他求解器更多地使用随机实例。例如,模拟退火高度依赖于随机数,而禁忌搜索只依赖于它来处理分数相同的问题。环境模式会影响该随机实例的种子。(看不懂这一块没有关系,后续会单独讲解)
支持以下配置:

FULL_ASSERT

FULL_ASSERT模式开启了所有的断言(例如断言每一步棋的增量分数计算没有被破坏),以便在Move实现、约束、引擎本身等方面出现错误时快速失败。
这种模式是可重复的(见可重复模式)。它也是侵入性的,因为它比非断言模式更频繁地调用calculateScore()方法。
FULL_ASSERT模式是非常慢的(因为它不依赖于增量分数计算)。

NON_INTRUSIVE_FULL_ASSERT

NON_INTRUSIVE_FULL_ASSERT开启了几个断言,以便在Move实现、约束条件、引擎本身等方面的错误时快速失败。 这种模式是可重复的(见可重复模式)。它是非侵入性的,因为它不会比非断言模式更频繁地调用方法calculateScore()
NON_INTRUSIVE_FULL_ASSERT模式就是龟速(因为它不依赖增量分数计算)。

FAST_ASSERT

FAST_ASSERT模式开启了大多数断言(例如断言一个undoMove的分数与移动前相同),以便在移动实现、约束条件、引擎本身等方面出现错误时快速失败。
这种模式是可重复的(见可重复模式)。它也是侵入性的,因为它比非断言模式更频繁地调用方法calculateScore()
FAST_ASSERT模式也很慢。
建议写一个测试用例,在FAST_ASSERT模式下对你的规划问题做一个简短的运行。

REPRODUCIBLE (默认的)

可重现模式是默认模式,因为它在开发过程中被推荐。在这种模式下,同一OptaPlanner中的两次运行将以相同的顺序执行相同的代码。这两个运行在每一步都会有相同的结果,除非下面的注解变化。这使能够一致地重现bug。它还允许你在不同的运行中公平地对某些重构(如分数约束的性能优化)进行基准测试。
这个配置十分有用,我们在调试开发过程中,需要复现上一次的求解结果来发现问题。

尽管有可重现的模式,应用程序仍然可能无法完全重现,因为:
使用无序的HashSet(或另一个在JVM运行之间具有不一致顺序的集合)作为规划实体或规划值(但不是正常的问题事实)的集合,特别是在解决方案的实现中,注意用LinkedHashSet代替它。
将依赖时间梯度的算法(最明显的是模拟退火法)与花费时间的终止结合起来。分配的CPU时间有足够大的差异会影响时间梯度值。用Late Acceptance代替Simulated Annealing。或者用步数终止来代替花费时间的终止。(这种情况不常见)

可重现的模式可能比不可重现的模式稍慢一些。如果你的生产环境可以从可重复性中得到一些帮助,请在生产中使用这种模式。

在实践中,如果没有指定种子,这种模式会使用默认的、固定的随机种子,而且它还会禁用某些并发优化。

NON_REPRODUCIBLE

非重现模式可能比重现模式稍快。在开发过程中避免使用它,因为它使调试和修复错误变得非常痛苦。如果你的生产环境不关心可重复性,在生产中使用这种模式。 在实践中,如果没有指定种子,该模式不使用固定的随机种子。

日志级别

我们可以调整日志的级别,通过日志来观察求解器的每一步都做了什么,这对于我们调试问题是很有帮助的。

  • error:记录错误,除了那些作为RuntimeException抛给调用代码的错误。
    • 如果发生错误,OptaPlanner会快速失败:它抛出一个RuntimeException的子类,并向调用代码提供详细的信息。它不会把它作为一个错误记录下来,以避免重复的日志信息。除非调用代码明确捕获并吃掉该RuntimeException,否则线程的默认ExceptionHandler会将其记录为错误。同时,代码会被终止。
  • warn:记录可疑的情况。
  • info:记录每个阶段和解算器本身。
  • debug:记录每个阶段的每个步骤。
  • trace:记录每个阶段的每个步骤的每一个动作。
    • trace级别下,会大大降低性能:通常会慢四倍。然而,在开发过程中,它对于发现瓶颈是非常宝贵的。 例如,将其设置为debug级别,以查看阶段性结束的时间和步骤的速度:
INFO  Solving started: time spent (3), best score (-4init/0), random (JDK with seed 0).
DEBUG     CH step (0), time spent (5), score (-3init/0), selected move count (1), picked move (Queen-2 {null -> Row-0}).
DEBUG     CH step (1), time spent (7), score (-2init/0), selected move count (3), picked move (Queen-1 {null -> Row-2}).
DEBUG     CH step (2), time spent (10), score (-1init/0), selected move count (4), picked move (Queen-3 {null -> Row-3}).
DEBUG     CH step (3), time spent (12), score (-1), selected move count (4), picked move (Queen-0 {null -> Row-1}).
INFO  Construction Heuristic phase (0) ended: time spent (12), best score (-1), score calculation speed (9000/sec), step total (4).
DEBUG     LS step (0), time spent (19), score (-1),     best score (-1), accepted/selected move count (12/12), picked move (Queen-1 {Row-2 -> Row-3}).
DEBUG     LS step (1), time spent (24), score (0), new best score (0), accepted/selected move count (9/12), picked move (Queen-3 {Row-3 -> Row-2}).
INFO  Local Search phase (1) ended: time spent (24), best score (0), score calculation speed (4000/sec), step total (2).
INFO  Solving ended: time spent (24), best score (0), score calculation speed (7000/sec), phase total (2), environment mode (REPRODUCIBLE).

所有花费的时间值都是以毫秒为单位,所有的东西都被记录到SLF4J

logback.xml文件中配置org.optaplanner包的日志级别。

<configuration>

  <logger name="org.optaplanner" level="debug"/>

  ...
</configuration>

SolverManager使用

SolverManager是一个用于一个或多个Solver实例的门面,以简化REST和其他企业服务中规划问题的解决。与Solver.solve(...)方法不同。

SolverManager.solve(...)立即返回:它为异步求解安排问题,而不阻塞调用线程。这避免了HTTP和其他技术的超时问题。

SolverManager.solve(...)可以并行地解决同一领域的多个规划问题。

在内部,SolverManager管理着一个调用Solver.solve(...)的求解器线程池,以及一个处理最佳解决方案变更事件的消费者线程池。

在Spring Boot中,SolverManager实例会自动注入到代码中。否则,用create(...)方法建立一个SolverManager实例。

SolverConfig solverConfig = SolverConfig.createFromXmlResource(".../cloudBalancingSolverConfig.xml");
SolverManager<CloudBalance, UUID> solverManager = SolverManager.create(solverConfig, new SolverManagerConfig());

提交给SolverManager.solve(...)方法的每个问题都需要一个唯一的problemId。以后对getSolverStatus(problemId)terminateEarly(problemId)的调用会使用该problemId来区分规划问题。problemId必须是一个不可变的类,如LongStringjava.util.UUID

SolverManagerConfig类有一个parallelSolverCount属性,它控制了多少个求解器被并行运行。例如,如果设置为4,提交五个问题时有四个问题立即解决,第五个问题在另一个问题结束时开始。如果这些问题每个问题解决5分钟,第五个问题需要10分钟才能完成。默认情况下,parallelSolverCount被设置为AUTO,这就解决了一半的CPU核心,与求解器的moveThreadCount无关。

要后去最佳解,在求解器正常终止后,使用SolverJob.getFinalBestSolution()

CloudBalance problem1 = ...;
UUID problemId = UUID.randomUUID();// Returns immediately
SolverJob<CloudBalance, UUID> solverJob = solverManager.solve(problemId, problem1);
...
CloudBalance solution1;try {
    // Returns only after solving terminates
    solution1 = solverJob.getFinalBestSolution();
} catch (InterruptedException | ExecutionException e) {
    throw ...;
}

批量求解

要并行解决多个数据集(受parallelSolverCount限制),需要每个数据集调用solve()

public class TimeTableService {

    private SolverManager<TimeTable, Long> solverManager;

    // 立即返回,对每个数据集都调用它
    public void solveBatch(Long timeTableId) {
        solverManager.solve(timeTableId,
                // 调用一次,当求解开始时
                this::findById,
                // 调用一次,当求解结束时
                this::save);
    }

    public TimeTable findById(Long timeTableId) {...}

    public void save(TimeTable timeTable) {...}

}

求解方案监听

当求解器运行时,终端用户正在等待解决方案,用户可能需要等待数分钟或数小时才能收到一个结果。为了向用户展示一切进展顺利,可以通过显示最佳解和到目前为止取得的最佳分数来显示进展。

要处理中间的最佳解决方案,可以使用solveAndListen()

public class TimeTableService {

    private SolverManager<TimeTable, Long> solverManager;

    // 立即返回
    public void solveLive(Long timeTableId) {
        solverManager.solveAndListen(timeTableId,
                // 调用一次,当求解开始时
                this::findById,
                // 多次调用,每一个最佳解决方案的改变都会调用
                this::save);
    }

    public TimeTable findById(Long timeTableId) {...}

    public void save(TimeTable timeTable) {...}

    public void stopSolving(Long timeTableId) {
        solverManager.terminateEarly(timeTableId);
    }

}

如果对中间的最佳解决方案已经满意了,并且不想再等待更好的解决方案,就调用SolverManager.terminateEarly(problemId)结束即可。

总结

这一篇章主要学习了Solver的使用,大家在后续使用时,建议使用SolverManager。环境配置这一片段有些内容比较难懂,没有关系后续会单独讲解。

作业

大家可以在之前的例子上使用今天所学习的内容来进行实战,尽快掌握对SolverManger的使用。

结束语

下一篇章,我们将来学习最重要的一个环节,约束评分规则的编写,而且它也是相对复杂的。

创作不易,禁止未授权的转载。如果我的文章对您有帮助,就请点赞/收藏/关注鼓励支持一下吧❤❤❤❤❤❤