【整数规划算法】列生成(理论分析+Python代码实现)

运筹优化博士,只做原创博文。更多关于运筹学,优化理论,数据科学领域的内容,欢迎关注我的知乎账号:https://www.zhihu.com/people/wen-yu-zhi-37

0 介绍

前面介绍的割平面法和分支定界法都是求解整数规划的常用方法,但是面对大规模整数规划/混合整数规划,往往直接采用割平面法和分支定界法求解是不现实的,这时候就需要对大规模整数规划/混合整数规划问题先进行分解和松弛,然后再进一步采用割平面法和分支定界法来帮助求解。目前我个人总结整数规划问题的分解/松弛的主流的方法有如下三种:
1 Benders decomposition (主要思想是行生成+割平面方法)
2 Dantzig-Wolfe decomposition (主要思想其实就是列生成)
3 Lagrangian decomposition (主要思想是 Lagrangian relaxation)
我们今天主要介绍的是 列生成 (Column Generation) 方法,前两种方法我们会在后续笔记中进行更新。
列生成 (Column Generation) 实际上就是Dantzig-Wolfe decomposition 里边最重要的一个环节,因此列生成经常是和DW分解联合在一起使用。本文分为三部分,第一部分是从cutting stock问题出发介绍列生成算法的基本思想,第二部分是在python环境下基于Gurobi对列生成算法进行实现,第三部分是从对偶的角度来分析列生成算法的好坏。

1 从 cutting stock 问题出发介绍列生成算法

1.1 直接建模

cutting stock 中文可翻译为一维下料问题。假设钢材厂有若干根长度为 W W W的钢卷,假设现在有 M M M 个客户需要 n i n_i ni个长度为 w i w_i wi的零件。现在问怎么样切割钢卷能够在满足所有客户的基础上,让所使用的钢卷数量最少?

举个例子来说就是钢材长生产的都是统一的标准的钢卷长度为 W = 20 W=20 W=20,现在有3个客户需要10个长度为6的零件,20个长度为8的零件,12个长度为12的零件。对以上描述直接采用数学优化模型建模可得:

y k = { 1 ,  第 k 个钢卷被切  0 ,  第 k 个钢卷不被切 y_k=\left\{ \begin{array}{l} 1,\ \text{第}k\text{个钢卷被切 }\\ 0,\ \text{第}k\text{个钢卷不被切}\\ \end{array} \right. yk={1, k个钢卷被切 0, k个钢卷不被切

x i , k = { 1 ,  第 i 个零件在第 k 个钢卷切割  0 ,  否则 x_{i,k}=\left\{ \begin{array}{l} 1,\ \text{第}i\text{个零件在第}k\text{个钢卷切割 }\\ 0,\ \text{否则}\\ \end{array} \right. xi,k={1, i个零件在第k个钢卷切割 0, 否则

目标函数(极小化钢卷被使用的数量): min ⁡ ∑ k ∈ K y k          ( 1.1 ) \min \sum_{k\in K}{y_k} \;\;\;\; (1.1) minkKyk(1.1)

约束1(满足所有客户的需求): ∑ k ∈ K x i , k ≥ n i ,    i = 1 , . . . , M          ( 1.2 ) \sum_{k\in K}{x_{i,k}}\ge n_i,\ \ i=1,...,M\;\;\;\; (1.2) kKxi,kni,  i=1,...,M(1.2)

约束2 (切割不能超过钢卷长度): ∑ i = 1 M w i x i , k ≤ W y k ,      k ∈ K          ( 1.3 ) \sum_{i=1}^M{w_ix_{i,k}}\le Wy_k,\,\,\,\,k\in K\;\;\;\;\left( 1.3 \right) i=1Mwixi,kWyk,kK(1.3)

K K K 为可用的钢卷集合。

以上模型就是非常直观的一个建模,该模型也被成为 Kantorovich(苏联应用数学家康托洛维奇) 模型。该模型从求解的角度来说是一个非常不好的模型。原因是它的线性松弛的界很差,如果我们把决策变量 x i , k , y k x_{i,k},y_k xi,k,yk都松弛为连续变量,则模型(1.1-1.3)会变成一个线性规划。

易知该线性规划(1.1-1.3)的最优解 x ∗ , y ∗ x^*,y^* x,y必然会在约束(1.2)和约束(1.3)等式成立的地方。即最优解满足:

∑ k ∈ K x i , k ∗ = n i ,    i = 1 , . . . , M          ( 1.4 ) ∑ i = 1 M w i x i , k = W y i ∗ ,    k ∈ K          ( 1.5 ) \sum_{k\in K}{x^*_{i,k}}= n_i,\ \ i=1,...,M\;\;\;\; (1.4) \\ \sum_{i=1}^M{w_ix_{i,k}}= Wy^{*}_i,\ \ k\in K\;\;\;\; (1.5) kKxi,k=ni,  i=1,...,M(1.4)i=1Mwixi,k=Wyi,  kK(1.5)

最优的目标函数为 ∑ k ∈ K y k ∗ = ∑ k ∈ K ∑ i = 1 M w i x i , k ∗ W = ∑ i = 1 M w i n i W          ( 1.6 ) \sum_{k\in K}{y^{*}_k} = \frac{\sum_{k\in K}{\sum_{i=1}^M{w_ix^{*}_{i,k}}}}{W} = \frac{\sum_{i=1}^M{w_in_i}}{W} \;\;\;\; (1.6) kKyk=WkKi=1Mwixi,k=Wi=1Mwini(1.6)

也就是说原模型(1.1-1.3)的线性松弛问题的最优解是一个naive的解,由这样得到的线性松弛问题模型的界是非常送的,那么直接调用求解器去求解原模型(1.1-1.3)的效率就会非常低。

1.2 基于列生成的建模

上面我们谈到直接建模得到的(1.1-1.3)整数规划模型不是一个好的模型。那么接下来我们尝试从另外一个角度对 cutting stock 进行建模。首先我们需要定义切割模式,还是从之前的例子来看,一根长度为20的钢卷切成若干长度6,8,12的零件有哪些切法?例如 切1个长度为6的,切1个长度为8的这是一种切法,切2个长度为6的,切1个长度为8的这也是一种切法,切1个长度为6的切一个长度为12 的这也是一种切法。我们把一种切法就叫做一种 切割模式,当然不止我这里列的三种切割模式。那么我们只需要决策出每种切割模型需要多少次就可以得到整个切割方案了。

主问题(Master Problem)
我们将上述采用切割模型的方式用数学模型描述出来就可以得到

决策变量: x j =  第 j 个 切 割 模 式 被使用的次数 x_j=\ \text{第}j\text{个}切割模式\text{被使用的次数} xj= j被使用的次数
目标函数: min ⁡ ∑ j = 1 N x j          ( 1.7 ) \min \sum_{j=1}^N{x_j} \;\;\;\; (1.7) minj=1Nxj(1.7)
约束: ∑ j = 1 n a i j x j ≥ n i ,    i = 1 , . . . , m          ( 1.8 ) x j ∈ Z + ,    j = 1 , . . . , n          ( 1.9 ) \sum_{j=1}^n{a_{ij}x_j}\ge n_i,\ \ i=1,...,m\;\;\;\; (1.8) \\ x_j\in Z_+,\ \ j=1,...,n \;\;\;\; (1.9) j=1naijxjni,  i=1,...,m(1.8)xjZ+,  j=1,...,n(1.9)
其中 a i j a_{ij} aij是参数 表示 在切割模式 j j j中 切割零件 i i i的数量。

在约束(1.8)中 每一列就表示一种切割模式。我们发现切割模式一般是指数级别的(对应到模型中就是 n 的数值),也就是说想要把所有切割模式都列举出来是不现实的。因此我们就会自然想到是不是能只加入一部分切割模式,先得到一个可行解(虽然这个可行解可能不太好),接着再加入一些列来逐渐改进切割模式。从这个想法出发,我们在原始的主问题的基础上只列出一部分切割模式,得到受限的主问题:

受限的主问题(Restricted Master Problem)
min ⁡ ∑ j ∈ P x j          ( 1.10 ) s . t .      ∑ j ∈ P a i j x j ≥ n i ,    i = 1 , . . . , m          ( 1.11 ) x j ∈ R + ,    j ∈ P          ( 1.12 ) \min \sum_{j\in P}{x_j} \;\;\;\; (1.10) \\ s.t. \;\; \sum_{j\in P}{a_{ij}x_j}\ge n_i,\ \ i=1,...,m\;\;\;\; (1.11) \\ x_j\in R_+,\ \ j \in P \;\;\;\; (1.12) minjPxj(1.10)s.t.jPaijxjni,  i=1,...,m(1.11)xjR+,  jP(1.12)
其中集合 P ⊆ { 1 , 2... , n } P\subseteq \{1,2...,n\} P{1,2...,n} 为切割模式的索引集合。

上述受限的主问题的对偶问题为:
max ⁡ ∑ i = 1 n n i π i          ( 1.13 ) s . t .    ∑ i = 1 m a i j π i ≤ 1 ,   j ∈ P          ( 1.14 ) x j ∈ R + ,    j ∈ P          ( 1.15 ) \max \sum_{i=1}^n{n_i\pi _i} \;\;\;\; (1.13) \\ s.t.\ \ \sum_{i=1}^m{a_{ij}\pi _i}\le 1,\ j\in P\;\;\;\; (1.14) \\ x_j\in R_+,\ \ j \in P \;\;\;\; (1.15) maxi=1nniπi(1.13)s.t.  i=1maijπi1, jP(1.14)xjR+,  jP(1.15)

子问题(Subproblem)
我们希望在受限的主问题的对偶问题上添加一列来改进受限的主问题的最优解。从线性规划的reduced cost的公式可以知道,添加第 j ∈ { 1 , . . , n } j\in \{1,..,n\} j{1,..,n}
1 − ∑ i = 1 m a i j π i          ( 1.16 ) 1-\sum_{i=1}^m{a_{ij}\pi _i}\;\;\;\; (1.16) 1i=1maijπi(1.16)

一种直观的添加列的方式就是选取能够让受限的主问题目标函数最小的列:
min ⁡ { 1 − ∑ i = 1 m a i j π i ∣ j ∈ { 1 , . . , n } }          ( 1.17 ) \min\{1-\sum_{i=1}^m{a_{ij}\pi _i} | j \in \{1,..,n\} \}\;\;\;\; (1.17) min{1i=1maijπij{1,..,n}}(1.17)

进一步将上式等价改写为如下子问题:
max ⁡ ∑ i = 1 m π i y i          ( 1.18 ) s . t .    ∑ i = 1 m w i y i ≤ W          ( 1.19 ) y i ∈ Z + ,      i = 1 , . . , m          ( 1.20 ) \max \sum_{i=1}^m{\pi _iy_i}\;\;\;\; (1.18) \\ s.t.\ \ \sum_{i=1}^m{w_iy_i}\le W \;\;\;\; (1.19) \\ y_i\in Z_{+}, \;\; i=1,..,m\;\;\;\; (1.20) maxi=1mπiyi(1.18)s.t.  i=1mwiyiW(1.19)yiZ+,i=1,..,m(1.20)

其中 y = ( y 1 , . . . , y m ) y=\left( y_1,...,y_m \right) y=(y1,...,ym)表示第 j j j ( a 1 j , . . . , a m j ) T \left( a_{1j},...,a_{mj} \right) ^T (a1j,...,amj)T ,约束(1.19)表示切割模式要满足钢卷长度的约束。可以知道该子问题(1.18-1.20)是一个背包问题。

2 列生成 cutting stock 算法流程与代码实现

列生成算法的基本流程如下所示:
【整数规划算法】列生成(理论分析+Python代码实现)_第1张图片
从上图中可以看出,一开始是初始化一个最简单的切割模式(pattern)。之后就是一个循环迭代过程,先求解受限的主问题获得对偶变量,然后求解背包问题获得可以新添加的列,最后把这个列加入到受限的主问题。如此往复循环直到收敛为止。
整个列生成算法的代码分为三部分:1受限的主问题;2子问题;3主干代码。本代码借助Gurobi来构建优化模型和求解优化模型,所以如果想读懂代码需要先掌握Gurobi的使用方法。

受限的主问题代码:

class Master:
    def __init__(self, lengths, demands, W) -> None:
        self.M, self.lengths, self.demands, self.W = len(lengths), lengths, demands, W
        self.n_col, self.n_dim =  0, 0
    
    def create_model(self):
        self.x = []
        self.model = gp.Model("Master")
        self.__set_vars()
        self.__set_contrs()
    
    def solve(self, flag = 0):
        self.model.Params.OutputFlag = flag
        self.model.optimize()
    
    def get_dual_vars(self):
        return [self.constrs[i].getAttr(GRB.Attr.Pi) for i in range(len(self.constrs))]
    
    def __set_contrs(self) -> None:
        self.constrs = self.model.addConstrs((self.x[i]*(self.W // self.lengths[i]) >= self.demands[i]) for i in range(self.M))
    
    def __set_vars(self) -> None:
        for i in range(self.M):
            self.x.append(self.model.addVar(obj = 1, lb = 0, ub = GRB.INFINITY, vtype = GRB.CONTINUOUS, name = 'x'+ str(i)))
        self.n_col = 1
        self.n_dim = self.M
    
    def update_contrs(self, column_coeff):
        self.column = gp.Column(column_coeff, self.model.getConstrs())
        self.model.addVar(vtype = GRB.CONTINUOUS, lb = 0, obj = 1, name = 'x' + str(self.n_dim), column = self.column)
        self.n_dim += 1
        self.n_col += 1

子问题代码

class SubProblem:
    def __init__(self, lengths, W) -> None:
        self.lengths, self.M, self.W = lengths, len(lengths), W
    
    def create_model(self):
        self.model = gp.Model("sub model")
        self.y = self.model.addVars(self.M, lb = 0, ub = GRB.INFINITY, vtype = GRB.INTEGER, name = 'y')
        self.model.addConstr((gp.quicksum(self.lengths[i]*self.y[i] for i in range(self.M)) <= self.W))
    
    def set_objective(self, pi):
        self.model.setObjective(gp.quicksum(pi[i]*self.y[i] for i in range(self.M)), sense = GRB.MAXIMIZE)
    
    def solve(self, flag = 0):
        self.model.Params.OutputFlag = flag
        self.model.optimize()
    
    def get_solution(self):
        return [self.model.getVars()[i].x for i in range(self.M)]

    def get_reduced_cost(self):
        return self.model.ObjVal

主干代码:

W = 20 # width of large roll
lengths = [3, 7, 9, 16] 
demands = [25, 30, 14, 8]
M = len(lengths) # number of items
MAX_ITER_TIMES = 10 # 最大迭代次数

cutting_stock = Master(lengths, demands, W) #初始化主问题
cutting_stock.create_model()    #建立主问题模型

sub_prob = SubProblem(lengths, W) #初始化子问题
sub_prob.create_model()  #建立子问题模型

for k in range(MAX_ITER_TIMES): 
    cutting_stock.solve()  # 求解主问题
    pi = cutting_stock.get_dual_vars() #得到主问题的对偶变量 
    cutting_stock.write()
 
    sub_prob.set_objective(pi)  # 重新给子问题目标函数赋值
    sub_prob.solve() # 求解子问题
    y = sub_prob.get_solution() #获得子问题的最优解
    reduced_cost = sub_prob.get_reduced_cost() #获得子问题的 reduced cost
    sub_prob.write()
    cutting_stock.update_contrs(column_coeff=y) #更新主问题的约束(添加新的列进去)
    if reduced_cost <= 1:  # 判定收敛条件
        break

cutting_stock.to_int() # 将主问题的模型改回整数规划问题求解
cutting_stock.solve(flag=1)

想下载完整版代码可见如下链接:
EasyIntegerProgramming/ColumnGeneration at master · WenYuZhi/EasyIntegerProgramming

3 从对偶角度分析列生成算法的好坏

如果说你仅仅是想使用列生成算法的话,到前面第二部分结束就已经足够了。那我们作为一个优化理论的研究者,仅仅停留在建模+算法实现的层次还是不够的。我们需要回答一个问题就是采用列生成算法得到的解到底质量如何?是不是能足够接近最优解呢?

考虑最初的 cutting stock 的模型(1.1-1.3),我们将其整理如下所示:

min ⁡ ∑ k ∈ K y k          ( 3.1 ) ∑ k ∈ K x i , k ≥ n i ,    i = 1 , . . . , M          ( 3.2 ) ∑ i = 1 M w i x i , k ≤ W y k ,      k ∈ K          ( 3.3 ) x i , k ∈ Z + ,      y k ∈ { 0 , 1 }          ( 3.4 ) \min \sum_{k\in K}{y_k} \;\;\;\; (3.1) \\ \sum_{k\in K}{x_{i,k}}\ge n_i,\ \ i=1,...,M\;\;\;\; (3.2) \\ \sum_{i=1}^M{w_ix_{i,k}}\le Wy_k,\,\,\,\,k\in K\;\;\;\;\left( 3.3 \right) \\ x_{i,k}\in Z_{+},\;\; y_k \in \{0,1\}\;\;\;\;\left( 3.4 \right) minkKyk(3.1)kKxi,kni,  i=1,...,M(3.2)i=1Mwixi,kWyk,kK(3.3)xi,kZ+,yk{0,1}(3.4)

通过观察以上模型可知,如果我们采用拉格朗日松弛将约束(3.2)松弛掉,那么该问题就会被分解为 K K K个背包问题。可得松弛问题为:
L ( u ) = ∑ k ∈ K y k + ∑ i = 1 m u i ( n i − ∑ k ∈ K x i , k )          ( 3.5 ) s . t .      ∑ i = 1 M w i x i , k ≤ W y k ,      k ∈ K          ( 3.6 ) x i , k ∈ Z + ,      y k ∈ { 0 , 1 }          ( 3.7 ) L\left( u \right) =\sum_{k\in K}{y_k}+\sum_{i=1}^m{u_i}\left( n_i-\sum_{k\in K}{x_{i,k}} \right) \;\;\;\;\left( 3.5 \right) \\ s.t. \;\; \sum_{i=1}^M{w_ix_{i,k}}\le Wy_k,\,\,\,\,k\in K\;\;\;\;\left( 3.6\right) \\x_{i,k}\in Z_{+},\;\; y_k \in \{0,1\}\;\;\;\;\left( 3.7 \right) L(u)=kKyk+i=1mui(nikKxi,k)(3.5)s.t.i=1Mwixi,kWyk,kK(3.6)xi,kZ+,yk{0,1}(3.7)
其中 u i ≥ 0 ,      i = 1 , . . . , m u_i\geq 0,\;\; i=1,...,m ui0,i=1,...,m是拉格朗日乘子。

我们进一步可以将上述问题分解为 K K K 个背包问题,如下所示:
L ( u ) = ∑ k ∈ K L k ( u ) + ∑ i = 1 m n i u i          ( 3.8 ) L\left( u \right) =\sum_{k\in K}{L_k\left( u \right)}+\sum_{i=1}^m{n_iu_i} \;\;\;\;\left( 3.8 \right) L(u)=kKLk(u)+i=1mniui(3.8)
其中
L k ( u ) = min ⁡ y k − ∑ i = 1 m u i x i , k          ( 3.9 ) s . t .      ∑ i = 1 M w i x i , k ≤ W y k ,          ( 3.10 ) x i , k ∈ Z + ,      y k ∈ { 0 , 1 }          ( 3.11 ) L_k\left( u \right) =\min y_k-\sum_{i=1}^m{u_ix_{i,k}} \;\;\;\;\left( 3.9 \right) \\ s.t. \;\; \sum_{i=1}^M{w_ix_{i,k}}\le Wy_k,\;\;\;\;\left( 3.10\right) \\ x_{i,k}\in Z_{+},\;\; y_k \in \{0,1\}\;\;\;\;\left( 3.11 \right) Lk(u)=minyki=1muixi,k(3.9)s.t.i=1Mwixi,kWyk,(3.10)xi,kZ+,yk{0,1}(3.11)
进一步将 L k ( u ) L_k(u) Lk(u)等价改写为:若 y k = 0 y_k=0 yk=0 L k ( u ) = 0 L_k(u)=0 Lk(u)=0,若 y k = 1 y_k=1 yk=1 L k ( u ) = z ∗ L_k(u)=z^* Lk(u)=z。因此 L k ( u ) = min ⁡ ( 0 , 1 − z ∗ ) L_k(u)=\min(0,1-z^*) Lk(u)=min(0,1z),其中
z ∗ = max ⁡ ∑ i = 1 m u i x i , k          ( 3.12 ) s . t .      ∑ i = 1 M w i x i , k ≤ W ,          ( 3.13 ) x i , k ∈ Z + ,      ( 3.14 ) z^*=\max \sum_{i=1}^m{u_ix_{i,k}} \;\;\;\;\left( 3.12 \right) \\ s.t. \;\; \sum_{i=1}^M{w_ix_{i,k}}\le W,\;\;\;\;\left( 3.13\right) \\ x_{i,k}\in Z_{+},\;\;\left( 3.14 \right) z=maxi=1muixi,k(3.12)s.t.i=1Mwixi,kW,(3.13)xi,kZ+,(3.14)

上述优化问题(3.12-3.14)是一个背包问题,并且这个背包问题和 k k k无关。因此进一步可以将式(3.8)改写为如下式:
L ( u ) = K L k ( u ) + ∑ i = 1 m n i u i          ( 3.15 ) L\left( u \right) =K{L_k\left( u \right)}+\sum_{i=1}^m{n_iu_i} \;\;\;\;\left( 3.15 \right) L(u)=KLk(u)+i=1mniui(3.15)

至此,我们给出定理3.1:对偶问题 max ⁡ u ≥ 0 L ( u ) \underset{u\ge 0}{\max}L\left( u \right) u0maxL(u)=主问题(1.7-1.9)最优解。

参考文献:

【1】孙小玲,李端,整数规划,科学出版社,2010
【2】Laurence A. Wolsey, Integer Programming, Wily, 2021
【3】运筹OR帷幄:优化 | 从下料问题看整数规划中的列生成方法(附Gurobi求解器源代码)
【4】Column Generation求解Cutting Stock Problem

你可能感兴趣的:(算法,python,机器学习)