本部分可以借鉴Gurobi的手册来讲述。
subtour-elimination
首先,我们还是以VRP
上古大神solomon
[^1]的benchmark
为算例,来进行今天代码的数值实验部分。
算例下载地址:https://www.sintef.no/projectweb/top/vrptw/solomon-benchmark/100-customers/
在这之前,我想先说一下Gurobi
和CPLEX
里面的callback
是怎么个逻辑:
callback
的工作逻辑: 王者荣耀版独家解读下面我夹杂王者荣耀的角度来轻松解释callback是怎么起作用的,打农药的小伙伴应该秒懂
。(有些地方不严谨,但是大概是这么个意思,这段本来就是辅助理解,不严谨的地方可以私信我,我再修改)
1.之前说过,
subtour-elimination
的想法,是想把所有的子集列举出来,为每一个子集添加破圈
约束,但是这么做太慢。
2.于是我们想,先不加subtour-elimination
的约束,我们先把只含有前两组
约束的IP
输入给Gurobi
求解,Gurobi
当然会先把IP
的整数约束松弛掉,把模型变成LP
,然后调用branch and bound
算法,并将IP
松弛后的relaxed LP
作为根节点
,进行branch and bound tree
的迭代。
下面高能解释来了 我们把这个算法的迭代比作一场王者双排排位赛
。假设我们准备开始玩游戏,我方打野
选了野王Gurobi
,OK,我见势立马一手奶妈蔡文姬
,死跟打野,只干两件事1. 探视野
(识别subtour)和2. 给助攻
(根据subtour构建破圈约束)。Gurobi
大佬构建模型,并且加入了前两组约束。(铭文带的不够呀,我有点慌,大佬却说,躺好看我carry, 帮我看蓝探视野),而我们也不示弱, 选了subtour-elimination
的辅助装出装策略。(也就是subtour-elimination
的callback
函数,用于添加通过callback
的方式添加subtour-elimination
约束的)
3.游戏开始,Gurobi
打着前两组约束,并且设置model.Params.lazyConstraints = 1
也就是给我(软辅蔡文姬
)发出跟着我
的信号。然后拉着我一起双排开始了游戏,代码中就是model.optimize(subtourelim)
。
OK,算法开始迭代。野王Gurobi
还是基本操作
,熟练的branch and bound
在野区以最适合的刷野路径刷野。在刷野(迭代
)的过程中,在每个branch and bound tree
的结点处,Gurobi
会去调用各种变式的simplex
算法得到该节点的解。如果得到了一个整数解(也可以是得到小数解就操作),我们可以在这个地方,人为的插一脚,相当于Gurobi
老哥正在屁颠屁颠刷野
(跑算法)呢,我们在旁边探视野
(做监工),一直就这么直勾的看着。我们看到老哥得到了一个整数解(或者一个小数可行解),一机灵,激动地过去拍拍Gurobi
老哥的肩膀说,大佬,我拿一下这个节点的LP解哈,您继续
。
4.OK, 拿到了LP
的解以后,我们自己来看看这个解中存不存在subtour
,如果我们检测完后发现不存在。尴尬,我们假装啥也没发生。继续静静地看着Gurobi
老哥刷野
(算法迭代)。下一次,Gurobi
老哥又得到了一个整数解(或者一个小数可行解),我们再厚脸皮去拿过来检测,结果发现有2-5-8-2
这个子环路混子
混在里面,这次可被我逮个正着哈,小伙儿,你子环路了幺
。我们激动地大声告诉Gurobi
老哥:“老哥,老哥,子环路2-5-8-2
在这儿挑衅你,你去GUNK
它,把2-5-8-2
这个混子送回家”,顺便我们还根据这个子环路2-5-8-2
的特点,快速给Gurobi
老哥想出了一套连招:老哥,你1433223就可以秒他!!!
哈哈,在实际中,把这个子环路踢出去的方法
(也就是刚刚的连招)就是加下面这个约束: x 25 + x 58 + x 82 ⩽ 2 x_{25}+x_{58}+x_{82} \leqslant 2 x25+x58+x82⩽2。这里还有个坑
,虽然你也可以写成等价的 x 25 + x 58 + x 82 < 3 x_{25}+x_{58}+x_{82} < 3 x25+x58+x82<3,但是求解器是不接受 < < <, > > >这样的约束的,你要硬加,那就报错。很多人其实并不知道这一点,我在这里提一下。
5.接上面,Gurobi
老哥非常强,手脚麻利动作快,脑子很好使能同时处理多个信息
,听到了我们奶妈蔡文姬
的报视野
–前面草丛有个2-5-8-2
的ADC很浪,落单了,还1433223
连招可以秒他,回头敬个礼说:好嘞,知道了,放心吧,瞧好了您呐,看我把他怼出去哈。
看这老哥如此稳的操作,我心中的默默点赞:同九义,何汝秀
。然后继续监工。之后我就再也没见过2-5-8-2
这个子环路混子。之后的监工中,我这奶妈蔡文姬
又陆续把1-5-8-1
,3-4-7-9-3
等一众混子报给Gurobi
老哥,老哥一一将他们1433223
送回家。
6.由于我方打野
位Gurobi
老哥刚开始只加入了前两组约束,轻车简从,走路带风,身为奶妈蔡文姬
的我紧跟打野,时刻监视打野行为,并不断为打野探视野,找敌方落单ADC
,并每次都及时给个助攻subtour-elimination constraints
( x 25 + x 58 + x 82 ⩽ 2 x_{25}+x_{58}+x_{82} \leqslant 2 x25+x58+x82⩽2),抢个人头,最终Gurobi
老哥轻松carry
全场,拿到2-5-8-2
,3-4-7-9-3
,1-5-8-1
等3个人头 。直击敌方水晶,获得最优解[0, 4, 3, 7, 1, 2, 5, 8, 9, 6, 0]
。评分127
,夺得胜方MVP
。起立,鼓掌!!!是的,每次都是这样。
上面的描述并不完美,但是我想,应该能给你一些辅助理解callback
工作逻辑的帮助。
callback
的通用步骤其实总结一下,使用callback
的方法分为下面几步(只针对本问题)
Gurobi
构建数学模型,只加入前两组约束;subtour
并返回消除子环路约束的函数subtourelim(model, where)
(注意,这个函数的参数model
, where
是固定的,求解器规定的)。这个函数用于:拿到整数规划分支定界迭代过程中当前结点的解的信息,并根据当前节点的解,识别子环路,如有则返回消除子环路的约束,否则不作操作。lazyConstraints
,并启动优化算法求解模型,也就是model.optimize(subtourelim)
,而且必须以callback
函数subtourelim(model, where)
为参数,具体代码为:model._vars = X
model.Params.lazyConstraints = 1
model.optimize(subtourelim)
【Remark】这里,
subtourelim(model, where)
中,添加子环路消除约束是以lazyConstraints
的形式添加的,lazyConstraints
就是不在建模一开始就加入,而是在算法迭代的过程中,动态的在branch and bound
的分支节点处才加入的约束。可以通过callback函数
,控制在节点的解
满足什么样的条件下,我们去构建特定形式的约束
,这个约束以lazyConstraints
的形式构建并添加到求解的函数model.optimize()
中,然后Gurobi
就可以自动的识别,且调用callback
函数,按照你的要求在求解过程中把约束加进去。这一招branch and cut
,benders decomposition
,row generation
的时候用的非常多。想要进阶的小伙伴这招儿还是必须要攻克的。
callback
实现subtour-elimination
的详细代码这部分代码有点长,待我明天再放出来把。算了,还是直接放上来把。
首先定义一些读取数据的函数:
readData(path, nodeNum)
:读取.txt文档中的算例数据;reportMIP(model, Routes)
:获得并打印最优解信息;getValue(var_dict, nodeNum)
:获得决策变量的值,并存储返回一个np.array()
数组;getRoute(x_value)
:根据解x_value
得到该解对应的路径。# _*_coding:utf-8 _*_
'''
@author: Hsinglu Liu
@version: 1.0
@Date: 2019.5.5
'''
from __future__ import print_function
from __future__ import division, print_function
from gurobipy import *
import re;
import math;
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import copy
from matplotlib.lines import lineStyles
import time
starttime = time.time()
# function to read data from .txt files
def readData(path, nodeNum):
nodeNum = nodeNum;
cor_X = []
cor_Y = []
f = open(path, 'r');
lines = f.readlines();
count = 0;
# read the info
for line in lines:
count = count + 1;
if(count >= 10 and count <= 10 + nodeNum):
line = line[:-1]
str = re.split(r" +", line)
cor_X.append(float(str[2]))
cor_Y.append(float(str[3]))
# compute the distance matrix
disMatrix = [([0] * nodeNum) for p in range(nodeNum)]; # 初始化距离矩阵的维度,防止浅拷贝
# data.disMatrix = [[0] * nodeNum] * nodeNum]; 这个是浅拷贝,容易重复
for i in range(0, nodeNum):
for j in range(0, nodeNum):
temp = (cor_X[i] - cor_X[j])**2 + (cor_Y[i] - cor_Y[j])**2;
disMatrix[i][j] = (int)(math.sqrt(temp));
# disMatrix[i][j] = 0.1 * (int)(10 * math.sqrt(temp));
# if(i == j):
# data.disMatrix[i][j] = 0;
# print("%6.0f" % (math.sqrt(temp)), end = " ");
temp = 0;
return disMatrix;
def printData(disMatrix):
print("-------cost matrix-------\n");
for i in range(len(disMatrix)):
for j in range(len(disMatrix)):
#print("%d %d" % (i, j));
print("%6.1f" % (disMatrix[i][j]), end = " ");
# print(disMatrix[i][j], end = " ");
print();
def reportMIP(model, Routes):
if model.status == GRB.OPTIMAL:
print("Best MIP Solution: ", model.objVal, "\n")
var = model.getVars()
for i in range(model.numVars):
if(var[i].x > 0):
print(var[i].varName, " = ", var[i].x)
print("Optimal route:", Routes[i])
def getValue(var_dict, nodeNum):
x_value = np.zeros([nodeNum + 1, nodeNum + 1])
for key in var_dict.keys():
a = key[0]
b = key[1]
x_value[a][b] = var_dict[key].x
return x_value
def getRoute(x_value):
# 假如是5个点的算例,我们的路径会是1-4-2-3-5-6这样的,因为我们加入了一个虚拟点
# 也就是当路径长度为6的时候,我们就停止,这个长度和x_value的长度相同
x = copy.deepcopy(x_value)
# route_temp.append(0)
previousPoint = 0
arcs = []
route_temp = [previousPoint]
count = 0
while(len(route_temp) < len(x) and count < len(x)):
print('previousPoint: ', previousPoint, 'count: ', count)
if(x[previousPoint][count] > 0):
previousPoint = count
route_temp.append(previousPoint)
count = 0
continue
else:
count += 1
return route_temp
# cost = [[0, 7, 2, 1, 5],
# [7, 0, 3, 6, 8],
# [2, 3, 0, 4, 2],
# [1, 6, 4, 0, 9],
# [5, 8, 2, 9, 0]]
然后定义几个非常关键的用于添加subtour-elimination
约束的函数:
subtourelim(model, where)
: callback函数,用于为model
对象动态添加subtour-elimination
约束;computeDegree(graph)
: 给定一个graph(二维数组形式),也就是给定一个邻接矩阵
,计算出每个结点的degree
.(degree=每个结点被进入次数+被离开的次数);findEdges(graph)
: 给定一个graph(二维数组形式),也就是给定一个邻接矩阵
,找到该图中所有的边
,例如[(1, 2), (2, 4), (2, 5)];subtour(graph)
:给定一个graph(二维数组形式),也就是给定一个邻接矩阵
,找到该图中包含结点数目最少的子环路
,例如[2, 3, 5]。其中,函数subtourelim(model, where)
中,调用了函数computeDegree(graph)
、findEdges(graph)
和subtour(graph)
。
# Callback - use lazy constraints to eliminate sub-tours
# Callback - use lazy constraints to eliminate sub-tours
def subtourelim(model, where):
if(where == GRB.Callback.MIPSOL):
# make a list of edges selected in the solution
print('model._vars', model._vars)
# vals = model.cbGetSolution(model._vars)
x_value = np.zeros([nodeNum + 1, nodeNum + 1])
for m in model.getVars():
if(m.varName.startswith('x')):
# print(var[i].varName)
# print(var[i].varName.split('_'))
a = (int)(m.varName.split('_')[1])
b = (int)(m.varName.split('_')[2])
x_value[a][b] = model.cbGetSolution(m)
print("solution = ", x_value)
# print('key = ', model._vars.keys())
# selected = []
# for i in range(nodeNum):
# for j in range(nodeNum):
# if(i != j and x_value[i][j] > 0.5):
# selected.append((i, j))
# selected = tuplelist(selected)
# # selected = tuplelist((i,j) for i in range(nodeNum), for if x_value[i][j] > 0.5)
# print('selected = ', selected)
# find the shortest cycle in the selected edge list
tour = subtour(x_value)
print('tour = ', tour)
if(len(tour) < nodeNum + 1):
# add subtour elimination constraint for every pair of cities in tour
print("---add sub tour elimination constraint--")
# model.cbLazy(quicksum(model._vars[i][j]
# for i in tour
# for j in tour
# if i != j)
# <= len(tour)-1)
# LinExpr = quicksum(model._vars[i][j]
# for i in tour
# for j in tour
# if i != j)
for i,j in itertools.combinations(tour, 2):
print(i,j)
model.cbLazy(quicksum(model._vars[i, j]
for i,j in itertools.combinations(tour, 2))
<= len(tour)-1)
LinExpr = quicksum(model._vars[i, j]
for i,j in itertools.combinations(tour, 2))
print('LinExpr = ', LinExpr)
print('RHS = ', len(tour)-1)
# compute the degree of each node in given graph
def computeDegree(graph):
degree = np.zeros(len(graph))
for i in range(len(graph)):
for j in range(len(graph)):
if(graph[i][j] > 0.5):
degree[i] = degree[i] + 1
degree[j] = degree[j] + 1
print('degree', degree)
return degree
# given a graph, get the edges of this graph
def findEdges(graph):
edges = []
for i in range(1, len(graph)):
for j in range(1, len(graph)):
if(graph[i][j] > 0.5):
edges.append((i, j))
return edges
# Given a tuplelist of edges, find the shortest subtour
def subtour(graph):
# compute degree of each node
degree = computeDegree(graph)
unvisited = []
for i in range(1, len(degree)):
if(degree[i] >= 2):
unvisited.append(i)
cycle = range(0, nodeNum + 1) # initial length has 1 more city
edges = findEdges(graph)
edges = tuplelist(edges)
print(edges)
while unvisited: # true if list is non-empty
thiscycle = []
neighbors = unvisited
while neighbors: # true if neighbors is non-empty
current = neighbors[0]
thiscycle.append(current)
unvisited.remove(current)
neighbors = [j for i,j in edges.select(current,'*') if j in unvisited]
neighbors2 = [i for i,j in edges.select('*',current) if i in unvisited]
if(neighbors2):
neighbors.extend(neighbors2)
# print('current:', current, '\n neighbors', neighbors)
isLink = ((thiscycle[0], thiscycle[-1]) in edges) or ((thiscycle[-1], thiscycle[0]) in edges)
if(len(cycle) > len(thiscycle) and len(thiscycle) >= 3 and isLink):
# print('in = ', ((thiscycle[0], thiscycle[-1]) in edges) or ((thiscycle[-1], thiscycle[0]) in edges))
cycle = thiscycle
return cycle
return cycle
然后是建模部分的代码,建模部分相比学运筹的人比较熟悉,这里比较特殊的就是求解时候的几行代码:
model.Params.lazyConstraints = 1
: set lazy constraints Parametermodel.optimize(subtourelim)
: use callback function when executing branch and bound algorithm