1 前言

网上能找到的python调用cplex示例实在太少,全英文的官方文档又十分难啃,还是从例子学起比较好,转了一圈找到github,发现IBM写了几个例子,但是又没有中文博客解释。借助网页和help,代码看半天终于懂了个大概,搬运一下,同时记录菜鸟笔记,抛砖引玉,欢迎大家批评指正。

2 IBM官方说明

IBM在下述网页中提供了DOcplex说明手册、示例、云端求解等内容,可以大致看一下按需获取。

IBMDecisionOptimization/docplex-examples
https://github.com/IBMDecisionOptimization/docplex-examples

在examples文件夹中,IBM给出了cp和mp的求解例子,我们主要关注mp问题。mp/modeling/中有6个例子,代码点进一看都很长,就先从第一个diet问题开始学习吧。

3 diet问题

题目来源:https://github.com/IBMDecisionOptimization/docplex-examples/blob/master/examples/mp/modeling/diet.py

3.1 背景及建模

数据来源:https://neos-guide.org/content/diet-problem-solver

该问题以最小化费用为目标,选择满足日常营养需求的一组食物。

目标函数:min总费用

每种食品费用 = 数量 × 单价,n种食品累加可得总费用

约束:满足各营养物质的取值范围要求

每种营养物质取值 = sum(数量 × 某食品中每单位该营养物质含量),n种食品累加

如:Calories,取值范围[2000, 2250]; Cholesterol,取值范围[0, 300]

3.2 代码详解

(1)导入库

from collections import namedtuple
from docplex.mp.model import Model
from docplex.util.environment import get_environment

分别用于命名元组、导入docplex和配置环境

(2)导入数据并初始化

FOODS = [
    ("Roasted Chicken", 0.84, 0, 10),
    ("Spaghetti W/ Sauce", 0.78, 0, 10),
    ("Tomato,Red,Ripe,Raw", 0.27, 0, 10),
    ("Apple,Raw,W/Skin", .24, 0, 10),
    ("Grapes", 0.32, 0, 10),
    ("Chocolate Chip Cookies", 0.03, 0, 10),
    ("Lowfat Milk", 0.23, 0, 10),
    ("Raisin Brn", 0.34, 0, 10),
    ("Hotdog", 0.31, 0, 10)
]

NUTRIENTS = [
    ("Calories", 2000, 2500),
    ("Calcium", 800, 1600),
    ("Iron", 10, 30),
    ("Vit_A", 5000, 50000),
    ("Dietary_Fiber", 25, 100),
    ("Carbohydrates", 0, 300),
    ("Protein", 50, 100)
]

FOOD_NUTRIENTS = [
    ("Roasted Chicken", 277.4, 21.9, 1.8, 77.4, 0, 0, 42.2),
    ("Spaghetti W/ Sauce", 358.2, 80.2, 2.3, 3055.2, 11.6, 58.3, 8.2),
    ("Tomato,Red,Ripe,Raw", 25.8, 6.2, 0.6, 766.3, 1.4, 5.7, 1),
    ("Apple,Raw,W/Skin", 81.4, 9.7, 0.2, 73.1, 3.7, 21, 0.3),
    ("Grapes", 15.1, 3.4, 0.1, 24, 0.2, 4.1, 0.2),
    ("Chocolate Chip Cookies", 78.1, 6.2, 0.4, 101.8, 0, 9.3, 0.9),
    ("Lowfat Milk", 121.2, 296.7, 0.1, 500.2, 0, 11.7, 8.1),
    ("Raisin Brn", 115.1, 12.9, 16.8, 1250.2, 4, 27.9, 4),
    ("Hotdog", 242.1, 23.5, 2.3, 0, 0, 18, 10.4)
]

FOODS用于存储食品名称、单价、数量范围信息;NUTRIENTS用于存储各营养物质名称和取值范围;FOOD_NUTRIENTS用于存储食品内各营养物质含量。

Food = namedtuple("Food", ["name", "unit_cost", "qmin", "qmax"])
Nutrient = namedtuple("Nutrient", ["name", "qmin", "qmax"])

使用namedtuple命名元组,使元组中的数据不仅能通过索引,也能通过自命名访问,增强可读性。

(3)模型建立函数

def build_diet_model(name='diet', **kwargs):
    ints = kwargs.pop('ints', False)

首先定义build_diet_model函数用于建立模型。**kwargs用于不知道要往函数中传入多少个关键词参数,或者想传入字典的值作为关键词参数时。ints用于判断决策变量类型(选择食品时的单位是整型还是连续型)。

①数据存储形式处理

    foods = [Food(*f) for f in FOODS]
    nutrients = [Nutrient(*row) for row in NUTRIENTS]

*可传入任意个数的参数。这两条语句可使命名后的元组与原来的数据对应,同时可以print查看foods和nutrients的具体存储信息。

foods = [Food(name=‘Roasted Chicken’, unit_cost=0.84, qmin=0, qmax=10), Food(name=‘Spaghetti W/ Sauce’, unit_cost=0.78, qmin=0, qmax=10), Food(name=‘Tomato,Red,Ripe,Raw’, unit_cost=0.27, qmin=0, qmax=10), Food(name=‘Apple,Raw,W/Skin’, unit_cost=0.24, qmin=0, qmax=10), Food(name=‘Grapes’, unit_cost=0.32, qmin=0, qmax=10), Food(name=‘Chocolate Chip Cookies’, unit_cost=0.03, qmin=0, qmax=10), Food(name=‘Lowfat Milk’, unit_cost=0.23, qmin=0, qmax=10), Food(name=‘Raisin Brn’, unit_cost=0.34, qmin=0, qmax=10), Food(name=‘Hotdog’, unit_cost=0.31, qmin=0, qmax=10)]

nutrients = [Nutrient(name=‘Calories’, qmin=2000, qmax=2500), Nutrient(name=‘Calcium’, qmin=800, qmax=1600), Nutrient(name=‘Iron’, qmin=10, qmax=30), Nutrient(name=‘Vit_A’, qmin=5000, qmax=50000), Nutrient(name=‘Dietary_Fiber’, qmin=25, qmax=100), Nutrient(name=‘Carbohydrates’, qmin=0, qmax=300), Nutrient(name=‘Protein’, qmin=50, qmax=100)]

    food_nutrients = {(fn[0], nutrients[n].name):
                          fn[1 + n] for fn in FOOD_NUTRIENTS for n in range(len(NUTRIENTS))}

fn[0]输出FOOD_NUTRIENTS第1列,即食品名称;nutrients[n].name输出营养物质名称;fn[1 + n],n in range(0,7)输出FOOD_NUTRIENTS的第2~8列,即营养物质含量。

为了将原来的FOOD_NUTRIENTS字典变成food_nutrients中的多键值形式,即{(食品i,营养物质j):值}的形式,需要对每一种食品循环7次(7种营养物质)。这样我们就可以根据食品名称+营养物质名称,快速查找到各食物对应的不同营养含量。

最后得到:food_nutrients = {(‘Roasted Chicken’, ‘Calories’): 277.4, (‘Roasted Chicken’, ‘Calcium’): 21.9, (‘Roasted Chicken’, ‘Iron’): 1.8, (‘Roasted Chicken’, ‘Vit_A’): 77.4, (‘Roasted Chicken’, ‘Dietary_Fiber’): 0, (‘Roasted Chicken’, ‘Carbohydrates’): 0, (‘Roasted Chicken’, ‘Protein’): 42.2, (‘Spaghetti W/ Sauce’, ‘Calories’): 358.2, (‘Spaghetti W/ Sauce’, ‘Calcium’): 80.2, (‘Spaghetti W/ Sauce’, ‘Iron’): 2.3, (‘Spaghetti W/ Sauce’, ‘Vit_A’): 3055.2, (‘Spaghetti W/ Sauce’, ‘Dietary_Fiber’): 11.6, (‘Spaghetti W/ Sauce’, ‘Carbohydrates’): 58.3, (‘Spaghetti W/ Sauce’, ‘Protein’): 8.2, (‘Tomato,Red,Ripe,Raw’, ‘Calories’): 25.8, (‘Tomato,Red,Ripe,Raw’, ‘Calcium’): 6.2, (‘Tomato,Red,Ripe,Raw’, ‘Iron’): 0.6, (‘Tomato,Red,Ripe,Raw’, ‘Vit_A’): 766.3, (‘Tomato,Red,Ripe,Raw’, ‘Dietary_Fiber’): 1.4, (‘Tomato,Red,Ripe,Raw’, ‘Carbohydrates’): 5.7, (‘Tomato,Red,Ripe,Raw’, ‘Protein’): 1, (‘Apple,Raw,W/Skin’, ‘Calories’): 81.4, (‘Apple,Raw,W/Skin’, ‘Calcium’): 9.7, (‘Apple,Raw,W/Skin’, ‘Iron’): 0.2, (‘Apple,Raw,W/Skin’, ‘Vit_A’): 73.1, (‘Apple,Raw,W/Skin’, ‘Dietary_Fiber’): 3.7, (‘Apple,Raw,W/Skin’, ‘Carbohydrates’): 21, (‘Apple,Raw,W/Skin’, ‘Protein’): 0.3, (‘Grapes’, ‘Calories’): 15.1, (‘Grapes’, ‘Calcium’): 3.4, (‘Grapes’, ‘Iron’): 0.1, (‘Grapes’, ‘Vit_A’): 24, (‘Grapes’, ‘Dietary_Fiber’): 0.2, (‘Grapes’, ‘Carbohydrates’): 4.1, (‘Grapes’, ‘Protein’): 0.2, (‘Chocolate Chip Cookies’, ‘Calories’): 78.1, (‘Chocolate Chip Cookies’, ‘Calcium’): 6.2, (‘Chocolate Chip Cookies’, ‘Iron’): 0.4, (‘Chocolate Chip Cookies’, ‘Vit_A’): 101.8, (‘Chocolate Chip Cookies’, ‘Dietary_Fiber’): 0, (‘Chocolate Chip Cookies’, ‘Carbohydrates’): 9.3, (‘Chocolate Chip Cookies’, ‘Protein’): 0.9, (‘Lowfat Milk’, ‘Calories’): 121.2, (‘Lowfat Milk’, ‘Calcium’): 296.7, (‘Lowfat Milk’, ‘Iron’): 0.1, (‘Lowfat Milk’, ‘Vit_A’): 500.2, (‘Lowfat Milk’, ‘Dietary_Fiber’): 0, (‘Lowfat Milk’, ‘Carbohydrates’): 11.7, (‘Lowfat Milk’, ‘Protein’): 8.1, (‘Raisin Brn’, ‘Calories’): 115.1, (‘Raisin Brn’, ‘Calcium’): 12.9, (‘Raisin Brn’, ‘Iron’): 16.8, (‘Raisin Brn’, ‘Vit_A’): 1250.2, (‘Raisin Brn’, ‘Dietary_Fiber’): 4, (‘Raisin Brn’, ‘Carbohydrates’): 27.9, (‘Raisin Brn’, ‘Protein’): 4, (‘Hotdog’, ‘Calories’): 242.1, (‘Hotdog’, ‘Calcium’): 23.5, (‘Hotdog’, ‘Iron’): 2.3, (‘Hotdog’, ‘Vit_A’): 0, (‘Hotdog’, ‘Dietary_Fiber’): 0, (‘Hotdog’, ‘Carbohydrates’): 18, (‘Hotdog’, ‘Protein’): 10.4}

②创建模型

    mdl = Model(name=name, **kwargs)

③设置决策变量(食物数量)

    ftype = mdl.integer_vartype if ints else mdl.continuous_vartype
    qty = mdl.var_dict(foods, ftype, lb=lambda f: f.qmin, ub=lambda f: f.qmax, name=lambda f: "q_%s" % f.name)

ftype表示决策变量类型,当调用函数是注明ints=True是整型,False则为连续型。qty表示每种食品的选择数量,下限lb取foods中的f.qmin(至少选多少);上限ub取f.qmax(最多选多少)。变量命名为“q_食物名”,如q_Roasted Chicken。

④添加约束条件(营养物质取值范围)

    for n in nutrients:
        amount = mdl.sum(qty[f] * food_nutrients[f.name, n.name] for f in foods)
        mdl.add_range(n.qmin, amount, n.qmax)
        mdl.add_kpi(amount, publish_name="Total %s" % n.name)

对于每种营养物质,amount为各营养物质总值,qty[f]为食物数量,food_nutrients[f.name, n.name]调用字典得到各食物的不同营养取值,相乘后再累加。再通过mdl.add_range将营养物质总值限制在之前所给范围,用mdl.add_kpi在结果中将各营养物质总含量作为一项关键指标输出"Total 营养物质名”,如Total Calories。

⑤表示目标函数

    total_cost = mdl.sum(qty[f] * f.unit_cost for f in foods)
    mdl.add_kpi(total_cost, 'Total cost')

各食品数量×单价 = 选择每种食品的花费,累加后得到总花费,同时将总花费Total cost也作为一项kpi输出。

⑥继续添加kpi——所选食物种类数

    def nb_products(mdl_, s_):
        qvs = mdl_.find_matching_vars(pattern="q_")
        return sum(1 for qv in qvs if s_[qv] >= 1e-5)

    mdl.add_kpi(nb_products, 'Nb foods')
    

建立了另一个函数nb_products用于计算选择的食物种类数。使用find_matching_vars查找包含字符串“q_”的变量,当“q_”有>0的数值时食物种类数sum += 1。

⑦目标函数最小化并返回mdl

    mdl.minimize(total_cost)

    return mdl

(4)求解模型并输出结果

if __name__ == '__main__':
    mdl = build_diet_model(ints=True, log_output=True, float_precision=6)
    mdl.print_information()

    s = mdl.solve()
    if s:
        qty_vars = mdl.find_matching_vars(pattern="q_")
        for fv in qty_vars:
            food_name = fv.name[2:]
            print("Buy {0:<25} = {1:9.6g}".format(food_name, fv.solution_value))

        mdl.report_kpis()
        # Save the CPLEX solution as "solution.json" program output
        with get_environment().get_output_stream("solution.json") as fp:
            mdl.solution.export(fp, "json")
    else:
        print("* model has no solution")

调用build_diet_model函数,决策变量为整型,输出日志文件,浮点数显示精度6位数;print_information()打印模型相关信息;mdl.solve() 用于求解模型;mdl.report_kpis()显示关键指标数据;最后将解决方案另存为“ solution.json”程序输出。

if语句内进行格式化输出处理。fv = q_Roasted Chicken,…,截取后面字段得到食物名,输出模型结果“Buy 食物名 = 数量”,如“Buy Spaghetti W/ Sauce = 2”。

其中,qty_vars = [docplex.mp.Var(type=I,name=‘q_Roasted Chicken’,ub=10), docplex.mp.Var(type=I,name=‘q_Spaghetti W/ Sauce’,ub=10), docplex.mp.Var(type=I,name=‘q_Tomato,Red,Ripe,Raw’,ub=10), docplex.mp.Var(type=I,name=‘q_Apple,Raw,W/Skin’,ub=10), docplex.mp.Var(type=I,name=‘q_Grapes’,ub=10), docplex.mp.Var(type=I,name=‘q_Chocolate Chip Cookies’,ub=10), docplex.mp.Var(type=I,name=‘q_Lowfat Milk’,ub=10), docplex.mp.Var(type=I,name=‘q_Raisin Brn’,ub=10), docplex.mp.Var(type=I,name=‘q_Hotdog’,ub=10)]

3.3 结果分析

最后程序输出如下:

先显示变量个数、约束个数等模型信息
在这里插入图片描述
再显示求解具体过程

在这里插入图片描述
在这里插入图片描述
分割线下的就是最后的模型求解结果了
在这里插入图片描述
买2单位Spaghetti W/ Sauce,1单位Apple,Raw,W/Skin,10单位Chocolate Chip Cookies,2单位Lowfat Milk和1单位Hotdog可以在满足营养物质要求的基础上实现费用最小化(Total cost = 2.87)。

此外还可以看到其他kpi信息,即各营养物质的总含量和食物种类数。

3.4 完整代码

# --------------------------------------------------------------------------
# Source file provided under Apache License, Version 2.0, January 2004,
# http://www.apache.org/licenses/
# (c) Copyright IBM Corp. 2015, 2018
# --------------------------------------------------------------------------

# The goal of the diet problem is to select a set of foods that satisfies
# a set of daily nutritional requirements at minimal cost.
# Source of data: http://www.neos-guide.org/content/diet-problem-solver

from collections import namedtuple

from docplex.mp.model import Model
from docplex.util.environment import get_environment

# ----------------------------------------------------------------------------
# Initialize the problem data
# ----------------------------------------------------------------------------

FOODS = [
    ("Roasted Chicken", 0.84, 0, 10),
    ("Spaghetti W/ Sauce", 0.78, 0, 10),
    ("Tomato,Red,Ripe,Raw", 0.27, 0, 10),
    ("Apple,Raw,W/Skin", .24, 0, 10),
    ("Grapes", 0.32, 0, 10),
    ("Chocolate Chip Cookies", 0.03, 0, 10),
    ("Lowfat Milk", 0.23, 0, 10),
    ("Raisin Brn", 0.34, 0, 10),
    ("Hotdog", 0.31, 0, 10)
]

NUTRIENTS = [
    ("Calories", 2000, 2500),
    ("Calcium", 800, 1600),
    ("Iron", 10, 30),
    ("Vit_A", 5000, 50000),
    ("Dietary_Fiber", 25, 100),
    ("Carbohydrates", 0, 300),
    ("Protein", 50, 100)
]

FOOD_NUTRIENTS = [
    ("Roasted Chicken", 277.4, 21.9, 1.8, 77.4, 0, 0, 42.2),
    ("Spaghetti W/ Sauce", 358.2, 80.2, 2.3, 3055.2, 11.6, 58.3, 8.2),
    ("Tomato,Red,Ripe,Raw", 25.8, 6.2, 0.6, 766.3, 1.4, 5.7, 1),
    ("Apple,Raw,W/Skin", 81.4, 9.7, 0.2, 73.1, 3.7, 21, 0.3),
    ("Grapes", 15.1, 3.4, 0.1, 24, 0.2, 4.1, 0.2),
    ("Chocolate Chip Cookies", 78.1, 6.2, 0.4, 101.8, 0, 9.3, 0.9),
    ("Lowfat Milk", 121.2, 296.7, 0.1, 500.2, 0, 11.7, 8.1),
    ("Raisin Brn", 115.1, 12.9, 16.8, 1250.2, 4, 27.9, 4),
    ("Hotdog", 242.1, 23.5, 2.3, 0, 0, 18, 10.4)
]

Food = namedtuple("Food", ["name", "unit_cost", "qmin", "qmax"])
Nutrient = namedtuple("Nutrient", ["name", "qmin", "qmax"])


# ----------------------------------------------------------------------------
# Build the model
# ----------------------------------------------------------------------------

def build_diet_model(name='diet', **kwargs):
    ints = kwargs.pop('ints', False)

    # Create tuples with named fields for foods and nutrients
    foods = [Food(*f) for f in FOODS]
    nutrients = [Nutrient(*row) for row in NUTRIENTS]

    food_nutrients = {(fn[0], nutrients[n].name):
                          fn[1 + n] for fn in FOOD_NUTRIENTS for n in range(len(NUTRIENTS))}

    # Model
    mdl = Model(name=name, **kwargs)

    # Decision variables, limited to be >= Food.qmin and <= Food.qmax
    ftype = mdl.integer_vartype if ints else mdl.continuous_vartype
    qty = mdl.var_dict(foods, ftype, lb=lambda f: f.qmin, ub=lambda f: f.qmax, name=lambda f: "q_%s" % f.name)

    # Limit range of nutrients, and mark them as KPIs
    for n in nutrients:
        amount = mdl.sum(qty[f] * food_nutrients[f.name, n.name] for f in foods)
        mdl.add_range(n.qmin, amount, n.qmax)
        mdl.add_kpi(amount, publish_name="Total %s" % n.name)

    # Minimize cost
    total_cost = mdl.sum(qty[f] * f.unit_cost for f in foods)
    mdl.add_kpi(total_cost, 'Total cost')

    # add a functional KPI , taking a model and a solution as argument
    # this KPI counts the number of foods used.
    def nb_products(mdl_, s_):
        qvs = mdl_.find_matching_vars(pattern="q_")
        return sum(1 for qv in qvs if s_[qv] >= 1e-5)

    mdl.add_kpi(nb_products, 'Nb foods')
    mdl.minimize(total_cost)

    return mdl


# ----------------------------------------------------------------------------
# Solve the model and display the result
# ----------------------------------------------------------------------------

if __name__ == '__main__':
    mdl = build_diet_model(ints=True, log_output=True, float_precision=6)
    mdl.print_information()

    s = mdl.solve()
    if s:
        qty_vars = mdl.find_matching_vars(pattern="q_")
        for fv in qty_vars:
            food_name = fv.name[2:]
            print("Buy {0:<25} = {1:9.6g}".format(food_name, fv.solution_value))

        mdl.report_kpis()
        # Save the CPLEX solution as "solution.json" program output
        with get_environment().get_output_stream("solution.json") as fp:
            mdl.solution.export(fp, "json")
    else:
        print("* model has no solution")

4 总结

从以上内容中可以看出,数据存储形式处理、模型表示是docplex求解的重难点。对于简单的规划问题,docplex求解并不困难,但当问题或数据稍显复杂时,程序中设计的环节变多,需要我们更加仔细、耐心地分析模型和每一个因素。

(由于初学者水平有限,文章不可避免地会出现一些错误,还望大佬们多多指点交流。)

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐