欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

用Python实现遗传算法(Genetic Algorithm)的实战教程

最编程 2024-02-11 16:38:26
...

  本文章用Python实现了基本的优化遗传算法并用类进行了封装

一、遗传算法概述

  遗传算法(Genetic Algorithm)是模拟达尔文生物进化论的自然选择和遗传学机理的生物进化过程的计算模型,是一种通过模拟自然进化过程搜索最优解的方法。
  遗传算法的降解可以看有史以来最容易理解的遗传算法,用动画展现的原理。
  遗传算法的一些基本实现可以看莫烦进化算法,用python打好了大致架构并应用了有趣的题目

二、遗传算法实现

1.首先引入需要的包

#jupyter matplotlib内置绘图的魔术命令
%matplotlib inline
#matplotlib绘图、3D相关包
from matplotlib import pyplot as plt
import matplotlib
from mpl_toolkits.mplot3d import Axes3D
#用于动态显示图像 
from IPython import display

#运算、操作处理相关包
import numpy as np
import pandas as pd

2.打好大致框架

class GA():

    def __init__(self, nums, bound, func, DNA_SIZE=None, cross_rate=0.8, mutation=0.003):
        pass
#将编码后的DNA翻译回来(解码)
    def translateDNA(self):
        pass
#得到适应度
    def get_fitness(self, non_negative=False):
        pass
#自然选择
    def select(self):
        pass
#染色体交叉
    def crossover(self):
        pass
#基因变异
    def mutate(self):
        pass
#进化
    def evolution(self):
        pass
#重置
    def reset(self):
        pass
#打印当前状态日志
    def log(self):
        pass
#一维变量作图
    def plot_in_jupyter_1d(self, iter_time=200):
        pass

2.init分析及实现

  作为构造方法,应该确定构造一个对象需要什么,并验证得到的数据
  我们希望得到初始的数据nums,每个变量的边界bound, 运算需要的函数func,DNA的大小(或碱基对的个数)DNA_SIZE,染色体交叉的概率cross_rate,基因变异的概率mutation
  其中得到的nums为二维N * M列表,N进化种群的总数MDNA个数(变量的多少)。
  boundM * 2的二维列表,形如:[(0, 5), (-11.2, +134), ...]。
  func为方法(function)对象,既可以用def定义出的,也可以是lambda表达式定义的匿名函数。
  DNA_SIZE可以指定大小,为None时会自动指派。
  cross_ratemutation也有各自的默认值

编码及解码方式
将固定的二进制整数位数映射到范围内

encoding\_num = (num-var\_min)\times{\frac{2^{DNA\_SIZE}}{var\_len}}\\ decoding\_num = \frac{encoding\_num}{\frac{2^{DNA\_SIZE}}{var\_len}} + var\_min其中,encoding\_numdecoding\_num分别代表编码和解码数,num为需要编码的数,var\_min为当前的范围下限,DNA\_SIZE为DNA长度,既碱基对个数,var\_len为当前变量取值范围的长度,既上限-下限

# input:
#     nums: m * n  n is nums_of x, y, z, ...,and m is population's quantity
#     bound:n * 2  [(min, nax), (min, max), (min, max),...]
#     DNA_SIZE is binary bit size, None is auto
def __init__(self, nums, bound, func, DNA_SIZE=None, cross_rate=0.8, mutation=0.003):
    nums = np.array(nums)
    bound = np.array(bound)
    self.bound = bound
    if nums.shape[1] != bound.shape[0]:
        raise Exception(f'范围的数量与变量的数量不一致, 您有{nums.shape[1]}个变量,却有{bound.shape[0]}个范围')

    for var in nums:
        for index, var_curr in enumerate(var):
            if var_curr < bound[index][0] or var_curr > bound[index][1]:
                raise Exception(f'{var_curr}不在取值范围内')

    for min_bound, max_bound in bound:
        if max_bound < min_bound:
            raise Exception(f'抱歉,({min_bound}, {max_bound})不是合格的取值范围')

    #所有变量的最小值和最大值
    #var_len为所有变量的取值范围大小
    #bit为每个变量按整数编码最小的二进制位数
    min_nums, max_nums = np.array(list(zip(*bound)))
    self.var_len = var_len = max_nums-min_nums
    bits = np.ceil(np.log2(var_len+1))

    if DNA_SIZE == None:
        DNA_SIZE = int(np.max(bits))
    self.DNA_SIZE = DNA_SIZE

    #POP_SIZE为进化的种群数
    self.POP_SIZE = len(nums)
    POP = np.zeros((*nums.shape, DNA_SIZE))
    for i in range(nums.shape[0]):
        for j in range(nums.shape[1]):
    #编码方式:
            num = int(round((nums[i,j] - bound[j][0]) * ((2**DNA_SIZE) / var_len[j])))
    #用python自带的格式化转化为前面空0的二进制字符串,然后拆分成列表
            POP[i,j] = [int(k) for k in ('{0:0'+str(DNA_SIZE)+'b}').format(num)]
    self.POP = POP
    #用于后面重置(reset)
    self.copy_POP = POP.copy()
    self.cross_rate = cross_rate
    self.mutation = mutation
    self.func = func

#save args对象保留参数:
#        bound                取值范围
#        var_len              取值范围大小
#        POP_SIZE             种群大小
#        POP                  编码后的种群[[[1,0,1,...],[1,1,0,...],...]]
#                             一维元素是各个种群,二维元素是各个DNA[1,0,1,0],三维元素是碱基对1/0
#        copy_POP             复制的种群,用于重置
#        cross_rate           染色体交换概率
#        mutation             基因突变概率
#        func                 适应度函数

3.translateDNA分析及实现

  作为翻译函数,我们希望它能把被编码过的种群POP翻译(解码)回来
  方法:先把0101列表转化为10进制数,再通过解码公式解码

def translateDNA(self):
    W_vector = np.array([2**i for i in range(self.DNA_SIZE)]).reshape((self.DNA_SIZE, 1))[::-1]
    binary_vector = self.POP.dot(W_vector).reshape(self.POP.shape[0:2])
    for i in range(binary_vector.shape[0]):
        for j in range(binary_vector.shape[1]):
            binary_vector[i, j] /= ((2**self.DNA_SIZE)/self.var_len[j])
            binary_vector[i, j] += self.bound[j][0]
    return binary_vector

  首先W_vector是带权向量,形如[..., 256, 128, 64, 32, 16, 8, 4, 2, 1],类似二进制转十进制的过程,将二进制对应位数累加即可。
  可以简化为矩阵乘法,首先,POP的大小(shape)为(POP_SIZE, VAR_QUANTITY, DNA_SIZE),假设有100个种群,每个种群有3个DNA(变量数量),每个DNA有20个碱基对,那么对应的W_vector是
\left[\begin{matrix} 2^{19} \\ 2^{18} \\ 2^{17} \\ …… \\ 2^{2} \\ 2^{1} \\ 2^{0} \end{matrix}\right]
形状为(DNA_SIZE, 1),与POP相乘得到的矩阵为(POP_SIZE, VAR_QUANTITY, 1),最后一维只有一个数字显然不是我们想要的,用reshape((POP_SIZE, VAR_QUANTITY))可以将最后一维去掉,我们也回到了最开始(输入)的维度。
  最终,返回翻译回来的矩阵

3. get_fitness分析及实现

  我们希望通过get_fitness得到当前的适应值,有可能怀有疑问,直接将translateDNA的结果交给适应函数func函数不就是适应值吗?的确这样很简单,但是有时候我们需要对适应值进行进一步处理,因此,我们封装一层函数,易于在有需求时修改代码。

def get_fitness(self, non_negative=False):
    result = self.func(*np.array(list(zip(*self.translateDNA()))))
    if non_negative:
        min_fit = np.min(result, axis=0)
        result -= min_fit 
    return result

  我们在后面看到一个需求,就是有时候我们需要非负的适应值,因此我们加了一个带默认值参数non_negative,假如需要非负适应值时,只要传入True就可以了
  首先将含有一堆[x0, x1, ..]变量的列表转置,变为含有[POP0_x0, POP1_x1, ..]这种形式,将其解包后传给func,得到的result是一维行向量。
  如果要求非负,只要令每个适应值减去最小值即可。结果返回适应值向量
  注意:本实例的适应度函数func不需要判断(if),如果适应度函数中有判断(分段),此处需要遍历数组而不能直接传入func进行矩阵运算。

4. select分析及实现

  select意为自然选择,适者生存,不适者被淘汰,因此适应度低的种群会有更大概率消失,当然这也意味着,就算是有再高的适应度,也是有几率被淘汰的(除非所有种群已经达到统一),我们希望这个函数能实现一次对内部成员POP的自然选择

def select(self):
    fitness = self.get_fitness(non_negative=True)
    self.POP = self.POP[np.random.choice(np.arange(self.POP.shape[0]), size=self.POP.shape[0], replace=True, p=fitness/np.sum(fitness))]

  首先,我们通过get_fitness获得适应度向量。
  然后是一大串程序,我们从choice开始分析。numpy.random.choice是numpy中的随机抽取函数,可以对序列中的元素进行随机抽取,第一个参数为待抽取序列,size参数为抽取的数量,replace参数为是否允许重复,p一个列表,列表中的每个元素为第一个参数序列中每个参数被选中的概率
  因此我们并非是将原有的元素按概率进行剔除,而是类似新抽取一个列表,适应度大的种群将有更大的几率存活并在生态圈(POP)中占有更大的比重
  在程序中我们不是直接抽取POP,而是对POP进行编号(np.arange),对编号序列进行抽取,生态圈大小维持原来的大小,允许重复,否则自然选择不会起作用,而每个种群的概率是:\frac{fit_{current}}{\sum_{i=1}^{n}{fit_i}}将所有种群组成的列表传给p,得到的是优胜者的编号,类似[12, 3, 45, 23, 12, 12,...]这样。
  将返回的矩阵传给原来的POP,POP为numpy.array,给item方法(等同于numpy.array对象取[ ]操作符)传递list<int>返回结果为列表中每个元素标号的元素,如上面的列表编号,将返回[a[12], a[3], a[45], a[23], a[12], a[12], ...]
  可能有人注意到了,第一行的get_fitness给non_negative传递了True,这是因为运算概率时如果有负值会出现负概率,而choice就会抛出:ValueError: probabilities are not non-gegative,意为概率不能为负,因此通过传入参数令get_fitness返回负值

5. crossover分析及实现

  作为染色体交叉函数,我们希望这个函数能对生态圈进行一次交叉操作。
  首先对每个种群检测是否交换DNA序列。
  如果交换,则在生态圈中随机找到一个与其交叉的种群。
  随机确定交叉的位数,然后进行交叉。

def crossover(self):
    for people in self.POP:
        if np.random.rand() < self.cross_rate:
            i_ = np.random.randint(0, self.POP.shape[0], size=1)
            cross_points = np.random.randint(0, 2, size=(len(self.var_len) ,self.DNA_SIZE)).astype(np.bool) 
            people[cross_points] = self.POP[i_, cross_points]

  其中i_就是提供交叉DNA种群的编号,cross_point是交叉点,最后一行是类似掩膜(mask)的操作,将交叉点的碱基对交给被交叉的种群。

6. mutation分析及实现

  mutation是变异函数,对所有生态圈种群,找到所有DNA,检测所有碱基,按概率变异,因此对三维列表POP进行遍历。

def mutate(self):
    for people in self.POP:
        for var in people:
            for point in range(self.DNA_SIZE):
                if np.random.rand() < self.mutation:
                    var[point] = 1 if var[point] == 0 else 1

7. evolution分析及实现

  进化函数所要做的是对生态圈进行一次整体进化,解析出来,只需要进行自然选择、染色体交叉、基因变异各一次就好,在前面都抽象好的当下,实现十分容易。

def evolution(self):
    self.select()
    self.crossover()
    self.mutate()

8. log分析及实现

  在进化的过程中,我们希望得到种群当前状态,因此需要一个日志函数,在这里,我实现的是返回包含当前所有DNA值以及种群的适应度的Pandas.DataFrame

def log(self):
    return pd.DataFrame(np.hstack((self.translateDNA(), self.get_fitness().reshape((len(self.POP),1)))), 
                        columns=[f'x{i}' for i in range(len(self.var_len))]+['F'])
GA.log()

8. reset分析及实现

  将内部备份的copy_POP复制回来即可

    def reset(self):
        self.POP = self.copy_POP.copy()

9.plot_in_jupyter_1d分析及实现

  在jupyter-notebook中显示多次迭代的动态图形

    def plot_in_jupyter_1d(self, iter_time=200):
        is_ipython = 'inline' in matplotlib.get_backend()
        if is_ipython:
            from IPython import display

        plt.ion()
        for _ in range(iter_time):
            plt.cla()
            x = np.linspace(*self.bound[0], self.var_len[0]*50)
            plt.plot(x, self.func(x))
            x = self.translateDNA().reshape(self.POP_SIZE)
            plt.scatter(x, self.func(x), s=200, lw=0, c='red', alpha=0.5)
            if is_ipython:
                display.clear_output(wait=True)
                display.display(plt.gcf())

            self.evolution()

  iter_time为迭代次数

三、实验

1.单变量实验

  实验目标(适应度)函数为:F(X) = X \times\sin{10·X} + X \times \cos{2·X}变量范围为[0, 5],碱基对个数为10,生态圈初始随机。

func = lambda x:np.sin(10*x)*x + np.cos(2*x)*x
ga = GA([[np.random.rand()*5] for _ in range(100)], [(0,5)], DNA_SIZE=10, func=func)
ga.plot_in_jupyter_1d()

可以看到点逐渐上升
开始
结果

2.多变量实验

  多变量作图没有封装,但类的抽象度很高,很容易使用,实验所用的目标函数为:X·cos{2\pi Y+Y·\sin{2\pi·X}}生态圈初始随机,范围分别为[-2, 10]、[-2, 10],碱基对个数为20,交叉概率0.7,变异概率为0.01。

nums = list(zip(np.arange(-2, 2, 0.2), np.arange(-2, 2, 0.2)))
bound = [(-2, 2), (-2, 2)]
func = lambda x, y: x*np.cos(2*np.pi*y)+y*np.sin(2*np.pi*x)
DNA_SIZE = 20
cross_rate = 0.7
mutation = 0.01

ga = GA(nums=nums, bound=bound, func=func, DNA_SIZE=DNA_SIZE, cross_rate=cross_rate, mutation=mutation)
is_ipython = 'inline' in matplotlib.get_backend()
if is_ipython:
    from IPython import display

plt.ion() #打开交互式
for _ in range(200):
    plt.cla() #清空画布
    X = Y = np.arange(-2, 2, 0.2)
    X, Y=np.meshgrid(X,Y)
    Z = func(X, Y)
    
    fig1=plt.figure()
    ax=Axes3D(fig1)
    ax.plot_surface(X, Y, Z, rstride=1, cstride=1, cmap=plt.cm.coolwarm)#做曲面
    ax.set_xlabel('x0 label', color='r')
    ax.set_ylabel('x1 label', color='g')
    ax.set_zlabel('F label', color='b')#给三个坐标轴注明
    
    ax.scatter(*list(zip(*ga.translateDNA())), ga.get_fitness(), s=100, lw=0, c='red', alpha=0.5)
    
    if is_ipython:
        display.clear_output(wait=True)
        display.display(plt.gcf())

    ga.evolution()
开始
中间
结束

用打印日志的方式找到最大值,同理,也可以进行排序显示:

ga.log().max()

#output:
#x0   -1.766613
#x1    1.765591
#F     3.265491
#dtype: float64

注意:每次运行结果可能有少量差异

四、后续维护

  基础的架构已经打完,可以在原有程序上进行改造。例如,将DNA_SIZE改成不等长方式,给每个重要变量做出get、set方法,丰富日志功能、信息,支持高纬度图显示等等