前言
在用基于DEAP设计的遗传算法求解函数极值后,我们想要进一步解决一些更加困难点的问题。TSP问题就是很好的实验算法的舞台。本文将会介绍以下内容:
- TSP问题的定义与运用遗传算法求解
- 对比遗传算法与贪心算法及其改进型在用时和精确度两方面的表现
- 探讨可能的改进方向
TSP问题的定义与GA求解
TSP问题简介
旅行商问题(Traveling Salesman Problem(TSP))描述:
给出个不同的城市以及城市两两之间的距离,求一个商人从某个城市出发,走遍所有城市并回到起点所需要经过的路径总距离为多少?
即求解一个路径(也即城市的排列),使得取最小值
如果对两个城市有,那该问题被称为对称旅行商问题(Symmetric Traveling Salesman Problem(STSP))。否则就是非对称旅行商问题(ATSP)。
尽管描述很简单,TSP问题是一个非常经典而重要的问题:
- 自从1930年代 Merrill M.Flood在学界引起了对该问题的重视以来,学者们已经从图论、组合优化、线性规划等不同角度对该问题进行了深入广泛的研究。
- TSP问题的一个特殊性在于它必然存在可以找到的最优解(通过穷举法一定能在时间内找到该最优解),但是在计算复杂度方面它是一个经典的NP-hard问题。因而快速有效寻找TSP的最优解仍然是个有吸引力的问题。
- 在实际应用方面,它可以被视为物流调度,交通规划,芯片布线优化等等问题的抽象简化。
TSP问题的遗传算法求解
通过之前对DEAP的解读,我们很容易就能基于该框架,写出一个求解TSP问题的GA算法。在TSPLib上测试该算法之前,我们先在随机生成的数据集上进行测试与改进:
## 环境设置
import matplotlib.pyplot as plt
import numpy as np
%matplotlib inline
from scipy.spatial import distance
from deap import creator, base, tools, algorithms
import random
params = {
'font.family': 'serif',
'figure.figsize': [4.0, 3.0],
'figure.dpi': 300,
'savefig.dpi': 300,
'font.size': 12,
'legend.fontsize': 'small'
}
plt.rcParams.update(params)
#--------------------------------
## 定义TSP中的基本元素
# 用[n,2]的np.array存储城市坐标;每行存储一个城市
def genCity(n, Lb = 100 ,Ub = 999):
# 生成城市坐标
# 输入:n -- 需要生成的城市数量
# 输出: nx2 np array 每行是一个城市的[X,Y]坐标
np.random.seed(42) # 保证结果的可复现性
return np.random.randint(low = Lb, high = Ub, size=(n,2))
# 计算并存储城市距离矩阵
def cityDistance(cities):
# 生成城市距离矩阵 distMat[A,B] = distMat[B,A]表示城市A,B之间距离
# 输入:cities -- [n,2] np array, 表示城市坐标
# 输出:nxn np array, 存储城市两两之间的距离
return distance.cdist(cities, cities, 'euclidean')
def completeRoute(individual):
# 序列编码时,缺少最后一段回到原点的线段
return individual + [individual[0]] # 不要用append
# 计算给定路线的长度
def routeDistance(route):
# 输入:
# route -- 一条路线,一个sequence
# 输出:routeDist -- scalar,路线的长度
if route[0] != route[-1]:
route = completeRoute(route)
routeDist = 0
for i,j in zip(route[0::],route[1::]):
routeDist += cityDist[i,j] # 这里直接从cityDist变量中取值了,其实并不是很安全的写法,单纯偷懒了
return (routeDist), # 注意DEAP要求评价函数返回一个元组
# 路径可视化
def plotTour(tour, cities, style = 'bo-'):
if len(tour)>1000: plt.figure(figsize = (15,10))
start = tour[0:1]
for i,j in zip(tour[0::], tour[1::]):
plt.plot([cities[i,0],cities[j,0]], [cities[i,1],cities[j,1]], style)
plt.plot(cities[start,0],cities[start,1],'rD')
plt.axis('scaled')
plt.axis('off')
#--------------------------------
## 设计GA算法
nCities = 30
cities = genCity(nCities) # 随机生成nCities个城市坐标
# 问题定义
creator.create('FitnessMin', base.Fitness, weights=(-1.0,)) # 最小化问题
creator.create('Individual', list, fitness=creator.FitnessMin)
# 定义个体编码
toolbox = base.Toolbox()
toolbox.register('indices', random.sample, range(nCities), nCities) # 创建序列
toolbox.register('individual', tools.initIterate, creator.Individual, toolbox.indices)
# 生成族群
N_POP = 100
toolbox.register('population', tools.initRepeat, list, toolbox.individual)
pop = toolbox.population(N_POP)
# 注册所需工具
cityDist = cityDistance(cities)
toolbox.register('evaluate', routeDistance)
toolbox.register('select', tools.selTournament, tournsize = 2)
toolbox.register('mate', tools.cxOrdered)
toolbox.register('mutate', tools.mutShuffleIndexes, indpb = 0.2)
# 数据记录
stats = tools.Statistics(key=lambda ind: ind.fitness.values)
stats.register('avg', np.mean)
stats.register('min', np.min)
# 调用内置的进化算法
resultPop, logbook = algorithms.eaSimple(pop, toolbox, cxpb = 0.5, mutpb = 0.2, ngen = 200, stats = stats, verbose = True)
本次运行的结果为:
gen nevals avg min
0 100 14201 11314.5
1 58 13675.4 11505.4
2 58 13410.6 11300.1
3 60 13174.9 11064.3
4 57 12962.4 10907.4
5 63 12796.9 10470.6
6 66 12585.8 10644.2
7 64 12245.2 10232
8 68 12067 9592.99
9 62 12059.6 9592.99
10 70 12206.6 9592.99
11 57 11908.6 9496.14
12 42 11468.3 9496.14
13 60 11393.1 9062.07
14 47 11109.7 9062.07
15 63 11123.8 9062.07
16 58 10923.4 8926.45
17 61 10995.1 8545.11
18 58 11174.7 9062.07
19 76 11091.9 9062.07
20 53 10993.1 8888.39
21 58 11016.3 8842.55
22 52 10914.2 8842.55
23 47 10745.8 8659.72
24 54 10526 8659.72
25 60 10870.4 8717.75
26 56 11078.5 8717.75
27 66 10904.5 8717.75
28 55 10796.9 8717.75
29 58 10840.5 8568.54
30 70 10783.7 8714.81
31 75 10945.5 8714.81
32 62 10906.3 8714.81
33 55 10883.5 8714.81
34 61 10768.4 8717.75
35 64 10861.7 9045.8
36 72 10839 9097.6
37 73 10819.7 8860.66
38 70 10528.5 8736.59
39 55 10250.1 8736.59
40 67 10563.3 8513.88
41 50 10602.1 8513.88
42 59 10518 8513.88
43 61 10596.9 8931.09
44 57 10780 9109.96
45 67 10757.6 8771.09
46 60 10504.8 8771.09
47 63 10421.2 8771.09
48 69 10526.5 8771.09
49 58 10442.3 8691.01
50 65 10535.4 8771.09
51 62 10683 8771.09
52 68 10532.6 8804.67
53 54 10488.4 8804.67
54 62 10583.1 8408.69
55 53 10514.8 8833.92
56 57 10524.8 9052.01
57 65 10599.3 8732.31
58 58 10227.1 8732.31
59 62 10358.2 8732.31
60 66 10532 8730.2
61 51 10348.5 8671.23
62 67 10542 8671.23
63 60 10584.6 8663.02
64 57 10417.8 8522.64
65 58 10552.6 8522.64
66 61 10413.8 8367.42
67 66 10422.5 8671.23
68 57 10559.6 8671.23
69 58 10263.6 8285.6
70 60 10463.1 8285.6
71 65 10499 8285.6
72 55 10330.4 8529.22
73 67 10350.1 8576.73
74 73 10271 7570.84
75 51 10252.9 8289.29
76 60 10343.2 8112.17
77 61 9767.09 7565.35
78 57 9751.91 7565.35
79 50 9684.03 7565.35
80 53 9808.93 7565.35
81 59 9509.4 7565.35
82 58 9758.84 7565.35
83 65 9852.58 6437.84
84 56 9866.73 6437.84
85 55 9572.01 6298.22
86 61 9217.73 6298.22
87 63 9311.42 6298.22
88 61 9057.95 6298.22
89 63 9103.11 6298.22
90 66 9116.68 6298.22
91 51 9024.19 6454.46
92 45 8683.05 6454.46
93 64 8584.42 6709.33
94 59 8942 6709.33
95 64 9082.05 6709.33
96 71 9037.46 6709.33
97 60 9154.49 6933.71
98 67 9384.12 6933.71
99 75 9682.95 7115.49
100 61 9300.16 7266.03
101 50 9035.16 7266.03
102 52 8740.91 6960.39
103 62 9063.54 7145.63
104 62 9220.89 7252.07
105 57 9210.75 6956.42
106 52 9228 6956.42
107 62 9095.18 6956.42
108 55 8764.35 6956.42
109 65 8961.6 6956.42
110 62 9029.89 6911.78
111 56 8757.81 6558.41
112 75 8436.12 6261.76
113 48 8489.56 6261.76
114 56 8379.92 6261.76
115 50 8567.57 6348.63
116 58 8505.19 6405.25
117 56 8485.4 5930.98
118 58 8689.05 5930.98
119 68 8504.73 6167.82
120 70 8123.5 6167.82
121 58 7992.72 6128.46
122 60 8441.54 6128.46
123 59 8270.66 6128.46
124 58 7944.21 5924.28
125 61 8259.92 5924.28
126 68 7954.45 5924.28
127 63 7913.86 6063.51
128 62 8507.67 6063.51
129 52 8079.8 6063.51
130 62 8180.93 5693.15
131 79 8299 6063.51
132 60 8070.93 6255.88
133 70 8149.17 6048.78
134 54 8125.64 6268.16
135 65 8161.05 6080.82
136 62 8093.43 6221.13
137 54 8046.15 6221.13
138 67 8064.43 5715.42
139 57 8108.32 5447.17
140 63 7910.74 5362.81
141 56 8040.57 5362.81
142 61 7936.12 5362.81
143 59 7951.14 5362.81
144 60 8222.17 5362.81
145 70 7815.39 5362.81
146 57 7660.78 5362.81
147 61 7545.06 5362.81
148 62 7619.84 5362.81
149 51 7387.11 5362.81
150 66 7396.11 5362.81
151 57 7224.2 5362.81
152 62 7062.38 5280.67
153 60 7450.55 5280.67
154 57 7058.69 5280.67
155 51 7452.15 5218.92
156 64 7332.41 5280.67
157 55 7261.36 5280.67
158 53 6949.59 5280.67
159 51 7034.86 5280.67
160 57 6985.01 5280.67
161 67 6554.21 5280.67
162 62 6671.5 5280.67
163 61 6938.98 5121.92
164 57 7247.23 5121.92
165 69 7018.4 5039.78
166 67 6764.25 5039.78
167 61 7172.14 5039.78
168 58 7172.76 5039.78
169 48 6760.36 5039.78
170 66 6591.99 5039.78
171 59 7039.59 5039.78
172 51 6917.34 5039.78
173 60 6631.73 5039.78
174 58 6683.84 4997.91
175 65 6559.69 4997.91
176 69 6908.49 4910.62
177 61 7016.93 4910.62
178 60 6772.34 4910.62
179 58 6492.51 4757.57
180 67 6389.65 4757.57
181 49 6018.27 4757.57
182 55 6364.89 4757.57
183 68 6781.2 4757.57
184 63 6500.91 4757.57
185 60 6451.73 4757.57
186 73 7277.07 4757.57
187 57 6232.07 4757.57
188 74 6360.29 4757.57
189 60 6608.44 4757.57
190 52 6222.14 4757.57
191 51 6482.11 4757.57
192 52 6118.93 4732.05
193 57 6170.32 4732.05
194 65 6303.12 4732.05
195 52 6060.32 4732.05
196 57 6133.72 4757.57
197 60 6120.28 4757.57
198 66 5602.95 4757.57
199 68 6006.72 4757.57
200 60 6057.53 4757.57
对结果进行可视化:
tour = tools.selBest(resultPop, k=1)[0]
tourDist = tour.fitness
tour = completeRoute(tour)
print('最优路径为:'+str(tour))
print('最优路径距离为:'+str(tourDist))
plotTour(tour, cities)
可视化结果:
最优路径为:[7, 15, 0, 14, 12, 6, 17, 25, 8, 27, 21, 20, 23, 24, 18, 13, 11, 1, 10, 29, 22, 9, 3, 28, 4, 5, 19, 26, 16, 2, 7]
最优路径距离为:(4757.574098243668,)
从肉眼可见的角度上,这个解距离最优解还是有差距的。
对训练过程可视化如下:
算法对比与分析
nnTSP + 2OPT
最近邻算法:nn TSP
穷举法能够保证求出TSP问题的最优解,但是其耗时会随着问题规模的扩大而剧增。假设求解一个规模为10的TSP问题需要1秒,那么可以估计对于更大规模问题,穷举法所需要的时间:
问题规模(城市数量) | 求解时间 |
---|---|
10 | 1 秒 |
11 | 11 秒 |
12 | 2.2 分 |
13 | 28.6 分 |
14 | 6.7 小时 |
15 | 100 小时 |
16 | 1601 小时 |
而在应用于实际时,TSP问题的规模往往成千上万,用穷举法求解显然不现实,因此我们需要更加有效的求解方法。
在考虑问题时,一般算法设计的策略有以下几种:
- 暴力破解:列出所有可能性,一一尝试并最终给出答案;
- 近似算法:如果问题的最优解或准确解很难给出,那么可以尝试找出问题的次优解或近似解;
- 贪心算法:对于多步骤算法,可以在每一步都取局部最优,并重复直到问题解决;
- 迭代算法:用一个算法求解,另一个算法来提升解的有效性;
- 集合算法:在算法集中准备一系列算法,依次尝试并取最好结果;
- 分而治之:讲一个问题划分为一系列子问题,依次求解后组合成最后结果;
- 站在巨人肩膀上:参考已有算法(just google it!)。
强烈推荐Peter Norvig的[这篇文章](https://nbviewer.jupyter.org/url/norvig.com/ipython/TSP.ipynb),其中详细分析了TSP问题的各种求解思路和python代码实现。
最近邻算法就是同时包含了近似算法和贪心算法思想的一种算法。用于求解TSP问题时,nnTSP可以描述为:
- 从某个城市出发;
- 移动到离现在所处城市最近的且没有访问过的邻居去;
其实现还是非常简单的:
# 在未访问列表中寻找当前位置的最近邻
def nearestNeighbor(cityDist, currentPos, unvisited):
# 输入:cityDist -- [n,n] np array,记录城市间距离
# currentPos -- 一个数字,指示当前位于的城市
# unvisited -- 一个列表,指示可选邻居列表
# 输出:
# nextToVisit -- 一个数字,给出最近邻的index
# dist -- 一个数字,给出到最近邻的距离
neighborDist = cityDist[currentPos,unvisited]
neighborIdx = np.argmin(neighborDist)
nextToVisit = unvisited[neighborIdx]
dist = neighborDist[neighborIdx]
return nextToVisit, dist
# 用贪婪算法求解TSP
def nnTSP(cities, start = 0):
cityList = list(range(cities.shape[0]))
tour = [start]
unvisited = cityList.copy()
unvisited.remove(start)
currentPos = start
tourDist = 0
cityDist = cityDistance(cities)
while unvisited:
# 当unvisited集合不为空时,重复循环
# 找到当前位置在unvisited中的最近邻
nextToVisit, dist = nearestNeighbor(cityDist, currentPos, unvisited)
tourDist += dist
currentPos = nextToVisit
tour.append(nextToVisit)
unvisited.remove(nextToVisit)
# 重新回到起点
tour.append(start)
tourDist += cityDist[currentPos, start]
return tour,tourDist
在上面相同的问题上应用nnTSP求解:
tour,tourDist = nnTSP(cities)
print('nnTSP寻找到的最优路径为:' + str(tour))
print('nnTSP寻找到最优路径长度为:' + str(tourDist))
plotTour(tour, cities)
结果为:
nnTSP寻找到的最优路径为:[0, 15, 7, 14, 2, 16, 19, 26, 5, 4, 9, 28, 3, 22, 29, 1, 11, 13, 10, 12, 6, 21, 20, 27, 25, 17, 8, 23, 18, 24, 0]
nnTSP寻找到最优路径长度为:5156.612343174932
repNNTSP
从nnTSP的算法设计上可以看到,该算法求出的最优路径是和起点相关的,对于不同的起点,会产生不同的最优路径。因而一个简单的改进思路是我们以每个城市为起点进行一次nnTSP搜索,然后在所有得到的路线中选取最优的。
代码实现如下:
# 重复nnTSP但是每次用不同的起点,挑选最佳的结果
def repNNTSP(cities):
optimizedRoute = [] # 最优路径
minDistance = np.Inf # 最优路径长度
for i in range(len(cities)):
tour,tourDist = nnTSP(cities, start = i)
if tourDist < minDistance:
optimizedRoute = tour
minDistance = tourDist
return optimizedRoute, minDistance
结果为:
repNNTSP寻找到的最优路径为:[20, 21, 27, 25, 17, 8, 15, 0, 7, 14, 2, 16, 19, 26, 5, 4, 9, 28, 3, 22, 29, 1, 11, 13, 10, 12, 6, 23, 18, 24, 20]
repNNTSP寻找到的最优路径长度为:4529.115576833992
如预期一般,这个结果比nnTSP更优秀,而且感觉上距离最优解已经很接近了。
2-OPT
在nnTSP给出的结果中,包含了一些交叉。这些交叉会导致路径长度的增加:
可以看到当出现X交叉时,从三角不等式可知,两根红线表示的长度一定小于两根蓝线,故而解开这样的交叉一定能减少路径长度。
我们可以考虑运用迭代思想,在使用nnTSP得到一条可行的路径之后,对路径进行优化,从而得到比nnTSP更优,同时比穷举法更快的解法。
这里我们可以在nnTSP建立了可行路径之后,选用2-Opt作为优化算法,其步骤为:
- 设路径长度为,选择起点和终点;
- 对位于之间的子路径进行反转;
- 如果翻转后的路径较优,将其设置为最优路径
# 2OPT
def opt(cityDist, route, k=2):
# 用2-opt算法优化路径
# 输入:cityDist -- [n,n]矩阵,记录了城市间的距离
# route -- sequence,记录路径
# 输出: 优化后的路径optimizedRoute及其路径长度
nCities = len(route) # 城市数
optimizedRoute = route # 最优路径
minDistance = routeDistance(route) # 最优路径长度
for i in range(1,nCities-2):
for j in range(i+k, nCities):
if j-i == 1:
continue
reversedRoute = route[:i]+route[i:j][::-1]+route[j:]# 翻转后的路径
reversedRouteDist = routeDistance(reversedRoute)
# 如果翻转后路径更优,则更新最优解
if reversedRouteDist < minDistance:
minDistance = reversedRouteDist
optimizedRoute = reversedRoute
return optimizedRoute, minDistance
用2-OPT迭代优化nnTSP:
optimizedRoute, minDistance = opt(cityDist, tour)
print('nnTSP + 2OPT优化后的最优路径为:' + str(optimizedRoute))
print('nnTSP + 2OPT优化后的最优路径长度为:' + str(minDistance))
plotTour(optimizedRoute, cities)
结果:
nnTSP + 2OPT优化后的最优路径为:[0, 15, 7, 14, 2, 16, 19, 26, 5, 4, 9, 28, 3, 22, 29, 1, 11, 13, 10, 12, 24, 18, 23, 8, 17, 25, 27, 20, 21, 6, 0]
nnTSP + 2OPT优化后的最优路径长度为:(4847.935437722887,)
可以看到在应用2-OPT之后,相比优化之前,路径长度减少了308.677;但是另一方面,通过简单的对sub-tour进行翻转并没有能完全uncross所有的交叉。
这个优化方法同样可以用于repNNTSP,来检查是否能找到更优解。用2-OPT迭代优化repNNTSP的代码:
repnnTSPtourOptimized, repnnTSPtourDistOptimized = opt(cityDist, repnnTSPtour, 2)
print('repnnTSPtour + 2OPT优化后的最优路径为:' + str(repnnTSPtourOptimized))
print('repnnTSPtour + 2OPT优化后的最优路径长度为:' + str(repnnTSPtourDistOptimized))
plotTour(repnnTSPtourOptimized, cities)
结果:
repnnTSPtour + 2OPT优化后的最优路径为:[20, 21, 27, 25, 8, 17, 15, 0, 7, 14, 2, 16, 19, 26, 5, 4, 9, 28, 3, 22, 29, 1, 11, 13, 10, 12, 6, 23, 18, 24, 20]
repnnTSPtour + 2OPT优化后的最优路径长度为:(4479.933406593657,)
这个结果看起来已经非常像最优解了:路径之间没有交叉,干净利落的沿着各点形成的convex hull游走。
算法对比
用时
在笔者的工作站上不那么精确地分别测试几种算法在求解规模为30的TSP时的耗时:
在SGA种群规模为100,进化200代时,通常的用时在700ms左右:
707 ms ± 6.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
而nnTSP的用时为:
142 µs ± 5.05 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
repNNTSP则不出所料是nnTSP的大约30倍:
4.21 ms ± 61.8 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
而作为迭代改进算法的2-Opt在一条路线上的用时为:
2.64 ms ± 18.8 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
总结算法的耗时:
问题规模 | SGA | nnTSP | repNNTSP | 2-OPT |
---|---|---|---|---|
30 | 707 ms | 0.142 ms | 4.21 ms | 2.64 ms |
100 | 1.76 s | 0.727 ms | 68.3 ms | 2.85 ms |
300 | 4.71 s | 3.95 ms | 1.19 s | 2.87 ms |
500 | 7.63 s | 9.6 ms | 4.86 s | 2.71 ms |
1000 | 35.9 s | 34.7 ms | 15.2 s | 2.64 ms |
精度
由于遗传算法并非每次都能稳定收敛于一个值,此处列出的解是5次计算所得的均值
问题规模 | SGA | nnTSP | repNNTSP | nnTSP+2-OPT | repNNTSP+2-OPT |
---|---|---|---|---|---|
30 | 5156.61234 | 4529.11558 | 4847.93543 | 4479.93341 | |
100 | 9597.18163 | 8664.28408 | 9388.79648 | 8532.54589 | |
300 | 14958.54994 | 14753.59721 | 14033.02485 | 13914.14821 |
为了在速度上稍有竞争力,只有200代迭代的情况下,SGA往往还没有收敛到最佳,波动还是相对较大的。但是规模问题越大,SGA收敛需要的迭代步数越多,强行在200步结束迭代的结果就是获取的解毫无竞争力。
小结
在随机生成的TSP问题中,我们的SGA基本上被其他算法吊打:
- SGA需要的时间远大于nnTSP和repNNTSP;而且它对问题的规模更加敏感,在大规模问题上想要收敛到满意的解需要更多迭代步与更大的种群,这又会造成计算时间进一步增加;
- 可能的改进方向之一是加入环境选择而不是用生成的子代完整替代父代,保证留下精英族群,来加速收敛;
- 可能的改进方向之二是对种群规模、迭代步数、交叉和突变概率等超参数进行调参,以使算法在TSP问题上更快收敛,并且获得更好的结果;
- 可能的改进方向之三在于利用问题特性,在SGA的基础上加入局部搜索算法,例如用2-OPT改善精英解;
- 下一期将会试着按照这三个方向进行SGA的改进。