GA(遗传算法)学习和JGAP的使用

概念原理

遗传算法是计算数学中用于解决最优化的搜索算法,是进化算法的一种。进化算法最初是借鉴了进化生物学中的一些现象而发展起来的,这些现象包括遗传、突变、自然选择以及杂交等。

遗传算法通常实现方式为一种计算机模拟。对于一个最优化问题,一定数量的候选解(称为个体)的抽象表示(称为染色体)的种群向更好的解进化。传统上,解用二进制表示(即0和1的串),但也可以用其他表示方法。进化从完全随机个体的种群开始,之后一代一代发生。在每一代中,整个种群的适应度被评价,从当前种群中随机地选择多个个体(基于它们的适应度),通过自然选择和突变产生新的生命种群,该种群在算法的下一次迭代中成为当前种群。

以上是维基百科对遗传算法的介绍,更详细的介绍可以这里。接下来,主要详细谈谈自己对GA的理解。

这里,我们首先来明确下遗传算法中的几大基础概念:

  1. 基因:一个遗传因子,在实际问题中应该是问题解的一个部分,若干个基因组成问题的解。而构建基因的方式有多种,其中常见的方法有二进编码,整数编码等,具体实例参考实践部分。

  2. 染色体:包含若干个基因的一个个体,在实际问题中做为问题的一个解存在。

  3. 种群:由若干个染色体(个体)组成的群体。

  4. 适应度函数:根据特定规则,计算染色体的适应度,直白点说就是适应能力如何。适应度越大,代表适应能力越强,生存(被选择)的概率相对也就更大。

  5. 选择器:根据某种选择策略,依据各个染色体的适应度大小来选择当代若干染色体(个体)作为下一代种群中的染色体(个体)。

  6. 交叉算子:两个染色体彼此交叉互换若干部分的基因组,达到遗传繁衍的作用。

  7. 变异算子:根据某种策略,来修改染色体其中特定的基因信息,达到变异的作用。

接下来,我们通过图解的方式来进一步明确基本交叉与变异的操作方式,这里我们以二进制的方式来构造染色体,那也就是说明只含有两种类型的基因,0和1。

交叉前两个染色体的状态:(红色部分为两染色体彼此交叉互换的部分)

image

交叉后两个染色体的状态:

image

变异前染色体的状态:(红色部分为突变的基因)

image

变异后染色体的状态:

image

遗传算法总体过程大概如下:首先通过一定的编码方式,来构造组成染色体的所有基因,然后根据构造得出的基因来组成一个个体(染色体),继而根据该个体(染色体)来生成含有一定数量的个体(或者说是染色体)的种群,最后通过自然选择、染色体间的交叉和基因的突变过程来繁衍出下一代种群。其中自然选择的算法有多种,比如最优N个体选择算法,轮盘赌选择算法等;交叉算法也有多种,比如贪婪交叉算法,平均交叉算法等;突变算法亦有多种,比如高斯突变算法,双向突变算法等。在自然选择的过程中,不管使用何种选择算法,在选择的过程中都需要根据由适应度算法针对特定个体(染色体)计算获得其适应度大小来选择是否将其选择为下一代种群的个体(染色体)。算法的基本骨架如下:

/*
* PC:交叉发生的概率
* PM:变异发生的概率
* S:种群规模
* G:终止进化的代数
* PT:进化产生的任何一个个体的适应度函数超过PT,则可以终止进化过程
*/
生成初始化种群
do{
  计算种群中所有染色体的适应度
  初始化空种群newPop
  do{
	  根据适应度由选择算法从当前种群中选出两个个体(染色体)
	  if(random(0,1)<PC)
	  {
		  两个个体根据交叉算法进行交叉操作
	  }
	  if(random(0,1)<PM)
	  {
		  两个个体分别根据变异算法进行变异操作
	  }
	  将两个新个体添加到新种群newPop中
  }while(新种群个体数还没有达到种群规模)
  将新种群代替当前种群
}while(新种群所有个体适应度都已达到PT或者进化次数已经完成)

JGAP学习

到现在为此,已经出现了各类语言版本的GA类库,如C#版本的AForge.genetic以及Java版本的JGAP。两都均是非常优秀的GA算法类库,灵活、方便、易扩展!接下来,我们就详细地来介绍JGAP吧。

这里是JGAP的官网,这里是JGAP源码包(v3.6.2版本),这里是对应的javadoc。在源码包里主要分为两部分,一个是GA实现的源代码,另一个是通过GA算法来完成具体实例的求解方案,比如旅行售货员问题和硬币找钱问题等。通过研究这两部分我们可以清楚地明白GA实现过程以及如何利用现有GA算法骨架来完成实际问题的求解。本着探索研究的目的,我将下载得到的源代码包及示例包整合在一起,组合生成一个叫做JGAPLearn的学习项目,方便编译调试,如果有需要的同学可以在文章末尾的下载地址里下载得到该学习项目源码。

在JGAP类库中,GA算法的实现原理上文所述基本一致,也提供了相应的接口类,比如基因类(Gene),染色体类(IChromosome),种群类(Population)以及适应度函数(FitnessFunction)等,同时基于以上接口,提供了不同作用的具体类,用于完成不同问题的求解。这里需要重点强调一点的是,在JGAP类库中还提供了接口类(Configuration),Configuration在JGAP中充当枢纽的作用,几乎在遗传算法求解过程的每一步都会调用到configuration类,从中获取相应的配置内容,比如选择器、交叉算子及变异算子等。以上接口类的详细内容在这里就省去不提呢,有兴趣的可以下载文章末尾的JGAPLearn项目源码,来窥探其中究竟吧。正所谓,源码面前,了无秘密,说得就是这个道理。在这里,我也只能粗粒度地讲解JGAP。接下来,我们就通过JGAP源码本身所携带的两个示例程序来学习下JGAP类库的使用方法吧,一个是旅行售货员问题,另一个是硬币找钱问题。

所谓旅行售货员问题是:

某售货员要到若干城市去推销商品,已知各城市之间的路程(或旅费)。他要选定一条从驻地出发,经过每个城市一遍,最后回到驻地的路线,使总的路程( 或旅费)最小。在这里,我们假设各个城市之间都有通路。在这是,我们要求的是最短路程的旅程线路。

当使用GA算法来求解最优线路,我们首先需要的构造出与问题相适应的基因(解的一个部分)与染色体(完整解),在这里,我们只需要使用IntegerGene基因类来模拟基因,每一个基因都关联着一个对应的二维坐标,通过基因对等基因值来索引得到对应的二维坐标值,这样就可以直接通过基因来计算各自所引用城市间的路程呢。如下所示:

public double distance(Gene a_from, Gene a_to) {
   IntegerGene geneA = (IntegerGene) a_from;//获取基因里的对等基因值,也就是对应城市坐标的索引值
   IntegerGene geneB = (IntegerGene) a_to;
   int a = geneA.intValue();
   int b = geneB.intValue();
   int x1 = CITYARRAY[a][0];
   int y1 = CITYARRAY[a][1];
   int x2 = CITYARRAY[b][0];
   int y2 = CITYARRAY[b][1];
  return Math.sqrt( (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));//计算两城市间的距离
}

因为基因是染色体的组成部分,所以两都的构建过程一般也是发生在一个函数里,如:

public IChromosome createSampleChromosome(Object a_initial_data) {
   try {
	 Gene[] genes = new Gene[CITIES];
	 for (int i = 0; i < genes.length; i++) {
	   genes[i] = new IntegerGene(getConfiguration(), 0, CITIES - 1);
										   //其中0和CITES代表的是基因中对等基因值设置范围的下限和上限
	   genes[i].setAllele(new Integer(i));//设置基因里的对等基因值,也就是对应城市坐标的索引值
	   
	 }
  //生成染色体,其也就代表着问题的一个解法。所以线路依次为所含基因genes数组里从索引0到最后一个基因所包含的对等基因值所代表的城市编号
	IChromosome sample = new Chromosome(getConfiguration(), genes); 
	return sample;
  }
  catch (InvalidConfigurationException iex) {
	throw new IllegalStateException(iex.getMessage());
  }
}

在构建好对应的样本染色体之后,我们现在需要完成的是对configuration对象的配置。这个对象比较复杂,上文也已经提到,其是整个算法的枢纽,本身包含很多遗传算法求解过程中需要用到的对应组件,比如选择器、交叉算子及变异算子等,让我们用源码来说明问题吧!

public Configuration createConfiguration(final Object a_initial_data)
		throws InvalidConfigurationException {
	// This is copied from DefaultConfiguration.
	// -----------------------------------------
	Configuration config = new Configuration();
	BestChromosomesSelector bestChromsSelector =
		new BestChromosomesSelector(config, 1.0d);//生成相应的选择器
	bestChromsSelector.setDoubletteChromosomesAllowed(false);//设置doublette染色体是否应该被加入选择器
	config.addNaturalSelector(bestChromsSelector, true);//设置选择器
	config.setRandomGenerator(new StockRandomGenerator());//设置生成器
	config.setMinimumPopSizePercent(0);//设置最小种群数目百分比
	config.setEventManager(new EventManager());//设置事件管理器,用于订阅相应的监听事件
	config.setFitnessEvaluator(new DefaultFitnessEvaluator());//设置适应度大小评估器
	config.setChromosomePool(new ChromosomePool());//设置染色体池,用于回收再利用不再使用的染色体
	// These are different
	// --------------------
	config.addGeneticOperator(new GreedyCrossover(config));//设置交叉算子
	config.addGeneticOperator(new SwappingMutationOperator(config, 20));//设置变异算子
	return config;
}

从上述代码片段中,我们看到,需要对configuration对象设置诸多包含项,因为这些包含项在接下来的进化过程中随时都有可能需要取出并执行相应的操作。到这里,我们基本上已经完成了所有的前期准备工作。不过在查看真正进化操作之前,我们要不得不先介绍一个对遗传算法来说至关重要的一环:适应度函数。由于JGAP类库已经定义好了对应的适应度接口,所以我们只需要依据对应的接口来实现相应的方法即可,其中最关键的api是evaluate()方法。下面,我们就来看看专门为旅行售货员问题编写的适应度接口类中的evalute()实现吧!

 protected double evaluate(final IChromosome a_subject) {
   double s = 0;
   Gene[] genes = a_subject.getGenes();
   //计算所行走的距离是依次累加根据连续基因对彼此所关联城市之间的距离访求来求得
   for (int i = 0; i < genes.length - 1; i++) {
	 s += m_salesman.distance(genes[i], genes[i + 1]);
   }
   s += m_salesman.distance(genes[genes.length - 1], genes[0]);//添加从最后一个城市返回到起始城市的距离
   return Integer.MAX_VALUE / 2 - s;
}

从上述代码段中,我们可以看到求当前路线所经历的距离的方法还是比较简单的,只不过是依次累加相邻间基因对所关联城市之间的距离最后再添加回到起始城市的距离。值得注意的是,每次进化的过程实际上就是变更染色体中所有基因之间的顺序而已,然后根据上述适应度函数计算得出当前顺序下所走路程的距离。从这里我们也可以看出,定义正确且适当的适应度函数是如何地重要,因为选择器在自然选择的时候随时随地都需要调用适应度函数!

最后,是时候祭出组合上述函数的完整进化函数呢!有图有真相!

 public IChromosome findOptimalPath(final Object a_initial_data)
	 throws Exception {
   m_config = createConfiguration(a_initial_data);//配置configuration并返回之
   FitnessFunction myFunc = createFitnessFunction(a_initial_data);
   m_config.setFitnessFunction(myFunc);//配置适应度接口,这是无论如何也得设置的哦!
   
   IChromosome sampleChromosome = createSampleChromosome(a_initial_data);
   m_config.setSampleChromosome(sampleChromosome);//设置样本染色体
 
  m_config.setPopulationSize(getPopulationSize());//设置种群大小,这里为512,这个参数可以自定义
 
  //接下来的几行代码是复制生成种群足够数量的染色体,主要是克隆样本染色体中的所有基因
  IChromosome[] chromosomes =
	  new IChromosome[m_config.getPopulationSize()];
  Gene[] samplegenes = sampleChromosome.getGenes();
  for (int i = 0; i < chromosomes.length; i++) {
	Gene[] genes = new Gene[samplegenes.length];
	for (int k = 0; k < genes.length; k++) {
	  genes[k] = samplegenes[k].newGene();
	  genes[k].setAllele(samplegenes[k].getAllele());
	}
	chromosomes[i] = new Chromosome(m_config, genes);
  }
  //genotype类是包含一个种群的类,负责种群进化的工作,很重要
  Genotype population = new Genotype(m_config,
									 new Population(m_config, chromosomes));
  IChromosome best = null;
  //根据前文设置好的进化次数,循环进入进化
  Evolution
	  for (int i = 0; i < getMaxEvolution(); i++) {
		  population.evolve();
	best = population.getFittestChromosome();//获取最后一轮进化所得的适应度最大的染色体,理想状况下其应该是近似最优解
  }
  return best;
}

过程很简单,代码段主要完成的生成对应的种群对象,然后生成对应的Genotype对象,来进行真正的进化操作,最后返回最后一轮进化所得种群中适应度最大的染色体,也即是近似最优解。直接打印该染色体即可以得到最优行走路线。在自定义的七个城市二维坐标,种群大小为512,进化次数为128次的情况下,运行程序五次,连续三次出现三次相同的行走路线。从结果来看,我们可以得出,通过GA算法,确实可以得到近似最优解。

好呢,接下来是硬币找钱问题呢!

有M种不同的种类的硬币,每个种类的硬币所代表的钱数都不一样。假设现在找零N元,如何利用上述各类的硬币使用最少硬币个数的情况下,来找出价值为N元的组合。

同理,和解决旅行售货员问题一样,按照GA的解决思路,首先我们应该确立好组成问题解的基因构造问题。使用GA解决硬币找钱问题,是根据适应度函数不断变化各种硬币的数量以期达到使用最少硬币量来组成所要找的钱数。也就是说,我们不能指定基因的对等基因值,因为它是随着进化过程不断变化的,而旅行售货员问题中的构造的基因所包含的对等基因值是恒定不变的,每次进化过程中修改的是染色体中所有基因的相对位置而已,并不会修改每个基因的对等基因值。综上所述,在构造硬币问题的基因应该是只要指定基因的对等基因值的上下限,而无需指定基因的对等基因值的确却值。代码如下:

Gene[] sampleGenes = new Gene[4];
sampleGenes[0] = new IntegerGene(conf, 0, 3 * 10); // Quarters,注意并不指定对等基因值
sampleGenes[1] = new IntegerGene(conf, 0, 2 * 10); // Dimes
sampleGenes[2] = new IntegerGene(conf, 0, 1 * 10); // Nickels
sampleGenes[3] = new IntegerGene(conf, 0, 4 * 10); // Pennies
Chromosome sampleChromosome = new Chromosome(conf, sampleGenes);

先实现好适应度类,直接上代码说明适应度函数是如何编写的吧!思想还是比较简单易懂的。

public double evaluate(IChromosome a_subject) {
   int changeAmount = amountOfChange(a_subject);//计算当前硬币数量下,总价钱
   int totalCoins = getTotalNumberOfCoins(a_subject);//计算当前所有硬币数量
   int changeDifference = Math.abs(m_targetAmount - changeAmount);//目标总价钱与当前总价钱的差值
   double fitness = (MAX_BOUND - 1 - changeDifference * 20);//计算适应度,值越大,代表当前解越优
   if (changeDifference == 0) {
	   //如果差值为0时,则适应度还应该增加一计算值,增加适应度,目的显而易见
	 fitness += computeCoinNumberBonus(totalCoins);
   }
  return Math.max(1.0d, fitness);
}

其实,构造好对应的基因和染色体之前,我们需要使用到configuration对象,在这里,我们只是设置了configuration对象的几个两个属性而已,一个为是否把当前种群中适应度最大的染色体直接保留到下一代种群中,另一个是设置适应度对象呢。代码如下:

Configuration conf = new DefaultConfiguration();
conf.setPreservFittestIndividual(true);//设置是否保存适应度最大的个体
FitnessFunction myFunc =
  new CoinsExampleFitnessFunction(a_targetChangeAmount);
conf.setFitnessFunction(myFunc);//设置适应度对象

到这里,大家可能会感觉有所疑问,怎么不用设置其他的配置项,比如选择器、交叉算子、变异算子?答案是因为在硬币找钱问题中我们使用一个configuration的子类PermutingConfiguration,此类的主要作用是用于动态置换其中若干配置项,比如使用不同的选择器,交叉算子,变异算子等等。这样,可以需要获取不同的configuration对象。

PermutingConfiguration pconf = new PermutingConfiguration(conf);
pconf.addGeneticOperatorSlot(new CrossoverOperator(conf));//设置交叉算子
pconf.addGeneticOperatorSlot(new MutationOperator(conf));//设置变异算子
pconf.addNaturalSelectorSlot(new BestChromosomesSelector(conf));//设置选择器
pconf.addNaturalSelectorSlot(new WeightedRouletteSelector(conf));//设置多个不同的选择器
pconf.addRandomGeneratorSlot(new StockRandomGenerator());//设置随机生成器
RandomGeneratorForTesting rn = new RandomGeneratorForTesting();
rn.setNextDouble(0.7d);
rn.setNextInt(2);
pconf.addRandomGeneratorSlot(rn);//设置多个不同的随机生成器
pconf.addRandomGeneratorSlot(new GaussianRandomGenerator());//设置多个不同的随机生成器
pconf.addFitnessFunctionSlot(new CoinsExampleFitnessFunction(
  a_targetChangeAmount));//设置适应度对象

同时,我们使用Evaluator对象来保留一些统计信息并在需要的时候返回。

Evaluator eval = new Evaluator(pconf);

最后,我们可以通过如下代码来随机生成种群,同时会对种群中所有的染色体的基因值设置随机值,随机值在上限与下限之间。这是Genotype类的randomInitialGenotype()api方法完成的。这个与旅行售货员求解思路是不同的。

Genotype population = Genotype.randomInitialGenotype(eval.next());

现在,我们已经可以直接对种群进行迭代进化操作呢。但是,之前我们既然使用了Evaluator,就是为了产生不同配置对象configuration,可用于不同的进化过程。上面代码中的eval.next()方法返回的就是一个可用的configuration对象,用于本次进化操作。

while (eval.hasNext()) {
     Genotype population = Genotype.randomInitialGenotype(eval.next());
     for (int run = 0; run < 10; run++) {
       for (int i = 0; i < MAX_ALLOWED_EVOLUTIONS; i++) {
         population.evolve();//进行迭代进化操作
         //获取最大适应度值
         double fitness = population.getFittestChromosome().getFitnessValue();
         if (i % 3 == 0) {
           String s = String.valueOf(i);
          eval.setValue(permutation, run, fitness, "" + permutation, s);//保存当前进化信息
          eval.storeGenotype(permutation, run, population);//保存当前种群信息
        }
      }
    }
    //省略后处理代码部分
}

在测试环境下,运行十次的统计中,所得结果与最优解近似相等的情况超过6次。说明,通过GA算法,是可以比较好地模拟求解硬币找钱问题。

结束语

到这里,这篇博文也差不多该收尾呢。首先大致介绍了遗传算法的由来和概念,接着引进了JGAP类库,最后通过两个测试用例说明JGAP的使用方法。当然,对JGAP的介绍显得比较粗糙,需要进一步地解剖和分析,尤其是对JGAP类库中整体类结构分析地还是不那样透彻,但是经过本文,相信对JGAP入门及基本的了解应该是没有什么问题呢。有时间再专门来一篇针对JGAP整体架构的详细解读吧!(全文完)

点击这里下载JGAPLearn的学习项目

你可能感兴趣的:(GA(遗传算法)学习和JGAP的使用)