OptaPlanning 第六章约束流分数计算

6. 约束流分数计算

约束流是一种函数式编程形式的增量分数计算,在纯Java中易于读、写和调试。如果您熟悉Java Streams或SQL,应该会对该API感到熟悉。

6.1. 介绍

使用Java的Streams API,我们可以实现一个简单的分数计算器,使用函数式方法:

然而,它的可伸缩性很差,因为它没有进行增量计算:当单个Shift的计划变量发生变化时,为了重新计算分数,普通的Streams API必须从头开始执行整个流。ConstraintStreams API使您能够在纯Java中编写类似的代码,同时获得增量分数计算的性能优势。下面是使用约束流API的相同代码示例:

这个约束流遍历问题事实中的类Shift的所有实例和计划问题中的计划实体。它查找分配给员工Ann的每个班次,并且对于每个这样的实例(也称为匹配),它在总分中添加1的软惩罚。下图展示了一个有4种不同班次的问题的过程:

如果在求解过程中任何实例发生更改,约束流将自动检测更改,并仅重新计算受更改影响的问题的最小必要部分。下图展示了这种增量分数计算:

ConstraintStreams API还支持通过自定义论证和起诉来解释分数。

6.2. 创建约束流

要在项目中使用ConstraintStreams API,首先编写一个类似于以下示例的纯Java ConstraintProvider实现。

这个例子包含一个约束:penalizeEveryShift(…)但是,您可以根据

需要包含尽可能多的内容。

将以下代码添加到您的求解器配置中:

6.3. 约束流基数

约束流基数是衡量单个约束匹配包含多少对象的度量。最简单的约束流的基数为1,这意味着每个约束匹配只包含1个对象。因此,它被称为UniConstraintStream:

一些约束流构建块可以增加流基数,例如join或groupBy:

后者也可以减少流基数:

当前支持以下约束流基数:

6.3.1. 实现更高的基数

OptaPlanner目前不支持大于4的约束流基数。然而,使用元组映射,无限基数是可能的:

OptaPlanner不提供任何开箱即用的元组实现。

建议使用免费的第三方实现之一。如果需要自定义实现,

请参阅映射函数的指导原则。

6.4. 构建块

约束流是不同操作的链,称为构建块。每个约束流都以forEach(…)构建块开始,并以惩罚或奖励结束。下面的例子展示了最简单的约束流:

这个约束流惩罚每个已知的和初始化的Shift实例。

6.4.1. ForEach

foreach (T)构建块选择问题事实集合或规划实体集合中的每个T实例,并且没有空的真正的规划变量。

要包含具有null真实规划变量的实例,请将forEach()构建块替换为foreachinclingnullvars ():

forEach()构建块有一个遗留的对等体from()。这种替代方法包括

基于其真正规划变量的初始化状态的实例。作为一个不希望的结果,

from()对可空变量的行为出乎意料。即使这些变量为空,也被认为

是初始化的,因此这个遗留方法仍然可以返回具有空变量的实体。

from(), frommunfiltered()和fromUniquePair()现在已弃用,

并将在OptaPlanner的未来主要版本中删除。

6.4.2. 惩罚和奖励

约束流的目的是为解决方案建立一个分数。为此,每个约束流都必须包含对penalize ()或reward()构建块的调用。penalize ()构建块会使分数变差,而reward()构建块会提高分数。

然后通过调用asConstraint()方法终止每个约束流,该方法最终构建约束。约束有几个组成部分:

  • 约束包是包含约束的Java包。默认值是包含ConstraintProvider实现的包,或者是约束配置的值(如果实现了)。
  • 约束名称是约束的人可读的描述性名称,它(连同约束包)在整个ConstraintProvider实现中必须是唯一的。
  • 约束权重是一个常数分数值,表示每次违反约束对分数的影响程度。有效的例子包括SimpleScore.ONE, HardSoftScore.ONE_HARD ,HardMediumSoftScore.of(1, 2, 3)。
  • 约束匹配权重是一个可选函数,指示在分数中应该应用约束权重多少次。惩罚或奖励分数的影响是约束权重乘以匹配权重。缺省值为1。

约束权重为零的约束将自动禁用,并且不会造成任何性能损失。

ConstraintStreams API支持许多不同类型的惩罚。浏览IDE中的API以获得方法重载的完整列表。下面是一些例子:

  • 简单的惩罚((penalize("Constraint name", SimpleScore.ONE))会使约束流中的每一次匹配的分数差1。分数类型必须与计划解决方案上的@PlanningScore注释成员使用的类型相同。
  • 动态惩罚(penalize("Constraint name", SimpleScore.ONE, Shift::getHours))使分数变差,因为在约束流中每个匹配Shift的小时数。这是一个使用约束匹配加权器的示例。
  • 可配置的惩罚(penalizeConfigurable("Constraint name"))使用约束配置中定义的约束权重使分数更差。
  • 可配置的动态惩罚(penalizeConfigurable("Constraint name", Shift::getHours))使用约束配置中定义的约束权重乘以约束流中每个匹配的Shift的小时数,使分数更差。

通过将这些构建模块名称中的关键字“惩罚”替换为“奖励”,你将获得影响分数的反方向操作。

6.4.2.1. 定制辩护和起诉

OptaPlanner的一个重要功能是它能够通过使用论证和起诉来解释它产生的解决方案的分数。默认情况下,每个约束都是用org.optaplanner.core.api.score.stream来证明的。DefaultConstraintJustification,最后的元组组成被起诉的对象。例如,在下面的约束中,被起诉的对象将是Vehicle类型和一个Integer:

protected Constraint vehicleCapacity(ConstraintFactory factory){返回factory. foreach (customer .class) .filter(customer::getVehicle, sum(customer::getDemand)) .filter((vehicle, demand) -> demand > vehicle. getcapacity ()) . penalizelong (HardSoftLongScore. factory){返回factory. foreach (customer .class) .filter(customer::getVehicle, sum))ONE_HARD, (vehicle, demand) -> demand - vehicle. getcapacity ()) .asConstraint("vehicleCapacity");}

为了创建热图,Vehicle非常重要,但是裸露的Integer没有任何语义。我们可以通过提供带有自定义起诉书映射的' indictWith(…)方法来删除它:

protected Constraint vehicleCapacity(ConstraintFactory factory){返回factory. foreach (customer .class) .filter(customer::getVehicle, sum(customer::getDemand)) .filter((vehicle, demand) -> demand > vehicle. getcapacity ()) . penalizelong (HardSoftLongScore. factory){返回factory. foreach (customer .class) .filter(customer::getVehicle, sum))ONE_HARD, (vehicle, demand) -> demand - vehicle. getcapacity ()) .indictWith((vehicle, demand) -> List.of(vehicle)) .asConstraint("vehicleCapacity");}

同样的机制也可以用于将任何被起诉的对象转换为任何其他对象。要将约束匹配呈现给用户或将它们发送到可以进一步处理的地方,请使用justifyWith(…)方法来提供自定义约束对齐:

protected Constraint vehicleCapacity(ConstraintFactory factory){返回factory. foreach (customer .class) .filter(customer::getVehicle, sum(customer::getDemand)) .filter((vehicle, demand) -> demand > vehicle. getcapacity ()) . penalizelong (HardSoftLongScore. factory){返回factory. foreach (customer .class) .filter(customer::getVehicle, sum))ONE_HARD, (vehicle, demand) -> demand - vehicle. getcapacity ()) .justify ((vehicle, demand, score) -> new VehicleDemandOveruse(vehicle, demand, score)) .indictWith((vehicle, demand) -> List.of(vehicle)) .asConstraint("vehicleCapacity");}

VehicleDemandOveruse是你必须实现的自定义类型。您可以完全控制类型、其名称或公开的方法。如果您选择使用适当的注释来修饰它,您将能够通过HTTP发送它或将它存储在数据库中。唯一的限制是它必须实现org.optaplanner.core.api. stream. constraintjustification标记接口。

6.4.3. 过滤

过滤使您能够减少流中约束匹配的数量。它首先枚举所有约束匹配,然后应用谓词过滤掉一些匹配。谓词是一个函数,只有在流中继续匹配时才返回true。以下约束流从所有Shift匹配中删除Beth的所有移位:

以下示例从Shift和DayOff的双约束匹配中检索员工要求休假一天的班次列表:

下图说明了这两个例子:

出于性能考虑,最好在可能的情况下使用连接构建块和适当的Joiner。

使用Joiner只创建必要的约束匹配,而过滤连接创建所有可能的约束匹配,

然后只过滤掉其中的一些。

过滤不同基数的约束流需要以下函数:

基数

过滤谓词

1

java.util.function.Predicate <一>

2

java.util.function。BiPredicate < A、B >

3

org.optaplanner.core.api.function.TriPredicate<A, B, C>

4

org.optaplanner.core.api.function.QuadPredicate<A, B, C, D>

6.4.4. 加入

连接是一种增加流基数的方法,它类似于SQL中的内部连接操作。如下图所示,join()创建了被连接流的笛卡尔积:

如果结果流包含大量需要立即过滤掉的约束匹配,那么这样做是低效的。

相反,使用Joiner条件将连接的匹配限制为那些感兴趣的匹配:

例如:

通过Joiners类,支持以下Joiner条件来连接两个流,从每一边配对一个匹配:

  • Equal():配对匹配的属性是equals()。这依赖于hashCode()。
  • greaterThan(), greaterThanOrEqual(), lessThan()和lessThanOrEqual():配对匹配具有按照指定顺序的Comparable属性。
  • 重叠():配对匹配具有相同Comparable类型的两个属性(一个开始属性和一个结束属性),它们都表示重叠的间隔。

所有Joiners方法都有一个重载方法,以便在流的两端使用相同类的相同属性。例如,调用equal(Shift::getEmployee)与调用equal(Shift::getEmployee, Shift::getEmployee)是相同的。

如果其他流可能匹配多次,但它必须只影响一次分数(对于原始流的每个元素),

则使用ifExists代替。它不会产生笛卡尔积,因此通常表现更好。

6.4.5. 分组和收集器

分组根据用户-提供者标准(也称为“组键”)收集流中的项,类似于group BY SQL子句的功能。此外,一些分组操作还接受一个或多个Collector实例,这些实例提供各种聚合功能。下图展示了一个简单的groupBy()操作:

作为组键使用的对象必须遵守hashCode的一般契约。最重要的是,

“每当在Java应用程序执行期间对同一对象多次调用它时,

hashCode方法必须一致地返回相同的整数。”

因此,不建议使用可变对象(尤其是可变集合)作为组键。

如果将规划实体用作组键,则不能从规划变量中计算它们的hashCode。

不遵循此建议可能会导致抛出运行时异常。

例如,下面的代码片段首先按运行的计算机对所有进程进行分组,使用ConstraintCollectors.sum(…)收集器汇总该计算机上的进程所需的所有功率,最后惩罚进程消耗的功率超过可用功率的每台计算机。

在分组过程中可能会丢失信息。在前面的示例中,

filter()和所有后续操作不再直接访问原始CloudProcess实例。

有几种现成的收集器。你也可以通过实现org.optaplanner.core.api. stream.uni. uniconstraintcollector接口,或者它的Bi…,Tri…和Quad…来提供你自己的收集器。

6.4.5.1. 开箱即用的收藏家

下列收集器是现成的:

6.4.5.1.1. count ()收集器

constraintcollections .count(…)计算每个组的所有元素。例如,下面对收集器的使用为两个独立的组提供了许多项—一个组中有不可用的演讲者,另一个组中没有。

计数以int类型收集。此收集器的变体:

  • countLong()收集一个长值而不是整型值。

要对bi、tri或quad流进行计数,请分别使用countBi()、countTri()或countQuad(),因为与其他内置收集器不同,由于Java的泛型擦除,它们不是重载方法。

6.4.5.1.2. countDistinct ()收集器

constraintcollections . countdistinct(…)对每个组中的任何元素计数一次,而不管它出现了多少次。例如,下面对收集器的使用在每个独特的房间中给出了一些对话。

不同的计数在int中收集。此收集器的变体:

  • countDistinctLong()收集一个长值而不是int值。

6.4.5.1.3. sum ()收集器

要对每组中所有元素的特定属性的值求和,请使用constraintcollections .sum(…)收集器。下面的代码片段首先按运行的计算机对所有进程进行分组,并使用ConstraintCollectors.sum(…)收集器汇总该计算机上的进程所需的所有功率。

总和在int中收集。此收集器的变体:

  • sumLong()收集一个长值而不是整型值。
  • sumBigDecimal()收集java.math.BigDecimal值而不是int值。
  • sumBigInteger()收集java.math.BigInteger值而不是int值。
  • sumDuration()收集java.time.Duration值而不是int值。
  • sumPeriod()收集java.time.Period值而不是int值。
  • 用于对自定义类型求和的泛型sum()变体

6.4.5.1.4. 平均()收集器

要计算每个组中所有元素的特定属性的平均值,请使用constraintcollections .average(…)收集器。下面的代码片段首先按运行的计算机对所有进程进行分组,然后使用constraintcollector .average(…)收集器对该计算机上的进程所需的所有功率进行平均。

平均值被收集为双精度,并且没有元素的平均值为空。此收集器的变体:

  • averageLong()收集一个长值而不是整型值。
  • averageBigDecimal()收集java.math.BigDecimal值而不是int值,从而产生BigDecimal平均值。
  • averageBigInteger()收集java.math.BigInteger值而不是int值,从而产生BigDecimal平均值。
  • averageDuration()收集java.time.Duration值而不是int值,从而产生Duration平均值。

6.4.5.1.5. min ()和max ()收藏家

要提取每个组的最小值或最大值,分别使用constraintcollector .min(…)和constraintcollector .max(…)收集器。

这些收集器对可比较的属性值(如Integer、String或Duration)进行操作,尽管这些收集器也有允许您提供自己的Comparator的变体。

下面的例子找到一台运行最耗电进程的计算机:

使用min(…)和max(…)约束收集器的Comparator和Comparable实现

应该与equals(…)一致。请参阅Javadoc中的Comparable了解更多信息。

6.4.5.1.6. toList(), toSet() and toMap() 

要将每个组的所有元素提取到集合中,请使用constraintcollections . tolist(…)。

下面的示例在列表中检索计算机上运行的所有进程:

此收集器的变体:

  • toList()收集List值。
  • toSet()收集一个Set值。
  • toSortedSet()收集一个SortedSet值。
  • toMap()收集Map值。
  • toSortedMap()收集一个SortedMap值。

结果集合中元素的迭代顺序不能保证是稳定的,除非它是一个排序的收集器,如toSortedSet或toSortedMap。

6.4.5.2. 有条件的收藏家

约束收集器框架使您能够创建仅在特定情况下进行收集的约束收集器。这是使用constraintcollectors .conditional(…)约束收集器实现的。

这个收集器接受一个谓词,如果谓词为真,它将委托给另一个收集器。以下示例返回分配给给定计算机的长时间运行进程的计数,不包括非长时间运行的进程:

这在使用多个收集器并且只需要限制其中一些收集器的情况下非常有用。如果它们都需要以相同的方式进行限制,那么在分组之前应用filter()是可取的。

6.4.5.3. 作曲收藏家

约束收集器框架使您能够利用简单的收集器创建复杂的收集器。这是使用ConstraintCollectors.compose(…)约束收集器实现的。

这个收集器接受2到4个其他约束收集器,以及一个将它们的结果合并为一个的函数。下面的示例使用count约束收集器和sum()约束收集器构建average()约束收集器:

类似地,compose()收集器使您能够绕过约束流基数的限制,并在groupBy()语句中使用多达4个收集器:

   

这样的复合收集器返回一个Triple实例,它允许您单独访问每个子收集器。

OptaPlanner不提供任何Pair、Triple或Quadruple的开箱即用实现。

6.4.6. 有条件的传播

条件传播使您能够根据其他对象的存在与否从约束流中排除约束匹配。

下面的例子惩罚至少有一个进程正在运行的计算机:

注意这里使用了ifExists()构建块。在UniConstraintStream上,ifExistsOther()构建块也是可用的,这在forEach()约束匹配类型与ifExists()类型相同的情况下很有用。

相反,如果使用ifNotExists()构建块(以及UniConstraintStream上的ifNotExistsOther()构建块),您可以实现相反的效果:

在这里,只有没有运行进程的计算机才会受到惩罚。

还要注意使用Joiner类来限制约束匹配。有关可用连接程序的说明,请参见连接。条件传播的操作与连接非常相似,除了不增加流基数。这些构建模块的匹配在下游是不可用的。

出于性能考虑,在适当的Joiner实例上使用条件传播比连接更可取。

虽然使用join()创建要连接的事实的笛卡尔积,但通过条件传播,

结果流中最多只有原始数量的约束匹配。连接应该只在另一个事实实际上

需要另一个操作的情况下使用。

6.4.7. 映射元组

映射使您能够通过对约束流中的每个元组应用映射函数来转换它。这种映射的结果是映射元组的UniConstraintStream。

在上面的示例中,如果两个不同的CloudProcesses共享一个CloudComputer,

那么映射函数将生成重复的元组。也就是说,这样的CloudComputer在结果

约束流中出现两次。有关如何处理重复元组,请参阅distinct()。

6.4.7.1. 设计映射功能

在设计映射函数时,请遵循以下准则以获得最佳性能:

  • 保持函数的纯粹性。映射函数应该只依赖于它的输入。也就是说,给定相同的输入,它总是返回相同的输出。
  • 保持函数的目标。两个输入元组不应该映射到相同的输出元组,或者映射到相等的元组。不遵循此建议将创建具有重复元组的约束流,并可能迫使您稍后使用distinct()。
  • 使用不可变的数据载体。映射函数返回的元组应该是不可变的,并由其内容标识,而不是其他。如果两个元组携带彼此相等的对象,这两个元组应该同样相等,最好是相同的实例。

6.4.7.2. 使用处理重复元组不同的()

作为一般规则,约束流中的元组是不同的。也就是说,没有两个元组彼此相等。然而,某些操作(如元组映射)可能会产生重复的约束流。

如果约束流产生重复的元组,您可以使用distinct()构建块来消除重复的副本。

distinct()有性能代价。为了获得最佳性能,

不要使用产生重复元组的约束流操作,以避免调用distinct()。

6.4.8. 压扁

扁平化使您能够将任何Java Iterable(例如List或Set)转换为一组元组,并将其发送到下游。(类似于Java Stream的flatMap(…)。)这是通过对源元组中的最后一个元素应用映射函数来实现的。

在上面的例子中,如果Job.getRequiredRoles()包含重复的值,

映射函数会生成重复的元组。假设函数返回[USER, USER, ADMIN],

元组(SomePerson, USER)向下发送两次。有关如何处理重复元组,

请参阅distinct()。

6.5. 测试约束流

约束流包括约束验证器单元测试工具。要使用它,首先向optaplanner-test JAR添加一个测试作用域依赖。

6.5.1. 孤立地测试约束

考虑以下约束流:

下面的例子使用约束验证器API为前面的约束流创建一个简单的单元测试:

这个测试确保水平冲突约束在同一行有两个皇后时分配1的惩罚。下面这行代码创建了一个共享的ConstraintVerifier实例,并用NQueensConstraintProvider初始化该实例:

@Test注释表明该方法是您选择的测试框架中的单元测试。约束验证器与许多测试框架一起工作,包括JUnit和AssertJ。

测试的第一部分准备测试数据。在这种情况下,测试数据包括Queen规划实体的两个实例及其依赖关系(行,列):

再往下,下面的代码测试约束:

verifyThat(…)调用用于指定正在测试的NQueensConstraintProvider类上的方法。该方法必须对测试类可见,这是Java编译器强制执行的。

给定given (…)调用用于枚举约束流操作的所有事实。在本例中,给定的given (…)调用接受先前创建的queen1和queen2实例。或者,您可以在这里使用givenSolution(…)方法,并提供一个计划解决方案。

最后,penalizesBy(…)调用完成测试,确保水平冲突约束(给定一个皇后)产生1个惩罚。这个数字是匹配权重(在约束流中定义)乘以匹配数量的乘积。

或者,您可以使用rewardsWith(…)调用来检查奖励而不是惩罚。这里使用的方法取决于所讨论的约束流是否以惩罚或奖励构建块终止。

ConstraintVerifier不会触发变量侦听器。它既不会设置也不会更新影子变量。

如果测试的约束依赖于影子变量,那么您有责任事先分配正确的值。

6.5.2. 一起测试所有约束

除了测试单个约束之外,您还可以测试整个ConstraintProvider实例。考虑下面的测试:

与前面的示例相比,只有两个值得注意的区别。首先,verifyThat()调用在这里没有参数,这表明整个ConstraintProvider实例正在被测试。其次,使用了scores(…)方法,而不是penalizesBy()或rewardsWith()调用。这将在给定事实上运行ConstraintProvider,并返回由给定事实产生的所有约束匹配的Scores之和。

使用此方法,您可以确保约束提供程序不会遗漏任何约束,并且随着代码库的发展,评分函数保持一致。因此,给定(…)方法有必要列出所有规划实体和问题事实,或者提供整个规划解决方案。

ConstraintVerifier不会触发变量侦听器。它既不会设置也不会更新影子变量。

如果测试的约束依赖于影子变量,那么您有责任事先分配正确的值。

6.5.3. 夸克测试

如果您正在使用optaplanner-quarkus扩展,请在测试中注入ConstraintVerifier:

6.5.4. 弹簧启动测试

如果您正在使用optaplanner-spring-boot-starter模块,请在测试中自动连接ConstraintVerifier:

6.6. 不同的实现类型

约束流有两种形式:

  • CS Drools(默认):在底层使用Drools的快速实现。
  • Bavet:更快,更近期的内部实现。要尝试一下,在你的求解器配置solver config中将constraintStreamImplType设置为BAVET:

这两种变体都实现了相同的ConstraintProvider API。在两者之间切换不需要更改Java代码。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值