使用 Commons Math、Hipparchus、OptaPlanner 和 Timefold 的 Groovy 解决简单的优化问题

作者:Paul King
发布日期:2024-03-14 10:22PM


介绍

许多问题涉及优化。我们将探讨一个可以使用线性优化(也称为线性规划)解决的案例研究。线性规划问题在满足一个或多个线性等式和不等式约束条件的情况下,优化特定的线性目标函数。

我们将研究这样一个问题,以及一些可用于解决这些问题的库和工具。

案例研究:膳食优化

让我们看一个案例研究,我们试图在保持一些我们可能为健康或饮食偏好原因设定的整体约束的同时,将饮食中食物的成本降至最低。这个例子是受这个 SAS 示例启发的,但在更多信息部分中,你可以看到一个更详细的线性规划示例,即经典的 Stigler 饮食问题,它使用 Google OR-Tools 解决。

首先,这里有六种食物,它们构成了我们的饮食,并列出了它们的成本和营养价值

面包 🍞

牛奶 🥛

奶酪 🧀

马铃薯 🥔

鱼 🐟

酸奶 🍶

成本

2.0

3.5

8.0

1.5

11.0

1.0

蛋白质 (g)

4.0

8.0

7.0

1.3

8.0

9.2

脂肪 (g)

1.0

5.0

9.0

0.1

7.0

1.0

碳水化合物 (g)

15.0

11.7

0.4

22.6

0.0

17.0

卡路里

90

120

106

97

130

180

我们希望在保持最佳营养的同时将成本降至最低,最佳营养定义为满足以下标准

  • 必须至少 300 卡路里

  • 脂肪不超过 10 克

  • 碳水化合物不低于 10 克

  • 脂肪不低于 8 克

  • 鱼类至少 0.5 单位

  • 牛奶不超过 1 单位

请注意,我们不建议将这组简化的约束作为真正的饮食,它只是为了我们的案例研究的说明目的。

将此与我们之前对线性规划的定义联系起来,上面的列表代表我们的线性约束。我们的目标函数是成本,它由每种食物的数量乘以其成本确定。

使用电子表格求解器解决

这种问题非常普遍,以至于即使在电子表格应用程序中也存在求解器。我们将展示使用OpenSolver 附加组件用于Google Sheets的解决方案。但如果你喜欢,也可以使用Microsoft Excel 做同样的事情。

首先,我们填写问题的数据。它将类似于下面显示的图,但最初,变量单元格(蓝色)和目标单元格(黄色)将为空。

Data

然后,使用 OpenSolver 扩展,我们通过单元格范围来标识数据(蓝色)和目标(黄色)单元格,以及约束。

Solver

然后我们点击“解决”,它将计算我们的优化值。

让我们看看如何使用 Groovy 以编程方式解决这个问题。Groovy 为对这类问题进行脚本编写提供了非常好的环境,但对于我们使用的库而言,应该可以使用大多数 JVM 语言。

使用 Apache Commons Math 或 Hipparchus

现在让我们看看如何使用单纯形求解器来解决这个问题。我们将使用 Apache Commons Math 中的 SimplexSolver 类,它与 Hipparchus 中的类基本相同(Commons Math 的分支)。

我们将从一个用于定义约束的小辅助方法开始

static scalar(coeffs, rel, double val) {
    new LinearConstraint(coeffs as double[], rel, val)
}

接下来,我们定义我们的个体约束和组合集

var milk_max   = scalar([0, 1, 0, 0, 0, 0], LEQ, 1)
var fish_min   = scalar([0, 0, 0, 0, 1, 0], GEQ, 0.5)
var protein    = scalar([4.0, 8.0, 7.0, 1.3, 8.0, 9.2],     LEQ, 10)
var fat        = scalar([1.0, 5.0, 9.0, 0.1, 7.0, 1.0],     GEQ, 8)
var carbs      = scalar([15.0, 11.7, 0.4, 22.6, 0.0, 17.0], GEQ, 10)
var calories   = scalar([90, 120, 106, 97, 130, 180],       GEQ, 300)

LinearConstraintSet constraints = [milk_max, fish_min, protein, fat, carbs, calories]

每个个体约束都对每种食物都有一个系数,一个关系和一个值。

接下来,我们定义我们的成本函数,以及一个额外的约束来表明我们不能购买负数量的任何食物。zeroOrMore 约束为我们节省了进行等效长手操作的时间,例如 fish_min,但每种食物的最小值为 0

var cost = new LinearObjectiveFunction([2.0, 3.5, 8.0, 1.5, 11.0, 1.0] as double[], 0)

var zeroOrMore = new NonNegativeConstraint(true)

现在,我们的解决方案是通过创建一个新的求解器,并要求它使用我们的成本函数和约束来优化。然后我们打印出我们的解决方案

var solution = new SimplexSolver().optimize(cost, constraints, zeroOrMore)

static pretty(int idx, double d) {
    d ? [sprintf('%s %.2f', ['🍞', '🥛', '🧀', '🥔', '🐟', '🍶'][idx], d)] : []
}

if (solution != null) {
    printf "Cost: %.2f%n", solution.value
    println solution.point.indexed().collectMany(this::pretty).join(', ')
}

运行时,它将输出以下内容

Cost: 12.08
🥛 0.05, 🧀 0.45, 🥔 1.87, 🐟 0.50

这与我们在使用电子表格时看到的解决方案相同。

你目前可以通过切换类路径上使用的 jar 的 Maven 坐标和更改一些导入语句来在 Apache Commons Math 和 Hipparchus 之间切换。这可能会在将来的版本中发生变化,但就目前而言

  • 使用 org.apache.commons:commons-math3:3.6.1 将提供一个较旧的稳定版本,Commons Math,它已经 8 年了,开始显露出它的年龄。

  • 使用 org.apache.commons:commons-math4-legacy:4.0-beta1 将提供来自 Apache Commons Math 的这些类的最新版本。命名可能需要一些解释。一直以来,人们都在努力将 Commons Math 模块化,因此出现了许多组件。优化类尚未开发出来,并且在上述工件中可用。

  • 使用 org.hipparchus:hipparchus-optim:3.0 将提供来自分支项目的类。对于我们使用的类而言,分支基本上没有区别,但如果你不介意依赖一个没有 ASF 支持的库,则库的其他部分可能会看到有用的更新。

如果你不喜欢这些选项,还有很多其他的选项,这里列举了几个,它们在与上面示例相同的存储库中包含 Groovy 解决方案

对于像我们的案例研究这样的简单优化问题,可以使用单纯形求解器来解决,你通常不需要再找其他方法。它是一种非常有效的解决此类问题的方法。对于一类稍微复杂的问题,它们可以借助一些巧妙的方法映射到线性规划问题。

对于更复杂的问题,通常不存在超级高效的解决方案方法。你需要利用各种技术来管理这类问题中固有的潜在庞大的搜索空间。这就是优化库如 OptaPlanner(和 Timefold)发挥作用的地方。

使用 OptaPlanner 或 Timefold

OptaPlanner 是一个优化库,它将优化算法与约束求解相结合。在过去 10 年的大部分时间里,该库是在 Red Hat 的指导下开发的。在过去 12 个月里,该项目和其他相关项目被捐赠给了ASF,作为Apache KIE 的一部分。最近,该库被分叉为Timefold

在本博客中,我们将使用 Timefold,但示例中的代码对于这两个库都是相同的,如引用的存储库中所示。只需更改库的 Maven 坐标以及相关的类导入即可。目前尚不清楚这两个项目将如何随着时间的推移而发展。

Timefold 项目的一个声明是,它具有更轻的依赖关系。这可以通过运行关联构建中的 printRuntimeClasspath 任务来确认。Timefold 有 20 个依赖 jar,而 OptaPlanner 有 55 个 jar。

虽然 Timefold 的能力对于我们的简单问题来说没有必要,但让我们检查一下如何将其用于相同的案例研究。

首先,我们将创建一个规划实体。这是一个类,求解器知道它会随着时间的推移而改变,并且将包含一个或多个规划变量。

在本例中,我们只有一个规划变量,即 amount 属性,求解器将在尝试找到最佳解决方案时调整它。

@PlanningEntity
@TupleConstructor(includeFields = true)
class Food {
    final String name, emoji
    final double cost, protein, fat, carbs, calories
    @PlanningVariable(valueRangeProviderRefs = "amount")
    Integer amount // times 100
}

我们使用 Integer 作为 amount 的类型,因为对于求解器来说,处理整数要容易得多。实际上,我们将存储(如先前示例中所示)乘以 100 的数量,但在显示结果时我们将除以 100。

我们类的其他字段在实例构造期间定义后是常量。

接下来,我们定义一个规划解决方案类。它包含有关任何给定解决方案的所有信息,包括一个 score。分数让我们可以确定一个解决方案是否比另一个解决方案更优,以及给定解决方案是否满足所有硬约束和软约束(稍后解释)。

@PlanningSolution
class DietSolution {
    @PlanningEntityCollectionProperty
    List<Food> foods

    @ValueRangeProvider(id = "amount")
    CountableValueRange<Integer> getAmount() {
        ValueRangeFactory.createIntValueRange(0, 200, 5)
    }

    @PlanningScore
    HardSoftScore score

    String toString() {
        var sb = new StringBuilder()
        foods.eachWithIndex { f, idx ->
            sb << "$f.emoji $f.name: ${f.amount / 100}\n"
        }
        for (name in ['fat', 'carbs', 'protein', 'calories', 'cost']) {
            var total = foods.sum{ f -> f."$name" * f.amount / 100 }
            sb << sprintf("Total %s: %.2f%n", name, total)
        }
        sb << "Score: $score"
        sb
    }
}

接下来,我们希望定义一些约束。一般来说,我们有必须满足的硬约束和如果可能应满足的软约束。在本例中,我们将有一些约束,例如各种食物和各种营养措施的最小值和最大值。

class DietConstraintProvider implements ConstraintProvider {
    @Override
    Constraint[] defineConstraints(ConstraintFactory factory) {
        new Constraint[]{
                maxField(factory, 'protein', 10),
                minField(factory, 'fat', 8),
                minField(factory, 'carbs', 10),
                minField(factory, 'calories', 300),
                minFood(factory, 'Fish', 50),
                maxFood(factory, 'Milk', 100),
                minCost(factory),
        }
    }

    private static int amountOf(Food f, String name) {
        (f."$name" * f.amount).toInteger()
    }

    private static Constraint minField(ConstraintFactory factory, String fieldName, double minAmount) {
        ToIntFunction<Food> amount = f -> amountOf(f, fieldName)
        factory.forEach(Food)
                .groupBy(sum(amount))
                .filter(fs -> fs < minAmount * 100)
                .penalize(ONE_HARD)
                .asConstraint("Min $fieldName")
    }

    private static Constraint maxField(ConstraintFactory factory, String fieldName, double maxAmount) {
        ToIntFunction<Food> amount = f -> amountOf(f, fieldName)
        factory.forEach(Food)
                .groupBy(sum(amount))
                .filter(fs -> fs > maxAmount * 100)
                .penalize(ONE_HARD)
                .asConstraint("Max $fieldName")
    }

    private static Constraint minFood(ConstraintFactory factory, String foodName, double minAmount) {
        factory.forEach(Food)
                .filter(f -> f.name == foodName && f.amount < minAmount)
                .penalize(ONE_HARD)
                .asConstraint("Min $foodName")
    }

    private static Constraint maxFood(ConstraintFactory factory, String foodName, double maxAmount) {
        factory.forEach(Food)
                .filter(f -> f.name == foodName && f.amount > maxAmount)
                .penalize(ONE_HARD)
                .asConstraint("Max $foodName")
    }

    private static ToIntFunction<Food> totalCost = f -> (f.cost * f.amount).toInteger()

    private static Constraint minCost(ConstraintFactory factory) {
        factory.forEach(Food)
                .filter(f -> f.amount > 0)
                .groupBy(sum(totalCost))
                .penalize(ONE_SOFT, fs -> fs >> 2)
                .asConstraint('Min cost')
    }
}

有了这些辅助类,我们现在就可以

def unsolved = new DietSolution(foods: [
        new Food('Bread' , '🍞',  2.0, 4.0, 1.0, 15.0,  90),
        new Food('Milk'  , '🥛',  3.5, 8.0, 5.0, 11.7, 120),
        new Food('Cheese', '🧀',  8.0, 7.0, 9.0,  0.4, 106),
        new Food('Potato', '🥔',  1.5, 1.3, 0.1, 22.6,  97),
        new Food('Fish'  , '🐟', 11.0, 8.0, 7.0,  0.0, 130),
        new Food('Yogurt', '🍶',  1.0, 9.2, 1.0, 17.0, 180)
])

def config = new SolverConfig()
        .withSolutionClass(DietSolution)
        .withEntityClasses(Food)
        .withConstraintProviderClass(DietConstraintProvider)
        .withTerminationSpentLimit(Duration.ofSeconds(10))

def factory = SolverFactory.create(config)
def solver = factory.buildSolver()
println solver.solve(unsolved)

运行时,它将输出以下内容

08:17:05.202 [main] INFO  a.t.s.core.impl.solver.DefaultSolver - Solving started: time spent (25), best score (-6init/0hard/0soft), environment mode (REPRODUCIBLE), move thread count (NONE), random (JDK with seed 0).
08:17:05.385 [main] INFO  a.t.s.c.i.c.DefaultConstructionHeuristicPhase - Construction Heuristic phase (0) ended: time spent (210), best score (-1hard/-521soft), score calculation speed (1355/sec), step total (6).
08:17:15.175 [main] INFO  a.t.s.c.i.l.DefaultLocalSearchPhase - Local Search phase (1) ended: time spent (10000), best score (-1hard/-261soft), score calculation speed (155967/sec), step total (1030).
08:17:15.176 [main] INFO  a.t.s.core.impl.solver.DefaultSolver - Solving ended: time spent (10000), best score (-1hard/-261soft), score calculation speed (152685/sec), phase total (2), environment mode (REPRODUCIBLE), move thread count (NONE).
🍞 Bread: 0.6
🥛 Milk: 0.6
🧀 Cheese: 0
🥔 Potato: 0.4
🐟 Fish: 0.5
🍶 Yogurt: 1.05
Total fat: 8.19
Total carbs: 42.91
Total protein: 21.38
Total calories: 418.80
Total cost: 10.45
Score: -1hard/-261soft

鉴于我们为求解器提供的时间,以及使用默认搜索算法,我们甚至无法满足所有硬约束。搜索空间太大,以至于我们从未到达搜索空间中所有约束都满足的区域。

好消息是,我们可以提供额外的指导,以便求解器在搜索过程中朝更好的方向前进。以下是一种可能的额外配置,以及更新后的 config 定义

def construction = new ConstructionHeuristicPhaseConfig(constructionHeuristicType: FIRST_FIT)
def moveSelector = new UnionMoveSelectorConfig([
        new ChangeMoveSelectorConfig(),
        new SwapMoveSelectorConfig()
])
def localSearch = new LocalSearchPhaseConfig(localSearchType: VARIABLE_NEIGHBORHOOD_DESCENT,
        moveSelectorConfig: moveSelector)
def config = new SolverConfig()
        .withSolutionClass(DietSolution)
        .withEntityClasses(Food)
        .withConstraintProviderClass(DietConstraintProvider)
        .withPhases(construction, localSearch) // additional solution guidance
        .withTerminationSpentLimit(Duration.ofSeconds(10))

运行时,它将输出以下内容

🍞 Bread: 0
🥛 Milk: 0
🧀 Cheese: 0.5
🥔 Potato: 1.9
🐟 Fish: 0.5
🍶 Yogurt: 0
Total fat: 8.19
Total carbs: 43.14
Total protein: 9.97
Total calories: 302.30
Total cost: 12.35
Score: 0hard/-308soft

我们可以看到,它现在接近线性规划所能提供的结果。

更多信息

结论

我们已经研究了使用 Groovy 和一些线性优化库来解决一个饮食案例研究。我们的主要关注点是 Apache Commons Math 和 Hipparchus 库。我们还探索了使用更强大的 Timeflow 和 OptaPlanner 库。