Java8-遗传算法基础-三-

49 阅读19分钟

Java8 遗传算法基础(三)

协议:CC BY-NC-SA 4.0

六、最优化

在这一章中,我们将探索常用于优化遗传算法的不同技术。随着所解决的问题变得越来越复杂,额外的优化技术变得越来越重要。一个优化良好的算法在解决较大问题时可以节省数小时甚至数天;因此,当问题达到一定的复杂程度时,优化技术是必不可少的。

除了探索一些常见的优化技术之外,本章还将介绍一些使用前几章案例研究中的遗传算法的实现示例。

自适应遗传算法

自适应遗传算法(AGA)是遗传算法的一个流行子集,当在正确的情况下使用时,它可以提供比标准实现显著的性能改进。正如我们在前面的章节中了解到的,决定遗传算法性能的一个关键因素是其参数的配置方式。我们已经讨论了在构建有效的遗传算法时,找到正确的变异率和交叉率值的重要性。典型地,在最终达到令人满意的配置之前,配置参数将需要一些试验和错误,以及一些直觉。自适应遗传算法是有用的,因为它们可以通过基于算法的状态调整这些参数来帮助自动调整这些参数。这些参数调整发生在遗传算法运行时,希望在执行过程中的任何特定时间使用最佳参数。正是这种算法参数的连续自适应调整通常会导致遗传算法的性能提高。

自适应遗传算法使用诸如平均群体适应度和群体的当前最佳适应度之类的信息,以最适合其当前状态的方式来计算和更新其参数。例如,通过将任何特定的个体与群体中当前最健康的个体进行比较,可以衡量该个体相对于当前最佳个体的表现如何。通常,我们希望增加保留表现良好的个人的机会,减少保留表现不佳的个人的机会。我们可以做到这一点的一个方法是允许算法自适应地更新变异率。

不幸的是,事情没那么简单。过一会儿,群体将开始收敛,个体将开始向搜索空间中的单个点靠拢。当这种情况发生时,搜索的进程可能会停止,因为个体之间的差异很小。在这种情况下,稍微提高变异率,鼓励在搜索空间内搜索替代区域是有效的。

我们可以通过计算当前最佳适应度和平均群体适应度之间的差异来确定算法是否已经开始收敛。当平均群体适应度接近当前最佳适应度时,我们知道群体已经开始在搜索空间的小区域周围收敛。

然而,自适应遗传算法可用于调整的不仅仅是突变率。可以应用类似的技术来调整遗传算法的其他参数,例如交叉率,以根据需要提供进一步的改进。

履行

正如许多与遗传算法有关的事情一样,更新参数的最佳方式通常需要一些实验。我们将探索一种更常见的方法,如果您愿意,您可以自己尝试其他方法。

如前所述,当计算任何给定个体的突变率时,要考虑的两个最重要的特征是当前个体的表现和整个群体的整体表现。我们将用于评估这两个特征并更新突变率的算法如下:

pm=(fmax-fI)/(fmax–favg)* m,fIfavg

p m = m,f i ≤ f avg

当个体的适应度大于群体的平均适应度时,我们从群体中选取最佳适应度(f max )并找出当前个体适应度(f i )之间的差异。然后我们找到最大群体适应度和平均群体适应度之间的差(f avg )并将这两个值相除。我们可以使用这个值来调整初始化时设置的突变率。如果个体的适应度等于或小于群体的平均适应度,我们简单地使用初始化时设置的突变率。

为了使事情变得简单,我们可以将新的自适应遗传算法代码实现到我们以前的类调度器代码中。首先,我们需要添加一种新的方法来获得群体的平均适应度。我们可以通过在文件中的任意位置向 Population 类添加以下方法来实现这一点:

/**

* Get average fitness

*

* @return The average individual fitness

*/

public double getAvgFitness(){

if (this.populationFitness == -1) {

double totalFitness = 0;

for (Individual individual : population) {

totalFitness += individual.getFitness();

}

this.populationFitness = totalFitness;

}

return populationFitness / this.size();

}

现在,我们可以通过更新变异函数来使用我们的自适应变异算法来完成实现,

/**

* Apply mutation to population

*

* @param population

* @param timetable

* @return The mutated population

*/

public Population mutatePopulation(Population population, Timetable timetable){

// Initialize new population

Population newPopulation = new Population(this.populationSize);

// Get best fitness

double bestFitness = population.getFittest(0).getFitness();

// Loop over current population by fitness

for (int populationIndex = 0; populationIndex < population.size(); populationIndex++) {

Individual individual = population.getFittest(populationIndex);

// Create random individual to swap genes with

Individual randomIndividual = new Individual(timetable);

// Calculate adaptive mutation rate

double adaptiveMutationRate = this.mutationRate;

if (individual.getFitness() > population.getAvgFitness()) {

double fitnessDelta1 = bestFitness - individual.getFitness();

double fitnessDelta2 = bestFitness - population.getAvgFitness();

adaptiveMutationRate = (fitnessDelta1 / fitnessDelta2) * this.mutationRate;

}

// Loop over individual’s genes

for (int geneIndex = 0; geneIndex < individual.getChromosomeLength(); geneIndex++) {

// Skip mutation if this is an elite individual

if (populationIndex > this.elitismCount) {

// Does this gene need mutating?

if (adaptiveMutationRate > Math.random()) {

// Swap for new gene

individual.setGene(geneIndex, randomIndividual.getGene(geneIndex));

}

}

}

// Add individual to population

newPopulation.setIndividual(populationIndex, individual);

}

// Return mutated population

return newPopulation;

}

这个新的 mutatePopulation 方法除了实现上述算法的自适应变异代码之外,与原来的方法相同。

当在启用自适应变异的情况下初始化遗传算法时,所使用的变异率现在将是最大可能的变异率,并且将根据当前个体和群体整体的适应度按比例缩小。正因为如此,较高的初始突变率可能是有益的。

练习

Use what you know about the adaptive mutation rate to implement an adaptive crossover rate into your genetic algorithm.  

多重启发式

当谈到优化遗传算法时,实现二次启发式是在某些条件下实现显著性能改进的另一种常见方法。在遗传算法中实现第二种启发式算法允许我们将多种启发式方法的最佳方面结合到一种算法中,提供对搜索策略和性能的进一步控制。

两种常用于遗传算法的启发式算法是模拟退火和禁忌搜索。模拟退火是一种模拟冶金中退火过程的启发式搜索。简而言之,它是一种爬山算法,旨在逐渐降低接受更差解决方案的比率。在遗传算法的背景下,模拟退火将随着时间的推移降低突变率和/或交叉率。

另一方面,禁忌搜索是一种搜索算法,它保持“禁忌”(源自“禁忌”)解决方案的列表,以防止算法返回到搜索空间中已知为薄弱的先前访问过的区域。这个禁忌列表有助于算法避免重复考虑它以前找到的已知薄弱的解决方案。

通常情况下,多启发式方法只会在包含它可以给搜索过程带来某些需要的改进的情况下实施。例如,如果遗传算法在搜索空间的某个区域收敛得太快,在算法中实现模拟退火可能有助于控制算法收敛的速度。

履行

让我们通过结合模拟退火算法和遗传算法来看一个多启发式算法的快速例子。如前所述,模拟退火算法是一种爬山算法,它最初以较高的速度接受较差的解决方案;然后,随着算法的运行,它会逐渐降低接受更差解决方案的比率。

将该特性实现到遗传算法中的最简单的方法之一是通过更新变异和交叉率,以高速率开始,然后随着算法的进展逐渐降低变异和交叉率。这种初始的高变异和交叉率将导致遗传算法搜索大面积的搜索空间。然后,随着变异和交叉率缓慢降低,遗传算法应该开始将其搜索集中在适应值较高的搜索空间区域。

为了改变变异和交叉概率,我们使用一个温度变量,该变量在算法运行时开始较高或“热”,然后慢慢降低或“冷”。这种加热和冷却技术直接受到冶金中退火工艺的启发。在每一代之后,温度稍微降低,这降低了突变和交叉的概率。

为了开始实现,我们需要在 GeneticAlgorithm 类中创建两个新变量。冷却速率应该设置为一个很小的分数,通常在 0.001 或更小的数量级,尽管这个数字将取决于您期望运行的代数以及您希望模拟退火有多激进。

private double temperature = 1.0;

private double coolingRate;

接下来,我们需要创建一个函数来根据冷却速率冷却温度。

/**

* Cool temperature

*/

public void coolTemperature() {

this.temperature *= (1 - this.coolingRate);

}

现在,我们可以更新变异函数,以便在决定是否应用变异时考虑温度变量。我们可以通过修改这行代码来做到这一点,

// Does this gene need mutation?

if (this.mutationRate > Math.random()) {

为了现在包括新的温度变量,

// Does this gene need mutation?

if ((this.mutationRate * this.getTempature()) > Math.random()) {

为了完成这一步,在执行类的“main”方法中更新遗传算法的循环代码,以便在每一代结束时运行 coolTemperature()函数。同样,您可能需要调整初始突变率,因为它现在将根据温度值作为最大速率。

练习

Use what you know about the simulated annealing heuristic to apply it to crossover rate.  

性能提升

除了改进搜索试探法,还有其他方法来优化遗传算法。优化遗传算法最有效的方法之一可能就是简单地编写高效的代码。当构建需要运行成千上万代的遗传算法时,只需将每代的处理时间缩短几分之一秒,就可以大大减少总体运行时间。

适应度函数设计

由于适应度函数通常是遗传算法中处理要求最高的部分,因此将代码改进集中在适应度函数上以获得最佳性能回报是有意义的。

在对适应度函数进行改进之前,最好先确保它能充分反映问题。遗传算法使用它的适应度函数来测量搜索空间的最佳区域,以集中它的搜索。这意味着设计不良的适应度函数会对遗传算法的搜索能力和整体性能产生巨大的负面影响。作为一个例子,想象一个遗传算法已经被建立来设计一个汽车面板,但是评估汽车面板的适应度函数完全是通过测量汽车的最高速度来完成的。如果面板满足一定的耐用性或人体工程学约束以及足够的空气动力学也很重要,那么这种过于简单的适合度函数可能无法提供足够的适合度值。

并行处理

现代计算机通常会配备几个独立的处理单元或“核心”。与标准单核系统不同,多核系统能够使用额外的内核同时处理多个计算。这意味着任何设计良好的应用都应该能够利用这一特性,允许其处理需求分布在可用的额外处理核心上。对于某些应用来说,这可能就像在一个内核上处理与 GUI 相关的计算,而在另一个内核上处理所有其他计算一样简单。

支持多核系统的优势是提高现代计算机性能的一种简单而有效的方法。正如我们之前讨论的,适应度函数经常会成为遗传算法的瓶颈。这使得它成为多核优化的完美候选。通过使用多个内核,可以同时计算众多个体的适应度,这在每个群体通常有数百个个体需要评估时会产生巨大的差异。

幸运的是,Java 8 提供了一些非常有用的库,使得在我们的遗传算法中支持并行处理变得更加容易。使用 IntStream,我们可以在我们的适应度函数中实现并行处理,而不用担心并行处理的精细细节(比如我们需要支持的内核数量);相反,它将根据可用内核的数量创建最佳数量的线程。

你可能想知道为什么在第五章中,遗传算法 calcFitness 方法在使用时间表对象之前克隆它。当线程化应用以进行并行处理时,需要注意确保一个线程中的对象不会影响另一个线程中的对象。在这种情况下,从一个线程对时间表对象所做的更改可能会对同时使用同一对象的其他线程产生意想不到的结果——首先克隆时间表允许我们为每个线程提供自己的对象。

我们可以通过修改 GeneticAlgorithm 的 evalPopulation 方法来使用 Java 的 IntStream,从而利用第五章的类调度器中的线程:

/**

* Evaluate population

*

* @param population

* @param timetable

*/

public void evalPopulation(Population population, Timetable timetable){

IntStream.range(0, population.size()).parallel()

.forEach(i -> this.calcFitness(population.getIndividual(i), timetable));

double populationFitness = 0;

// Loop over population evaluating individuals and suming population fitness

for (Individual individual : population.getIndividuals()) {

populationFitness += individual.getFitness();

}

population.setPopulationFitness(populationFitness);

}

现在,如果系统支持,calcFitness 函数可以跨多个内核运行。

因为本书中涉及的遗传算法使用了相当简单的适应度函数,并行处理可能不会提供太多的性能改进。测试并行处理能在多大程度上提高遗传算法性能的一个好方法可能是在适应度函数中添加对 Thread.sleep()的调用。这将模拟一个需要大量时间来完成执行的适应度函数。

适应值哈希

如前所述,适应度函数通常是遗传算法中计算量最大的部分。因此,即使是对适应度函数的微小改进也会对性能产生相当大的影响。值哈希是另一种方法,它可以通过将先前计算的适应值存储在哈希表中来减少计算适应值所花费的时间。在大型分布式系统中,您可以使用集中式缓存服务(如 Redis 或 memcached)来达到同样的目的。

在执行过程中,由于个体的随机突变和重组,以前发现的解决方案偶尔会被重新访问。随着遗传算法收敛并开始在搜索空间的越来越小的区域中寻找解决方案,这种偶尔重访解决方案变得更加常见。

每次重新访问解决方案时,都需要重新计算其适合度值,这将处理能力浪费在重复的计算上。幸运的是,这可以很容易地通过在计算后将适合度值存储在哈希表中来解决。当重新访问以前访问过的解决方案时,可以直接从哈希表中提取它的适应值,避免重新计算它。

要将适应性值散列添加到代码中,首先在 GeneticAlgorithm 类中创建适应性散列表,

// Create fitness hashtable

private Map<Individual, Double> fitnessHash = Collections.synchronizedMap(

new LinkedHashMap<Individual, Double>() {

@Override

protected boolean removeEldestEntry(Entry<Individual, Double> eldest) {

// Store a maximum of 1000 fitness values

return this.size() > 1000;

}

});

在这个例子中,在我们开始移除最老的值之前,散列表将存储最多 1000 个适合度值。这可以根据需要进行更改,以获得最佳的性能平衡。虽然更大的哈希表可以保存更多的适应值,但这是以内存使用为代价的。

现在,可以添加 get 和 put 方法来检索和存储适合度值。这可以通过更新 calcFitness 方法来完成,如下所示。请注意,我们已经从上一节中删除了 IntStream 代码,因此我们可以一次评估一个改进。

/**

* Calculate individual’s fitness value

*

* @param individual

* @param timetable

* @return fitness

*/

public double calcFitness(Individual individual, Timetable timetable){

Double storedFitness = this.fitnessHash.get(individual);

if (storedFitness != null) {

return storedFitness;

}

// Create new timetable object for thread

Timetable threadTimetable = new Timetable(timetable);

threadTimetable.createClasses(individual);

// Calculate fitness

int clashes = threadTimetable.calcClashes();

double fitness = 1 / (double) (clashes + 1);

individual.setFitness(fitness);

// Store fitness in hashtable

this.fitnessHash.put(individual, fitness);

return fitness;

}

最后,因为我们使用单个对象作为哈希表的键,所以我们需要覆盖单个类的“equals”和“hashCode”方法。这是因为我们需要根据个体的染色体生成散列,而不是根据默认情况下的对象本身。这一点很重要,因为具有相同染色体的两个独立个体应该被适应值哈希表识别为相同的。

/**

* Generates hash code based on individual’s

* chromosome

*

* @return Hash value

*/

@Override

public int hashCode() {

int hash = Arrays.hashCode(this.chromosome);

return hash;

}

/**

* Equates based on individual’s chromosome

*

* @return Equality boolean

*/

@Override

public boolean equals(Object obj) {

if (obj == null) {

return false;

}

if (getClass() != obj.getClass()) {

return false;

}

Individual individual = (Individual) obj;

return Arrays.equals(this.chromosome, individual.chromosome);

}

编码

影响遗传算法性能的另一个因素是所选择的编码。虽然从理论上讲,任何问题都可以用 0 和 1 的二进制编码来表示,但这很少是最有效的编码选择。

当遗传算法难以收敛时,通常可能是因为为问题选择了错误的编码,导致它在搜索新解时陷入困境。挑选一个好的编码没有什么难的,但是使用过于复杂的编码通常会产生不好的结果。例如,如果您想要一种可以对 0-10 之间的 10 个数字进行编码的编码,通常最好使用 10 个整数的编码,而不是二进制字符串。这样更容易应用变异和交叉函数,这些函数可以应用于单个整数,而不是表示整数值的位。这也意味着您不需要处理无效的染色体,例如代表值 15 的“1111 ”,这超出了我们要求的 0-10 范围。

变异和交叉方法

当考虑改进遗传算法性能的选项时,选择好的变异和交叉方法是另一个重要因素。要使用的最佳变异和交叉方法将主要取决于所选择的编码和问题本身的性质。一个好的变异或交叉方法应该能够产生有效的解,而且能够以预期的方式变异和交叉个体。

例如:如果我们正在优化一个接受 0-10 之间任何值的函数,一种可能的变异方法是高斯变异,它向基因添加一个随机值,稍微增加或减少其初始值。然而,另一种可能的突变方法是边界突变,其中选择上下边界之间的随机值来替换基因。这两种突变方法都能够产生有效的突变,但是根据问题的性质和实现的其他细节,一种方法可能会优于另一种方法。一个糟糕的突变方法可能只是根据原始值将值向下舍入到 0 或 10。在这种情况下,发生突变的数量取决于基因的价值,这可能导致表现不佳。初始值 1 将变为 0,这是一个相对较小的变化。然而,值 5 将变为大得多的 10。这种偏差会导致对更接近 0 和 10 的值的偏好,这通常会对遗传算法的搜索过程产生负面影响。

摘要

遗传算法可以以不同的方式进行修改,以实现显著的性能改进。在这一章中,我们看了许多不同的优化策略,以及如何将它们应用到遗传算法中。

自适应遗传算法是一种优化策略,可以提供优于标准遗传算法的性能改进。自适应遗传算法允许算法动态更新其参数,通常修改变异率或交叉率。这种参数的动态更新通常比不基于算法状态进行调整的静态定义的参数获得更好的结果。

我们在本章中考虑的另一个优化策略是多重试探法。该策略包括将遗传算法与另一种启发式算法(如模拟退火算法)相结合。通过将搜索字符与另一种启发式规则相结合,在这些特征有用的情况下,有可能实现性能改进。我们在本章中看到的模拟退火算法是基于冶金学中的退火过程。当在遗传算法中实现时,它允许最初在基因组中发生大的变化,然后逐渐减少变化量,从而允许算法专注于搜索空间中有希望的区域。

实现性能改进的最简单方法之一是优化适应度函数。适应度函数通常是计算开销最大的部分,这使得它非常适合优化。同样重要的是,健身功能是明确定义的,并提供个人实际健身的良好反映。如果适应度函数不能很好地反映个人的表现,它会减慢搜索过程,并将其引向搜索空间的不良区域。

一种优化适应度函数的简单方法是支持并行处理。通过一次处理多个适应度函数,可以大大减少遗传算法评估个体所花费的时间。

另一种可以用来减少处理适应度函数所需时间的策略是适应度值散列法。适应值散列使用散列表来存储多个最近使用的染色体的适应值。如果这些染色体再次出现在算法中,它可以召回适应值,而不是重新计算它。这可以防止对过去已经评估过的个人进行繁琐的重新处理。

最后,考虑改进遗传编码或使用不同的突变或交叉方法是否可以改进进化过程也是有效的。例如,使用不能很好地代表编码个体的编码,或者不能在基因组中产生所需多样性的突变方法,会导致算法停滞,并导致产生差的解决方案。