“物竞天择,适者生存“,进化界的名言没想到也能用在算法里,不得不承认每个算法工程师也是天马行空的魔法师。由于经常参加数学煎馍美食烹饪大赛,因此时不时需要和启发式算法打交道,这些智能算法的思想充满着活力和开拓性,coding的时候甚至感觉自己像上帝一样为自己的世界制定着规则,不禁连连感叹。
今天开启智能算法篇章,首先上场的是遗传大法。
Ω
启发式算法的目的往往是寻求一个优化模型的最优解,但由于模型的过于庞大或是复杂,想要把精确的最优解揪出来需要花费大量的时间和算力。那么我们退而求其次,争取尽可能逼近最优解,接受一定范围的误差,从而有了各种启发式算法八仙过海。
遗传算法,顾名思义,我们将每一个可行解看作为一个个体,让这些个体生存繁衍,我们作为上帝制定出如何计算个体对于环境适应值(目标函数)的规则。接下来就交给大自然去选择,经过一代一代的淘汰进化,最终出现在我们面前就是适应度最高的个体们。
那么繁衍淘汰这么复杂的生理过程如何在计算机内表达呢,我们知道繁衍的本质就是基因的交换重组,而基因的本质是对遗传信息的编码,计算机最擅长的莫过于此。因此我们首先需要将每个个体(可行解)进行编码为基因串,为了贴合计算机并方便起见一般都是转换为二进制编码,那么繁衍的过程很显然就是对基因进行切片重组。问题是大自然如果只靠单纯的基因重组繁衍后代,那么物种的多样性将被限制,之所以物种能够进化一方面是自然选择,另外一个重要的机制是变异,变异使得原本的基因片段得以改变,从而物种朝着不同的方向发展(搜索),大大增加了算法的鲁棒性。
下面是一个数模比赛中遇到的简化版案例。
ƺ
简单地说,有一些交易订单,首先根据每个订单的计算结果对所有订单进行降序排序(其中是两个与订单无关的待定权重),然后排序结果会送入一个贪心算法中,最终输出一个结果。那么显然不同的会影响订单的排序结果,从而影响到最终的输出结果。此时我希望最大化最终结果,但对于权重的合适取值无从下手。那么就让大自然的遗传大法来筛选吧。
⚤ 编码解码
在此我们需要先确定的取值范围,从而才能对有限的基因进行编码解码。根据初步的估计可以得出两者最优解的分布范围
那么一组可行解即为7位十进制数,根据可知需要24位{0,1}二进制串来编码
前16位为α,后8位为β,那么可以写出解码函数:
def decode(gene):
alpha = eval(gene[0:16]) * 0.0001
beta = eval('0b' + gene[16:]) * 0.00001
return [alpha, beta]
⚤ 初始化种群
个体不能无中生有,我们必然需要先随机生成一个祖先种群,即第一代个体,这些个体没有什么特别要求,只要满足约束条件即可(可行解)。在我们这个问题中对没有约束,只要在上述范围内即可。
注意这里由于二进制表示范围大于我们的上述范围,因此还是先分别生成十进制数然后转化为二进制,最后拼接在一起的。
import random as rd
def init(num):
pop = []
for i in range(num):
b1 = bin(rd.randint(0, 10000))
b2 = bin(rd.randint(0, 1000))
if len(b1) < 16:
b1 = list(b1)
b1.insert(2, '0' * (16 - len(b1)))
b1 = ''.join(b1)
if len(b2) < 12:
b2 = list(b2)
b2.insert(2, '0' * (12 - len(b2)))
b2 = ''.join(b2)
pop.append(b1 + b2[2:])
return pop
⚤ 适应性评价
在开启大自然的选拔机制前,我们需要定义每个个体的适应度,本质上来说就是有多合我们的心意,在运行过程中代表着存活率。如果目标函数是最大化,那么往往是直接将目标函数作为适应性函数;若是最小化,则可以进行取负平移或是取倒数等操作。
另外适应性计算不需要是一个函数,在本例中就是一个贪心算法来计算个体适应性的。
对于个体假设其适应性为,那么其生存率为
⚤ 轮盘赌选择
这是一个比较经典常用的优胜劣汰方式,根据淘汰率生成个随机数,每个随机数选择满足下列条件的第个个体,未被选择的个体将被淘汰
有点像上帝掷骰子。这里不直接选择生存率最高的个体也是在模拟现实世界中的意外,给算法加入了更大的随机性,毕竟未必优良个体繁衍的后代就一定还是优良的,相反劣势个体也是一样。这样的处理方式大大增加了算法的灵活性。
jdg = np.array(list(map(adpt, pop)))
out_num = int(out_rate * len(pop))
next_pop = []
in_num = len(pop) - out_num
for i in range(in_num):
dice = rd.random() * jdg.sum()
sum = 0
for j in range(len(pop)):
sum += jdg[j]
if sum > dice:
next_pop.append(pop[j])
break
⚤ 交叉重组
对上一步中幸存下来的个体,随机选取两者进行交叉重组。首先随机生成一个交叉位点,然后将两个个体的基因在交叉位点处拆分重组后形成的两个新个体加入当前的种群中。我在这里的处理方式是不断繁衍至种群个体数量恢复如初,如果按指数方式增加种群数量的话,迭代次数稍微一大就会很慢。
for i in range(round(out_num / 2)):
x = rd.randint(0, in_num - 1)
y = rd.randint(0, in_num - 1)
node = rd.randint(3, 25)
next_pop.append(next_pop[x][0:node] + next_pop[y][node:])
next_pop.append(next_pop[y][0:node] + next_pop[x][node:])
⚤ 个体变异
对新一代种群中的每个个体生成一个随机数,若(是变异概率,一般较小)则随意生成一个变异位点,将该位点处的编码进行二进制反转(0→1,1→0)。过大会导致进化收敛到最后的优良种群基因不稳定,即使迭代次数较大也难以收敛。
🧟♀️丧尸就是变异过猛的结果🧟♂️
BUT,如果说搜索范围非常大的话,我们将适当调大可能会有着意想不到的结果,但前提是每次迭代后都要更新当前最优个体基因和适应性,这样就相当于一种变相的随机搜索。当然可以两者相结合,那你将面向搜索。
for i in range(len(next_pop)):
if rd.random() < pm:
node = rd.randint(2, 25)
next_pop[i] = next_pop[i][0:node] + ('1' if next_pop[i][node] == '0' else '0') + next_pop[i][node + 1:]
⚤ 重复迭代
一般设置300-500轮迭代,更重要的是观察适应性是否逐渐收敛。
那么上帝是不是也在运行着高阶的遗传算法呢?