四、旅行推销员
介绍
在这一章中,我们将探讨旅行推销员问题以及如何用遗传算法来解决它。在这样做的时候,我们将着眼于旅行推销员问题的性质,以及我们如何使用这些性质来设计遗传算法。
旅行推销员问题(TSP)是一个经典的优化问题,早在 19 世纪就被研究过。旅行推销员问题涉及到在一组城市中寻找最有效的路线,每个城市只去一次。
旅行推销员问题通常被描述为通过一组城市优化一条路线;然而,旅行推销员问题可以应用于其他应用。例如,城市的概念可以被认为是某些应用的客户,甚至是微芯片上的焊接点。距离的概念也可以修改,以考虑其他限制,如时间。
最简单的形式是,城市可以用图上的节点来表示,每个城市之间的距离用边的长度来表示(见图 4-1 )。“路线”或“旅程”简单地定义了应该使用哪些边,以及使用的顺序。然后,可以通过对路线中使用的边求和来计算路线的分数。
图 4-1。
Our graph showing the cities and the respective distances between them
在 20 世纪,许多数学家和科学家研究了旅行推销员问题;然而,这个问题至今仍未解决。产生旅行推销员问题的最优解的唯一有保证的方法是使用强力算法。强力算法是一种被设计成系统地尝试每一种可能的解决方案的算法。然后你从候选解的完整集合中找到最优解。试图用强力算法解决旅行推销员问题是一项极其困难的任务,因为随着城市数量的增加,潜在解决方案的数量呈阶乘增长。阶乘函数比指数函数增长得更快,这就是为什么很难暴力破解旅行推销员问题的原因。例如,对于 5 个城市,有 120 个可能的解决方案(1x2x3x4x5),对于 10 个城市,该数字将增加到 3,628,800 个解决方案!到 15 个城市,有超过一万亿个解决方案。在 60 个城市,可能的解决方案比可见宇宙中的原子还多。
当只有几个城市时,强力算法可以用来寻找最优解,但随着城市数量的增加,它们变得越来越具有挑战性。即使应用技术来消除反向和相同的路由,在合理的时间内找到最佳解决方案仍然很快变得不可行。
事实上,我们知道找到一个最优的解决方案通常是不必要的,因为一个足够好的解决方案通常是所需要的。有许多不同的算法可以快速找到可能只在几个百分点内的最优解。最常用的算法之一是最近邻算法。用这种算法,一个起始城市是随机挑选的。然后,找到下一个最近的未访问城市,并将其选作路线中的第二个城市。这个挑选下一个最近的未访问城市的过程一直持续到所有城市都被访问过并且找到了完整的路线。最近邻算法已经被证明在产生合理的解决方案方面是令人惊讶地有效的,该合理的解决方案的分数在最优解决方案的分数之内。更好的是,这可以在很短的时间内完成。这些特性使它在许多情况下成为一个有吸引力的解决方案,并且是遗传算法的一个可能的替代方案。
问题
我们将在这个实现中处理的问题是一个典型的旅行推销员问题,在这个问题中,我们需要优化通过一组城市的路线。我们可以通过将每个城市设置到一个随机的 x,y 位置,在 2D 空间中生成一些随机的城市。
当寻找两个城市之间的距离时,我们将简单地使用两个城市之间最短的长度作为距离。我们可以用下面的等式来计算这个距离:
)
通常情况下,问题会比这更复杂。在这个例子中,我们假设每个城市之间存在一条直接的理想路径;这也被称为“欧几里德距离”。这通常不是典型的情况,因为可能存在各种障碍,使得实际最短路径比欧几里德距离长得多。我们还假设从城市 A 到城市 B 的旅行时间与从城市 B 到城市 A 的旅行时间一样长。同样,现实中很少会出现这种情况。往往会有单行道之类的障碍物,在某个方向行驶时会影响城市间的距离。城市之间的距离根据方向而变化的旅行推销员问题的实现被称为非对称旅行推销员问题。
履行
是时候使用我们的遗传算法知识来解决这个问题了。在为这个问题设置了一个新的 Java/Eclipse 包之后,我们将开始对路由进行编码。
开始之前
本章将基于你在第三章中开发的代码。在开始之前,创建一个新的 Eclipse 或 NetBeans 项目,或者在现有项目中为这本书创建一个名为“第四章”的新包。
从第三章的中复制个体、群体和遗传算法类,并将它们导入到第四章的中。确保更新每个类文件顶部的包名!最上面应该都写着“包章 4 ”。
打开 GeneticAlgorithm 类并删除以下方法:calcFitness、evalPopulation、crossoverPopulation 和 mutatePopulation。你将在本章的课程中重写这些方法。
接下来,打开 Individual 类,删除签名为“public Individual(int chromosomelongth)”的构造函数。单个类中有两个构造函数,所以要小心删除正确的那个!要删除的构造函数是随机初始化染色体的那个;在这一章中你也将重写它。
第三章中的 Population 类只需要修改文件顶部的包名。
编码
我们在这个例子中选择的编码需要能够按顺序对城市列表进行编码。我们可以为每个城市分配一个唯一的 ID,然后按照候选路线的顺序使用染色体来引用它。这种使用基因序列的编码被称为排列编码,非常适合旅行推销员问题。
我们需要做的第一件事是给我们的城市分配唯一的 id。如果我们要访问 5 个城市,我们可以简单地给它们分配 IDs,2,3,4,5。然后,当我们的遗传算法找到一条路线时,我们的染色体可能会将城市 id 排序如下:3,4,1,2,5。这只是意味着我们将从城市 3 开始,然后前往城市 4,然后城市 1,然后城市 2,然后城市 5,然后返回城市 3 完成路线。
初始化
在我们开始优化路线之前,我们需要创建一些城市。如前所述,我们可以通过选择随机的 x,y 坐标来生成随机的城市,并使用它们来定义一个城市位置。
首先,我们需要创建一个 City 类,它可以创建和存储一个城市,并计算到另一个城市的最短距离。
package chapter4;
public class City {
private int x;
private int y;
public City(int x, int y) {
this.x = x;
this.y = y;
}
public double distanceFrom(City city) {
// Give difference in x,y
double deltaXSq = Math.pow((city.getX() - this.getX()), 2);
double deltaYSq = Math.pow((city.getY() - this.getY()), 2);
// Calculate shortest path
double distance = Math.sqrt(Math.abs(deltaXSq + deltaYSq));
return distance;
}
public int getX() {
return this.x;
}
public int getY() {
return this.y;
}
}
City 类有一个构造函数,它采用 x 和 y 坐标在 2D 平面上创建一个城市。该类还包含一个 distanceFrom 方法,该方法使用勾股定理计算从当前城市到另一个城市的直线距离。最后,有两个 getter 方法可用于检索城市的 x 和 y 位置。
接下来,我们应该恢复在“开始之前”一节中删除的单个类构造函数。旅行推销员问题对染色体的约束与我们上两个问题不同。回想一下,机器人控制器问题中的唯一约束是染色体必须是 128 位长,并且必须是二进制的。
不幸的是,旅行推销员问题并非如此;约束更加复杂,并规定了我们可以使用的初始化、交叉和变异技术。在这种情况下,染色体必须有一定的长度(无论城市游览有多长),但一个额外的约束是每个城市必须游览一次且只能游览一次,否则染色体无效。染色体中不能有重复的基因,染色体中不能有省略的城市。
我们可以很容易地创建一个没有任何随机性的独立构造函数。简单地创建一个染色体,其中包含每个城市的索引:1、2、3、4、5、6……等等。随机排列初始染色体是读者在本章末尾的一个练习。
将下面的构造函数添加到单个类中。你可以把它放在任何你喜欢的地方,但是靠近顶部是构造函数的好位置。和往常一样,这里省略了注释和文档块,但是请参阅本书附带的 Eclipse 项目以获得更多注释。
public Individual(int chromosomeLength) {
// Create random individual
int[] individual;
individual = new int[chromosomeLength];
for (int gene = 0; gene < chromosomeLength; gene++) {
individual[gene] = gene;
}
this.chromosome = individual;
}
至此,我们可以创建我们的执行类及其“main”方法了。通过使用文件➤新➤类菜单项,在包“第四章中创建一个名为“TSP”的新 Java 类。正如在第三章中,我们将使用一些 TODOs 来剔除遗传算法伪代码,这样我们就可以通过实现来标记我们的进展。
让我们借此机会在“main”方法的顶部初始化一个由 100 个随机生成的城市对象组成的数组。只需生成随机的 x 和 y 坐标,并将它们传递给 City 构造函数。确保您的 TSP 类如下所示:
package chapter4;
public class TSP {
public static int maxGenerations = 3000;
public static void main(String[] args) {
int numCities = 100;
City cities[] = new City[numCities];
// Loop to create random cities
for (int cityIndex = 0; cityIndex < numCities; cityIndex++) {
int xPos = (int) (100 * Math.random());
int yPos = (int) (100 * Math.random());
cities[cityIndex] = new City(xPos, yPos);
}
// Initial GA
GeneticAlgorithm ga = new GeneticAlgorithm(100, 0.001, 0.9, 2, 5);
// Initialize population
Population population = ga.initPopulation(cities.length);
// TODO: Evaluate population
// Keep track of current generation
int generation = 1;
// Start evolution loop
while (ga.isTerminationConditionMet(generation, maxGenerations) == false) {
// TODO: Print fittest individual from population
// TODO: Apply crossover
// TODO: Apply mutation
// TODO: Evaluate population
// Increment the current generation
generation++;
}
// TODO: Display results
}
}
希望这个过程越来越熟悉;我们再次开始实现在第二章的开头出现的伪代码。我们还生成了一个城市对象的数组,我们将在我们的评估方法中使用,就像我们在上一章中如何生成一个迷宫对象来评估个人一样。
剩下的就是死记硬背了:初始化一个 GeneticAlgorithm 对象(包括种群大小、突变率、交叉率、精英计数和锦标赛规模),然后初始化一个种群。个人的染色体长度必须与我们希望访问的城市数量相同。
我们可以重用上一章中简单的“最大代数”终止条件,所以这次我们只剩下六个 TODOs 和一个工作循环。像往常一样,让我们从评估和健康评分方法开始。
估价
现在,我们需要评估群体,并为个体分配适合度值,这样我们就知道哪个表现最好。第一步是定义问题的适应度函数。这里,我们只需要计算出个体的染色体给出的路线的总距离。
首先,我们需要创建一个新的类来存储一条路线并计算它的总距离。在 package “chapter 4 中创建一个名为” Route "的新类,并插入以下代码:
package chapter4;
public class Route {
private City route[];
private double distance = 0;
public Route(Individual individual, City cities[]) {
// Get individual’s chromosome
int chromosome[] = individual.getChromosome();
// Create route
this.route = new City[cities.length];
for (int geneIndex = 0; geneIndex < chromosome.length; geneIndex++) {
this.route[geneIndex] = cities[chromosome[geneIndex]];
}
}
public double getDistance() {
if (this.distance > 0) {
return this.distance;
}
// Loop over cities in route and calculate route distance
double totalDistance = 0;
for (int cityIndex = 0; cityIndex + 1 < this.route.length; cityIndex++) {
totalDistance += this.route[cityIndex].distanceFrom(this.route[cityIndex + 1]);
}
totalDistance += this.route[this.route.length - 1].distanceFrom(this.route[0]);
this.distance = totalDistance;
return totalDistance;
}
}
这个类只包含一个构造函数和一个计算总路线距离的方法。构造函数接受一个个体和一个城市定义列表(与我们在 TSP 类的“main”函数中创建的城市数组相同)。然后,构造函数按照个体染色体的顺序构建一个城市对象数组;这种数据结构使得在 getDistance 方法中评估总路径距离变得简单。
getDistance 方法遍历 route 数组(城市对象的有序数组),并调用 City 类的“distanceFrom”方法依次计算两个城市之间的距离,并进行求和。
为了实现这种适应性评分方法,我们需要更新 GeneticAlgorithm 类中的 calcFitness 函数。calcFitness 类应该将距离计算委托给 Route 类,为此,它需要接受我们的城市定义数组并将其传递给 Route 类。
将下面的方法添加到 GeneticAlgorithm 类中文件的任意位置。
public double calcFitness(Individual individual, City cities[]){
// Get fitness
Route route = new Route(individual, cities);
double fitness = 1 / route.getDistance();
// Store fitness
individual.setFitness(fitness);
return fitness;
}
在此函数中,适合度的计算方法是用 1 除以总路线距离,因此距离越短得分越高。计算出适合度后,会将其存储起来,以备再次需要时快速调用。
现在,我们可以在 GeneticAlgorithm 类中更新我们的 evalPopulation 方法,以接受 cities 参数并找到群体中每个个体的适合度。
public void evalPopulation(Population population, City cities[]){
double populationFitness = 0;
// Loop over population evaluating individuals and summing population fitness
for (Individual individual : population.getIndividuals()) {
populationFitness += this.calcFitness(individual, cities);
}
double avgFitness = populationFitness / population.size();
population.setPopulationFitness(avgFitness);
}
像往常一样,这个函数在群体中循环,计算每个个体的适应度。与之前的实现不同,我们计算的是平均群体适应度,而不是总群体适应度。(因为我们使用的是锦标赛选择而不是轮盘赌选择,所以我们实际上不需要群体的适应性;如果我们不记录这个值,什么都不会改变。)
现在,我们可以解析 TSP 类中与评估和显示结果相关的“main”方法中的四个 TODOs。更新 TSP 类以表示以下内容。解决的四个 TODOs 是两个“评估群体”行(循环前和循环内),循环顶部的“打印群体中最合适的个体”行,以及循环后的“显示结果”行。
package chapter4;
public class TSP {
public static int maxGenerations = 3000;
public static void main(String[] args) {
int numCities = 100;
City cities[] = new City[numCities];
// Loop to create random cities
for (int cityIndex = 0; cityIndex < numCities; cityIndex++) {
int xPos = (int) (100 * Math.random());
int yPos = (int) (100 * Math.random());
cities[cityIndex] = new City(xPos, yPos);
}
// Initial GA
GeneticAlgorithm ga = new GeneticAlgorithm(100, 0.001, 0.9, 2, 5);
// Initialize population
Population population = ga.initPopulation(cities.length);
// Evaluate population
ga.evalPopulation(population, cities);
// Keep track of current generation
int generation = 1;
// Start evolution loop
while (ga.isTerminationConditionMet(generation, maxGenerations) == false) {
// Print fittest individual from population
Route route = new Route(population.getFittest(0), cities);
System.out.println("G"+generation+" Best distance: " + route.getDistance());
// TODO: Apply crossover
// TODO: Apply mutation
// Evaluate population
ga.evalPopulation(population, cities);
// Increment the current generation
generation++;
}
// Display results
System.out.println("Stopped after " + maxGenerations + " generations.");
Route route = new Route(population.getFittest(0), cities);
System.out.println("Best distance: " + route.getDistance());
}
}
此时,我们可以单击“Run ”,循环将执行这些动作,将相同的内容打印 3000 次,但没有显示任何变化。这当然是意料之中的;我们需要实现交叉和变异,作为我们剩下的两个目标。
终止检查
正如我们已经了解到的,除非我们尝试每一种可能的解决方案,否则没有办法知道我们是否找到了旅行推销员问题的最优解决方案。这意味着我们在这个实现中使用的终止检查不能在找到最优解时终止,因为它根本无法知道。
既然我们无法在找到最优解时终止,我们可以简单地允许算法在最终终止前运行一定数量的代,因此我们能够重用第三章的 GeneticAlgorithm 类中的 isTerminationConditionMet 方法。
但是,请注意,在这种情况下——最佳解决方案无法得知——除了简单地设置代数上限之外,还有许多复杂的终止技术。
一种常见的技术是测量随着时间的推移群体健康的改善。如果人口仍在快速增长,您可能希望允许算法继续运行。一旦种群停止改善,你就可以结束进化,提出最优解。
您可能永远也不会在像旅行推销员问题这样的复杂解决方案空间中找到全局最优解,但是存在许多强局部最优解,并且进展中的平稳状态通常表明您已经找到了这些局部最优解中的一个。
有几种方法可以测量遗传算法随时间的进展。最简单的方法是测量最佳个体没有改进的连续世代的数量。如果没有改进的代数超过了某个阈值,例如 500 代没有改进,您可以停止该算法。
这种具有大解空间的简单方法的一个缺点是,您可能会看到群体的适应度不断提高——这可能非常慢!有如此多的组合,以至于每十几代就有一个点的改善是可行的,你永远不会遇到连续 500 代都没有改善的情况。当然,你可以设置一个不考虑改进的最大代数上限。您还可以实现一种更复杂的技术,比如获取不同窗口的移动平均值,并将它们相互比较。如果适应性改善在几个窗口内一直呈下降趋势,则停止该算法。
然而,在我们的例子中,我们将坚持使用第三章中的简单方法,并让读者在本章末尾实现一个更好的终止条件作为练习。
交叉
对于旅行推销员问题,基因和基因在染色体中的顺序都非常重要。事实上,对于旅行推销员问题,我们的染色体中不应该有一个以上的特定基因副本。这是因为它会创建一个无效的解决方案,因为一个城市在一条给定的路径上不应被访问超过一次。考虑这样一种情况,我们有三个城市:城市 A、城市 B 和城市 C。A、B、C 的一条路线是有效的;然而,C,B,C 的路线不是:这条路线访问城市 C 两次,也从未访问城市 a。因此,我们必须找到并应用交叉方法,为我们的问题产生有效的结果。
在杂交过程中,我们还需要尊重父母染色体的排序。这是因为染色体的顺序会影响解决方案的适应性。事实上,重要的只是顺序。为了更好地理解为什么会出现这种情况,请考虑以下两条路线是如何完全不同的,尽管它们包含完全相同的基因:
Route 1: A,B,C,D,E
Route 2: C,A,D,B,E
我们之前看了均匀交叉;然而,统一交叉方法在单个基因的水平上起作用,并且不考虑染色体的顺序。单点和两点交叉方法做得更好,因为它们处理染色体块,这将保持这些块内的顺序。然而,单点和两点杂交的问题是,它们并不关心染色体上哪些基因被添加或删除。这意味着我们很可能最终得到无效的解决方案,其染色体包含一个以上对同一城市的引用,或者完全缺少城市。
解决这两个问题的交叉方法是有序交叉。在这种交叉方法中,选择第一个父代染色体的子集。然后将该子集添加到子染色体的相同位置。
下一步是将第二个父母的遗传信息添加到后代的染色体中。我们这样做是从所选子集的末端位置开始,然后包括来自父代 2 的每个基因,这些基因还没有出现在后代的染色体中。
在这个例子中,我们将从基因“2”开始,检查它是否能在后代的染色体中找到。因为 2 目前不在后代的染色体中,所以我们可以将它添加到后代染色体中第一个可用的位置。然后,因为我们到达了双亲 2 的染色体的末端,我们回到第一个基因,“5”。这一次,5 在后代的染色体中,所以我们跳过它,移到 1。我们一直这样做,直到得到以下结果:
这种交叉方法保留了许多来自父代的顺序,但也确保了解决方案对于旅行推销员问题等问题仍然有效。
这种算法有一个方面是我们目前的单个类无法实现的:这项技术需要检查后代的染色体是否存在特定的基因。在前面的章节中,我们没有特定的基因——我们有二元染色体——所以没有必要实现一种方法来检查染色体中基因的存在。
幸运的是,这是一个很容易添加的方法。打开单个类,在文件中的任意位置添加一个名为“containsGene”的方法:
public boolean containsGene(int gene) {
for (int i = 0; i < this.chromosome.length; i++) {
if (this.chromosome[i] == gene) {
return true;
}
}
return false;
}
这个方法查看染色体中的每个基因,如果它找到了它正在寻找的基因,它将返回 true 否则返回 false。这个方法的使用解决了这个问题:“这个解决方案访问城市#5 吗?让我们调用 individual.containsGene(5)来找出答案。”
我们现在准备通过更新 genetic algorithm 类来将我们的有序交叉方法应用于我们的遗传算法。像上一章一样,我们可以实现锦标赛选择作为我们用于交叉的选择方法,但是我们没有修改上一章的 selectParent 方法。
将此 crossoverPopulation 方法添加到 GeneticAlgorithm 类中:
public Population crossoverPopulation(Population population){
// Create new population
Population newPopulation = new Population(population.size());
// Loop over current population by fitness
for (int populationIndex = 0; populationIndex < population.size(); populationIndex++) {
// Get parent1
Individual parent1 = population.getFittest(populationIndex);
// Apply crossover to this individual?
if (this.crossoverRate > Math.random() && populationIndex >= this.elitismCount) {
// Find parent2 with tournament selection
Individual parent2 = this.selectParent(population);
// Create blank offspring chromosome
int offspringChromosome[] = new int[parent1.getChromosomeLength()];
Arrays.fill(offspringChromosome, -1);
Individual offspring = new Individual(offspringChromosome);
// Get subset of parent chromosomes
int substrPos1 = (int) (Math.random() * parent1.getChromosomeLength());
int substrPos2 = (int) (Math.random() * parent1.getChromosomeLength());
// make the smaller the start and the larger the end
final int startSubstr = Math.min(substrPos1, substrPos2);
final int endSubstr = Math.max(substrPos1, substrPos2);
// Loop and add the sub tour from parent1 to our child
for (int i = startSubstr; i < endSubstr; i++) {
offspring.setGene(i, parent1.getGene(i));
}
// Loop through parent2's city tour
for (int i = 0; i < parent2.getChromosomeLength(); i++) {
int parent2Gene = i + endSubstr;
if (parent2Gene >= parent2.getChromosomeLength()) {
parent2Gene -= parent2.getChromosomeLength();
}
// If offspring doesn’t have the city add it
if (offspring.containsGene(parent2.getGene(parent2Gene)) == false) {
// Loop to find a spare position in the child’s tour
for (int ii = 0; ii < offspring.getChromosomeLength(); ii++) {
// Spare position found, add city
if (offspring.getGene(ii) == -1) {
offspring.setGene(ii, parent2.getGene(parent2Gene));
break;
}
}
}
}
// Add child
newPopulation.setIndividual(populationIndex, offspring);
} else {
// Add individual to new population without applying crossover
newPopulation.setIndividual(populationIndex, parent1);
}
}
return newPopulation;
}
在这种方法中,我们首先创建一个新的种群来容纳后代。然后,当前种群按照最适合的个体的顺序循环。如果精英主义被启用,最初的几个精英个体被跳过,并被不加改变地添加到新群体中。然后使用交叉率考虑剩余的个体进行交叉。如果要对个体应用交叉,则使用 selectParent 方法选择一个父代(在这种情况下,selectParent 执行锦标赛选择,如第三章中的所示),并创建一个新的空白个体。
接下来,在亲代 1 的染色体中随机选取两个位置,并将这两个位置之间的遗传信息子集添加到后代的染色体中。最后,所需的剩余遗传信息按照在 parent2 中找到的顺序添加;然后当完成时,该个体被添加到新的群体中。
现在,我们可以将 crossoverPopulation 方法实现到 TSP 类中的“main”方法中,并解析我们的一个 TODOs。找到“TODO:应用交叉”并替换为:
// Apply crossover
population = ga.crossoverPopulation(population);
此时单击“运行”应该会产生一个有效的算法!经过 3,000 代之后,您应该会看到大约 1,500 的最佳距离。但是,您可能还记得,单独的交叉容易陷入局部最优,您可能会发现算法停滞不前。变异是我们在解决方案空间的新位置随机丢弃候选对象的方式,可以帮助以短期收益为代价改善长期结果。
变化
像交叉一样,我们在旅行推销员问题中使用的变异类型很重要,因为我们需要再次确保染色体在应用后是有效的。我们随机改变一个基因的单个值的方法可能会导致染色体重复,结果染色体将是无效的。
一个简单的解决方案叫做交换突变,这是一种简单交换两点遗传信息的算法。交换突变通过循环个体染色体中的基因来工作,每个基因都被认为是由突变率决定的突变。如果一个基因被选择突变,染色体中的另一个随机基因被挑选出来,然后它们的位置被交换。
这个过程确保不会产生重复的基因,并且任何产生的后代都将是有效的解决方案。
要实现这个突变方法,首先要将 mutatePopulation 方法添加到 GeneticAlgorithm 类中。
public Population mutatePopulation(Population population){
// Initialize new population
Population newPopulation = new Population(this.populationSize);
// Loop over current population by fitness
for (int populationIndex = 0; populationIndex < population.size(); populationIndex++) {
Individual individual = population.getFittest(populationIndex);
// Skip mutation if this is an elite individual
if (populationIndex >= this.elitismCount) {
// System.out.println("Mutating population member "+populationIndex);
// Loop over individual’s genes
for (int geneIndex = 0; geneIndex < individual.getChromosomeLength(); geneIndex++) {
// Does this gene need mutation?
if (this.mutationRate > Math.random()) {
// Get new gene position
int newGenePos = (int) (Math.random() * individual.getChromosomeLength());
// Get genes to swap
int gene1 = individual.getGene(newGenePos);
int gene2 = individual.getGene(geneIndex);
// Swap genes
individual.setGene(geneIndex, gene1);
individual.setGene(newGenePos, gene2);
}
}
}
// Add individual to population
newPopulation.setIndividual(populationIndex, individual);
}
// Return mutated population
return newPopulation;
}
这个方法的第一步是创建一个新的群体来容纳变异的个体。接下来,种群从最适合的个体开始循环。如果精英主义被启用,最初的几个个体被跳过并被不加改变地添加到新群体中。然后剩余个体的染色体循环,根据突变率单独考虑每个基因的突变。如果一个基因发生突变,从个体中随机挑选另一个基因,并交换这些基因。最后,变异的个体被添加到新的种群中。
现在,我们可以将突变方法添加到 TSP 类的“main”方法中,并解析我们的最终 TODO。找到注释“TODO: Apply mutation”并替换为:
// Apply mutation
population = ga.mutatePopulation(population);
执行
TSP 类的最终代码应该如下所示:
package chapter4;
public class TSP {
public static int maxGenerations = 3000;
public static void main(String[] args) {
// Create cities
int numCities = 100;
City cities[] = new City[numCities];
// Loop to create random cities
for (int cityIndex = 0; cityIndex < numCities; cityIndex++) {
// Generate x,y position
int xPos = (int) (100 * Math.random());
int yPos = (int) (100 * Math.random());
// Add city
cities[cityIndex] = new City(xPos, yPos);
}
// Initial GA
GeneticAlgorithm ga = new GeneticAlgorithm(100, 0.001, 0.9, 2, 5);
// Initialize population
Population population = ga.initPopulation(cities.length);
// Evaluate population
//ga.evalPopulation(population, cities);
Route startRoute = new Route(population.getFittest(0), cities);
System.out.println("Start Distance: " + startRoute.getDistance());
// Keep track of current generation
int generation = 1;
// Start evolution loop
while (ga.isTerminationConditionMet(generation, maxGenerations) == false) {
// Print fittest individual from population
Route route = new Route(population.getFittest(0), cities);
System.out.println("G"+generation+" Best distance: " + route.getDistance());
// Apply crossover
population = ga.crossoverPopulation(population);
// Apply mutation
population = ga.mutatePopulation(population);
// Evaluate population
ga.evalPopulation(population, cities);
// Increment the current generation
generation++;
}
System.out.println("Stopped after " + maxGenerations + " generations.");
Route route = new Route(population.getFittest(0), cities);
System.out.println("Best distance: " + route.getDistance());
}
}
此外,根据以下属性和方法签名检查 GeneticAlgorithm 类。如果您错过了实现这些方法之一,或者您的方法签名之一不匹配,请现在返回并解决该问题。
package chapter4;
import java.util.Arrays;
public class GeneticAlgorithm {
private int populationSize;
private double mutationRate;
private double crossoverRate;
private int elitismCount;
protected int tournamentSize;
public GeneticAlgorithm(int populationSize, double mutationRate, double crossoverRate, int elitismCount, int tournamentSize) { }
public Population initPopulation(int chromosomeLength){ }
public boolean isTerminationConditionMet(int generationsCount, int maxGenerations) { }
public double calcFitness(Individual individual, City cities[]) { }
public void evalPopulation(Population population, City cities[]) { }
public Individual selectParent(Population population) { }
public Population crossoverPopulation(Population population) { }
public Population mutatePopulation(Population population) { }
}
最后,确保您已经通过替换构造函数和添加 containsGene 方法更新了单个类。
此时,单击“Run”并观察输出。像往常一样,提醒自己遗传算法是由统计数据决定的随机过程,你不能仅凭一次试验就得出任何结论。不幸的是,这个问题初始化了一组随机的城市,这意味着程序的每次运行都会有不同的最优解;这使得试验遗传算法参数和判断它们的性能变得困难。本章末尾的第一个练习是硬编码一个城市列表,这样您就可以准确地对算法的性能进行基准测试。
然而,在这一点上你可以做一些有趣的观察。多次运行该算法,观察最佳距离。都差不多吗?至少在同一个球场?这很有趣,因为每次运行使用不同的城市。但仔细想想,这是有道理的:虽然这些城市每次都在不同的位置,但每次仍然有 100 个城市,它们仍然随机地放置在 100x100 的地图上,这意味着我们可以很容易地估计问题解决方案的总距离。
假设有一张 100x100 的地图,其面积为 10,000 个单位,但是您的目标不是访问 100 个城市,而是访问 10,000 个城市。如果城市被均匀地放置在地图上(每个网格点上一个),最佳解决方案应该是正好 10,100 的距离(以之字形访问地图上的每个分块)。如果不是平均分布这些城市,而是随机分布这 10,000 个城市,最佳解决方案将是以 10,000 为中心的统计分布,由于位置的随机性,每次运行都会略有不同。
现在我们可以倒推,考虑更少的城市;在地图上均匀地放置 25 个城市,最短的路线是 600 个单位。这里的关系变得很清楚:距离与地图面积的平方根乘以城市数量的平方根有关。利用这种关系,我们发现 100 个均匀放置的城市的最小距离为 1100(即);最后增加的平方根项说明了南北向的移动,但是当我们开始从统计学角度来说时,我们可以去掉这个项。如果我们在地图上随机放置同样的 100 个城市,我们可以预期最小距离是以 1000 为中心的分布。同样,1000 个城市应该有 3100 附近的最佳距离。
如果你研究一下城市的数量,你会发现,对于较小的数字,该算法很容易证实这些怀疑,但自然地,它很难找到超过 100 个城市的最小值。
既然我们已经了解了地图大小、城市数量和预期最佳距离之间的关系,我们甚至可以在没有一组固定城市的情况下进行实验,并使用我们的统计预期来代替。一个特别感兴趣的领域是突变对结果质量的影响。
如果你把解空间想象成一个有很多起伏的山丘的景观,遗传算法就像把 100 个有不同行为的人放在景观的随机位置,看哪个人找到了最低的山谷。(在这种情况下,因为我们的个体构造函数不是随机的,所以我们实际上是在同一个点上删除所有个体。)经过几代人,个体和他们的后代会向山下移动,然后当他们找到附近最低的山谷时停下来。然而,突变将它们捡起来,放入一个新的随机位置——这个位置可能比前一个更好或更差,但至少这是一个新的、独特的位置,允许它们在一个新的环境中继续搜索。
然而,突变通常会有短期的危害。突变可能是有利的,也可能是不利的——这就是为什么我们使用精英主义来保护最优秀的个体免受突变的影响。然而,突变引入的多样性可能会产生深远的长期影响,因为它将一个人置于一个否则可能无法探索的景观中:想象一座中间有一个巨大裂缝的高大火山,其中包含景观中的最低点(全球最佳状态,周围是不利的景观)。任何群体都不太可能爬上火山并在中心找到全局最优解——除非随机突变将一个个体置于火山边缘。
话虽如此,但观察不同变异率和精英主义计数对长期运行(数万代)难题(200 个城市或更多)的影响。突变是有益还是有害?精英主义是有益还是有害?
摘要
旅行商问题是一个经典的优化问题,它问:在一列城市之间,访问每个城市一次,然后返回初始城市的最短可能路线是什么?
这是一个未解决的优化问题,其中只有使用强力算法才能找到最优解。然而,由于旅行推销员问题的可能解决方案的数量随着每个城市的增加而快速增长,即使使用最强大的计算机,暴力解决方案也很快变得不可行。对于这些情况,启发式方法被用来寻找一个好的近似。
我们介绍了旅行推销员问题的一个基本实现,使用 2D 图上的城市并用直线距离将它们连接起来。
使用城市 id 的有序列表作为染色体编码,我们能够表示旅行推销员问题的解决方案。但是,因为每个城市 ID 必须在编码中至少出现一次,而且只能出现一次,所以我们研究了两种新的交叉和变异方法,它们可以保持这种约束:有序交叉和交换变异。
在有序交叉中,父代 1 染色体的随机子集被添加到后代中,然后所需的剩余遗传信息按照在父代 2 染色体中找到的顺序被添加到后代中。这种添加 parent1 的遗传信息子集,然后只添加 parent2 中缺失的剩余遗传信息的方法保证了每个城市 ID 在解决方案中只出现一次。
在交换突变中,选择两个基因并交换它们的位置。同样,这种变异方法保证了旅行推销员问题的有效解决方案,因为它不允许完全删除城市 id,也不会导致城市出现两次。
练习
Hard-code cities into the TSP class “main” method so that you can accurately take benchmarks of performance. Add support for both, shortest route and quickest route using user defined distances and times between cities. Add support for an asymmetric TSP (the cost of traveling from A to B may not equal the cost from traveling from B to A). Modify the Individual class constructor to randomize the order of cities. How does this affect the performance of the algorithm? Update the termination condition to measure the algorithm’s progress and quit when no significant progress is being made. How does this affect the algorithm’s performance and results?
五、课程表
介绍
在这一章中,我们将创建一个遗传算法来为大学课程表安排课程。我们将考察几个不同的场景,在这些场景中可能会用到排课算法,以及在设计课程表时通常会用到的约束条件。最后,我们将构建一个简单的类调度器,它可以扩展以支持更复杂的实现。
在人工智能中,排课问题是约束满足问题的一个变种。这类问题与问题有关,这些问题有一组变量,需要以避免违反一组已定义的约束的方式进行分配。
约束分为两类:硬约束——产生功能解决方案需要满足的约束,以及软约束——首选但不以牺牲硬约束为代价的约束。
例如,当制造新产品时,产品的功能需求是硬约束,并指定重要的性能需求。没有这些约束,你就没有产品。不能打电话的电话根本算不上电话!然而,你也可能有软约束,虽然不是必需的,但考虑起来仍然很重要,比如产品的成本、重量或美观。
当创建一个排课算法时,通常会有许多硬约束和软约束需要考虑。排课问题的一些典型的硬约束是:
- 教授在任何时候都只能在一个班级
- 教室需要足够大以容纳整个班级
- 教室在任何给定时间只能容纳一个班级
- 教室必须包含任何必需的设备
一些典型的软约束可能是:
- 教室容量应适合班级规模
- 教授的首选教室
- 教授的首选上课时间
有时,多个软约束可能会冲突,需要在它们之间找到一个折衷方案。例如,一个班级可能只有 10 名学生,因此软约束可以奖励分配一个合适的教室,其容量约为 10 人;然而,上课的教授可能更喜欢能容纳 30 名学生的大教室。如果教授偏好被认为是软约束,那么这些配置中的一个将是优选的,并且有希望被课程调度器找到。
在更高级的实现中,还可以对软约束进行加权,以便算法了解哪些软约束是最需要考虑的。
像旅行推销员问题一样,迭代方法可以用来寻找班级调度问题的最优解;然而,随着类别配置数量的增加,找到最佳解决方案变得越来越困难。在这些情况下,当类别配置的可能数量超过通过迭代方法解决的可行数量时,遗传算法是很好的替代方法。虽然他们不能保证找到最优解,但他们非常擅长在合理的时间内找到接近最优的解。
问题
在这一章中,我们将讨论的排课问题是一个大学排课器,它可以根据我们提供的数据创建一个大学课程表,比如可用的教授、可用的教室、时间段和学生群体。
我们应该注意,建立大学时间表与建立小学时间表略有不同。小学的时间表要求他们的学生有一个全天的完整时间表,没有空闲时间。相反,典型的大学时间表通常会有自由时间,这取决于学生注册了多少个模块。
每堂课都将被安排一个时间段,一个教授,一个教室和一个学生小组。我们可以通过将学生组的数量乘以每个学生组注册的模块数量相加来计算需要安排的班级总数。
对于我们的应用安排的每个类,我们将考虑以下硬约束:
- 只能安排在免费教室上课
- 一个教授在任何时候只能教一门课
- 教室必须足够大,以容纳学生群体
为了在这个实现中保持简单,我们现在只考虑硬约束;然而,取决于时间表规范,通常会有更多的硬约束。规范中还可能包含许多软约束,现在我们将忽略它们。虽然没有必要,但考虑软约束通常会对遗传算法生成的时间表的质量产生很大影响。
履行
是时候使用我们的遗传算法知识来解决这个问题了。在为这个问题建立了一个新的 Java/Eclipse 包之后,我们将从编码染色体开始。
开始之前
本章将建立在您在前面所有章节中开发的代码的基础上——因此,密切关注这一部分尤其重要!
在开始之前,创建一个新的 Eclipse 或 NetBeans 项目,或者在现有项目中为这本书创建一个名为“chapter 5 ”的新包。
从第四章复制个体、群体和遗传算法类,并将它们导入第五章。确保更新每个类文件顶部的包名!最上面应该都写着“包章 5 ”。
打开 GeneticAlgorithm 类并进行以下更改:
- 删除 selectParent 方法,并用第三章(锦标赛选择)中的 selectParent 方法替换它
- 删除交叉过剩法,并将其替换为第二章中的交叉过剩法(均匀交叉)
- 删除 initPopulation、mutatePopulation、evalPopulation 和 calcPopulation 方法——您将在本章中重新实现它们
Population 和 Individual 类现在可以不考虑,但是请记住,在本章的后面,您将为每个文件添加一个新的构造函数。
编码
我们在课程安排应用中使用的编码需要能够有效地编码我们需要的所有课程属性。对于这个实现,它们是:课程安排的时间段、教授上课的教授和课程的教室。
我们可以简单地给每个时间段、教授和教室分配一个数字 ID。然后我们可以使用编码整数数组的染色体——我们熟悉的方法。这意味着每个需要调度的类只需要三个整数来编码,如下所示:
通过将这个数组分成三个块,我们可以检索每个类所需的所有信息。
初始化
现在我们已经了解了我们的问题,以及我们将如何对染色体进行编码,我们可以开始实现了。首先,我们需要为我们的调度程序创建一些数据:特别是我们试图围绕其建立时间表的教室、教授、时间段、模块和学生团体。
通常这些数据来自包含完整课程模块和学生数据的数据库。然而,为了实现这个目的,我们将创建一些硬编码的伪数据来使用。
让我们首先设置我们的支持 Java 类。我们将为上面的每种数据类型(房间、班级、小组、教授、模块和时间段)创建一个容器类。虽然每个容器类都非常简单,但它们大多定义了一些类属性、getters 和 setters,没有真正的逻辑。我们将在这里依次打印它们。
首先,创建一个存储教室信息的 Room 类。和往常一样,如果使用 Eclipse,您可以使用文件➤新➤类菜单选项创建这个类。
package chapter``5
public class Room {
private final int roomId;
private final String roomNumber;
private final int capacity;
public Room(int roomId, String roomNumber, int capacity) {
this.roomId = roomId;
this.roomNumber = roomNumber;
this.capacity = capacity;
}
public int getRoomId() {
return this.roomId;
}
public String getRoomNumber() {
return this.roomNumber;
}
public int getRoomCapacity() {
return this.capacity;
}
}
这个类包含一个构造函数,它接受房间 ID、房间号和房间容量。它还提供了获取房间属性的方法。
接下来,创建一个时隙类;该时间段代表一周中上课的日期和时间。
package chapter``5
public class Timeslot {
private final int timeslotId;
private final String timeslot;
public Timeslot(int timeslotId, String timeslot){
this.timeslotId = timeslotId;
this.timeslot = timeslot;
}
public int getTimeslotId(){
return this.timeslotId;
}
public String getTimeslot(){
return this.timeslot;
}
}
可以使用构造函数创建一个时隙,并将时隙 ID 和时隙细节作为一个字符串传递给它(细节可能看起来像“Mon 9:00–10:00”)。该类还包含获取对象属性的 getters。
第三个要设立的班级是教授班:
package chapter``5
public class Professor {
private final int professorId;
private final String professorName;
public Professor(int professorId, String professorName){
this.professorId = professorId;
this.professorName = professorName;
}
public int getProfessorId(){
return this.professorId;
}
public String getProfessorName(){
return this.professorName;
}
}
Professor 类包含一个接受教授 ID 和教授姓名的构造函数;它还包含获取教授属性的 getter 方法。
接下来,添加一个模块类来存储关于课程模块的信息。“模块”是一些人所谓的“课程”,如“微积分 101”或“美国历史 302”,像现实生活中的课程一样,可以有多个部分和学生群体在一周的不同时间与不同的教授一起学习课程。
package chapter``5
public class Module {
private final int moduleId;
private final String moduleCode;
private final String module;
private final int professorIds[];
public Module(int moduleId, String moduleCode, String module, int professorIds[]){
this.moduleId = moduleId;
this.moduleCode = moduleCode;
this.module = module;
this.professorIds = professorIds;
}
public int getModuleId(){
return this.moduleId;
}
public String getModuleCode(){
return this.moduleCode;
}
public String getModuleName(){
return this.module;
}
public int getRandomProfessorId(){
int professorId = professorIds[(int) (professorIds.length * Math.random())];
return professorId;
}
}
这个模块类包含一个构造函数,它接受模块 ID(数字)、模块代码(类似于“CS101”或“Hist302”)、模块名称和教授 ID 数组,教授 ID 数组可以教授模块。module 类还提供了 getter 方法——以及一个选择随机教授 ID 的方法。
下一个需要的类是 Group class 类,它保存关于学生组的信息。
package chapter``5
public class Group {
private final int groupId;
private final int groupSize;
private final int moduleIds[];
public Group(int groupId, int groupSize, int moduleIds[]){
this.groupId = groupId;
this.groupSize = groupSize;
this.moduleIds = moduleIds;
}
public int getGroupId(){
return this.groupId;
}
public int getGroupSize(){
return this.groupSize;
}
public int[] getModuleIds(){
return this.moduleIds;
}
}
group 类构造函数接受组 ID、组大小和组采用的模块 ID。它还提供了获取组信息的 getter 方法。
接下来,添加一个“Class”类。可以理解的是,本章中的术语可能会令人困惑——因此,大写的“class”指的是您将要创建的这个 Java 类,而我们将使用小写的“Class”来指代任何其他 Java 类。
Class 代表了以上所有的组合。它代表一个学生小组在特定的时间、特定的教室和特定的教授一起学习某个模块的某个部分。
package chapter``5
public class Class {
private final int classId;
private final int groupId;
private final int moduleId;
private int professorId;
private int timeslotId;
private int roomId;
public Class(int classId, int groupId, int moduleId) {
this.classId = classId;
this.moduleId = moduleId;
this.groupId = groupId;
}
public void addProfessor(int professorId) {
this.professorId = professorId;
}
public void addTimeslot(int timeslotId) {
this.timeslotId = timeslotId;
}
public void setRoomId(int roomId) {
this.roomId = roomId;
}
public int getClassId() {
return this.classId;
}
public int getGroupId() {
return this.groupId;
}
public int getModuleId() {
return this.moduleId;
}
public int getProfessorId() {
return this.professorId;
}
public int getTimeslotId() {
return this.timeslotId;
}
public int getRoomId() {
return this.roomId;
}
}
现在我们可以创建一个时间表类,将所有这些对象封装成一个时间表对象。到目前为止,时间表类是最重要的类,因为它是唯一理解不同约束应该如何相互作用的类。
Timetable 类还理解如何解析染色体,并创建一个候选时间表进行评估和评分。
package chapter``5
import java.util.HashMap;
public class Timetable {
private final HashMap<Integer, Room> rooms;
private final HashMap<Integer, Professor> professors;
private final HashMap<Integer, Module> modules;
private final HashMap<Integer, Group> groups;
private final HashMap<Integer, Timeslot> timeslots;
private Class classes[];
private int numClasses = 0;
/**
* Initialize new Timetable
*
*/
public Timetable() {
this.rooms = new HashMap<Integer, Room>();
this.professors = new HashMap<Integer, Professor>();
this.modules = new HashMap<Integer, Module>();
this.groups = new HashMap<Integer, Group>();
this.timeslots = new HashMap<Integer, Timeslot>();
}
public Timetable(Timetable cloneable) {
this.rooms = cloneable.getRooms();
this.professors = cloneable.getProfessors();
this.modules = cloneable.getModules();
this.groups = cloneable.getGroups();
this.timeslots = cloneable.getTimeslots();
}
private HashMap<Integer, Group> getGroups() {
return this.groups;
}
private HashMap<Integer, Timeslot> getTimeslots() {
return this.timeslots;
}
private HashMap<Integer, Module> getModules() {
return this.modules;
}
private HashMap<Integer, Professor> getProfessors() {
return this.professors;
}
/**
* Add new room
*
* @param roomId
* @param roomName
* @param capacity
*/
public void addRoom(int roomId, String roomName, int capacity) {
this.rooms.put(roomId, new Room(roomId, roomName, capacity));
}
/**
* Add new professor
*
* @param professorId
* @param professorName
*/
public void addProfessor(int professorId, String professorName) {
this.professors.put(professorId, new Professor(professorId, professorName));
}
/**
* Add new module
*
* @param moduleId
* @param moduleCode
* @param module
* @param professorIds
*/
public void addModule(int moduleId, String moduleCode, String module, int professorIds[]) {
this.modules.put(moduleId, new Module(moduleId, moduleCode, module, professorIds));
}
/**
* Add new group
*
* @param groupId
* @param groupSize
* @param moduleIds
*/
public void addGroup(int groupId, int groupSize, int moduleIds[]) {
this.groups.put(groupId, new Group(groupId, groupSize, moduleIds));
this.numClasses = 0;
}
/**
* Add new timeslot
*
* @param timeslotId
* @param timeslot
*/
public void addTimeslot(int timeslotId, String timeslot) {
this.timeslots.put(timeslotId, new Timeslot(timeslotId, timeslot));
}
/**
* Create classes using individual’s chromosome
*
* @param individual
*/
public void createClasses(Individual individual) {
// Init classes
Class classes[] = new Class[this.getNumClasses()];
// Get individual’s chromosome
int chromosome[] = individual.getChromosome();
int chromosomePos = 0;
int classIndex = 0;
for (Group group : this.getGroupsAsArray()) {
int moduleIds[] = group.getModuleIds();
for (int moduleId : moduleIds) {
classes[classIndex] = new Class(classIndex, group.getGroupId(), moduleId);
// Add timeslot
classes[classIndex].addTimeslot(chromosome[chromosomePos]);
chromosomePos++;
// Add room
classes[classIndex].setRoomId(chromosome[chromosomePos]);
chromosomePos++;
// Add professor
classes[classIndex].addProfessor(chromosome[chromosomePos]);
chromosomePos++;
classIndex++;
}
}
this.classes = classes;
}
/**
* Get room from roomId
*
* @param roomId
* @return room
*/
public Room getRoom(int roomId) {
if (!this.rooms.containsKey(roomId)) {
System.out.println("Rooms doesn’t contain key " + roomId);
}
return (Room) this.rooms.get(roomId);
}
public HashMap<Integer, Room> getRooms() {
return this.rooms;
}
/**
* Get random room
*
* @return room
*/
public Room getRandomRoom() {
Object[] roomsArray = this.rooms.values().toArray();
Room room = (Room) roomsArray[(int) (roomsArray.length * Math.random())];
return room;
}
/**
* Get professor from professorId
*
* @param professorId
* @return professor
*/
public Professor getProfessor(int professorId) {
return (Professor) this.professors.get(professorId);
}
/**
* Get module from moduleId
*
* @param moduleId
* @return module
*/
public Module getModule(int moduleId) {
return (Module) this.modules.get(moduleId);
}
/**
* Get moduleIds of student group
*
* @param groupId
* @return moduleId array
*/
public int[] getGroupModules(int groupId) {
Group group = (Group) this.groups.get(groupId);
return group.getModuleIds();
}
/**
* Get group from groupId
*
* @param groupId
* @return group
*/
public Group getGroup(int groupId) {
return (Group) this.groups.get(groupId);
}
/**
* Get all student groups
*
* @return array of groups
*/
public Group[] getGroupsAsArray() {
return (Group[]) this.groups.values().toArray(new Group[this.groups.size()]);
}
/**
* Get timeslot by timeslotId
*
* @param timeslotId
* @return timeslot
*/
public Timeslot getTimeslot(int timeslotId) {
return (Timeslot) this.timeslots.get(timeslotId);
}
/**
* Get random timeslotId
*
* @return timeslot
*/
public Timeslot getRandomTimeslot() {
Object[] timeslotArray = this.timeslots.values().toArray();
Timeslot timeslot = (Timeslot) timeslotArray[(int) (timeslotArray.length * Math.random())];
return timeslot;
}
/**
* Get classes
*
* @return classes
*/
public Class[] getClasses() {
return this.classes;
}
/**
* Get number of classes that need scheduling
*
* @return numClasses
*/
public int getNumClasses() {
if (this.numClasses > 0) {
return this.numClasses;
}
int numClasses = 0;
Group groups[] = (Group[]) this.groups.values().toArray(new Group[this.groups.size()]);
for (Group group : groups) {
numClasses += group.getModuleIds().length;
}
this.numClasses = numClasses;
return this.numClasses;
}
/**
* Calculate the number of clashes
*
* @return numClashes
*/
public int calcClashes() {
int clashes = 0;
for (Class classA : this.classes) {
// Check room capacity
int roomCapacity = this.getRoom(classA.getRoomId()).getRoomCapacity();
int groupSize = this.getGroup(classA.getGroupId()).getGroupSize();
if (roomCapacity < groupSize) {
clashes++;
}
// Check if room is taken
for (Class classB : this.classes) {
if (classA.getRoomId() == classB.getRoomId() && classA.getTimeslotId() == classB.getTimeslotId()
&& classA.getClassId() != classB.getClassId()) {
clashes++;
break;
}
}
// Check if professor is available
for (Class classB : this.classes) {
if (classA.getProfessorId() == classB.getProfessorId() && classA.getTimeslotId() == classB.getTimeslotId()
&& classA.getClassId() != classB.getClassId()) {
clashes++;
break;
}
}
}
return clashes;
}
}
这个类包含了在时间表中添加房间、时间段、教授、模块和组的方法。以这种方式,时间表类服务于双重目的:时间表对象知道所有可用的房间、时间段、教授等。,但是时间表对象也可以读取染色体,从该染色体创建类的子集,并帮助评估染色体的适合度。
请密切注意该类中的两个重要方法:createClasses 和 calcClashes。
createClasses 方法接受一个个体(即一个染色体),并利用它对必须安排的学生组和模块总数的了解,为这些组和模块创建许多类对象。然后,该方法开始读取染色体,并将可变信息(时间段、房间和教授)分配给这些类别中的每一个。因此,createClasses 方法确保每个模块和学生小组都被考虑在内,但它使用遗传算法和结果染色体来尝试不同的时间段、房间和教授组合。Timetable 类在本地缓存这些信息(作为“this.classes”)供以后使用。
一旦构建了类,calcClashes 方法依次检查每个类,并计算“冲突”的数量。在这种情况下,“冲突”是任何硬约束违反,例如教室太小的班级、与教室和时间段的冲突,或者与教授和时间段的冲突。遗传算法的计算方法稍后会使用冲突数。
高管阶层
我们现在可以创建一个包含程序的“main”方法的执行类。和前面的章节一样,我们将基于第二章中的伪代码构建这个类,用一些“TODO”注释代替我们将在本章中填写的实现细节。
首先,创建一个新的 Java 类,并将其命名为“TimetableGA”。确保它在“package chapter 5 中,并向其中添加以下代码:
package chapter``5
public class TimetableGA {
public static void main(String[] args) {
// TODO: Create Timetable and initialize with all the available courses, rooms, timeslots, professors, modules, and groups
// Initialize GA
GeneticAlgorithm ga = new GeneticAlgorithm(100, 0.01, 0.9, 2, 5);
// TODO: Initialize population
// TODO: Evaluate population
// Keep track of current generation
int generation = 1;
// Start evolution loop
// TODO: Add termination condition
while (false) {
// Print fitness
System.out.println("G" + generation + " Best fitness: " + population.getFittest(0).getFitness());
// Apply crossover
population = ga.crossoverPopulation(population);
// TODO: Apply mutation
// TODO: Evaluate population
// Increment the current generation
generation++;
}
// TODO: Print final fitness
// TODO: Print final timetable
}
}
为了完成这一章,我们给了自己八个待办事项。请注意,交叉不是一个待办事项,我们将重复使用第三章中的锦标赛选择和第二章中的统一交叉。
第一个 TODO 很容易解决,我们现在就做。一般来说,学校课程表的信息来自数据库,但是现在让我们对一些班级和教授进行硬编码。由于下面的代码有点长,让我们在 TimetableGA 类中为它创建一个单独的方法。将此方法添加到您喜欢的任何位置:
private static Timetable initializeTimetable() {
// Create timetable
Timetable timetable = new Timetable();
// Set up rooms
timetable.addRoom(1, "A1", 15);
timetable.addRoom(2, "B1", 30);
timetable.addRoom(4, "D1", 20);
timetable.addRoom(5, "F1", 25);
// Set up timeslots
timetable.addTimeslot(1, "Mon 9:00 - 11:00");
timetable.addTimeslot(2, "Mon 11:00 - 13:00");
timetable.addTimeslot(3, "Mon 13:00 - 15:00");
timetable.addTimeslot(4, "Tue 9:00 - 11:00");
timetable.addTimeslot(5, "Tue 11:00 - 13:00");
timetable.addTimeslot(6, "Tue 13:00 - 15:00");
timetable.addTimeslot(7, "Wed 9:00 - 11:00");
timetable.addTimeslot(8, "Wed 11:00 - 13:00");
timetable.addTimeslot(9, "Wed 13:00 - 15:00");
timetable.addTimeslot(10, "Thu 9:00 - 11:00");
timetable.addTimeslot(11, "Thu 11:00 - 13:00");
timetable.addTimeslot(12, "Thu 13:00 - 15:00");
timetable.addTimeslot(13, "Fri 9:00 - 11:00");
timetable.addTimeslot(14, "Fri 11:00 - 13:00");
timetable.addTimeslot(15, "Fri 13:00 - 15:00");
// Set up professors
timetable.addProfessor(1, "Dr P Smith");
timetable.addProfessor(2, "Mrs E Mitchell");
timetable.addProfessor(3, "Dr R Williams");
timetable.addProfessor(4, "Mr A Thompson");
// Set up modules and define the professors that teach them
timetable.addModule(1, "cs1", "Computer Science", new int[] { 1, 2 });
timetable.addModule(2, "en1", "English", new int[] { 1, 3 });
timetable.addModule(3, "ma1", "Maths", new int[] { 1, 2 });
timetable.addModule(4, "ph1", "Physics", new int[] { 3, 4 });
timetable.addModule(5, "hi1", "History", new int[] { 4 });
timetable.addModule(6, "dr1", "Drama", new int[] { 1, 4 });
// Set up student groups and the modules they take.
timetable.addGroup(1, 10, new int[] { 1, 3, 4 });
timetable.addGroup(2, 30, new int[] { 2, 3, 5, 6 });
timetable.addGroup(3, 18, new int[] { 3, 4, 5 });
timetable.addGroup(4, 25, new int[] { 1, 4 });
timetable.addGroup(5, 20, new int[] { 2, 3, 5 });
timetable.addGroup(6, 22, new int[] { 1, 4, 5 });
timetable.addGroup(7, 16, new int[] { 1, 3 });
timetable.addGroup(8, 18, new int[] { 2, 6 });
timetable.addGroup(9, 24, new int[] { 1, 6 });
timetable.addGroup(10, 25, new int[] { 3, 4 });
return timetable;
}
现在,将 main 方法顶部的第一个 TODO 替换为以下内容:
// Get a Timetable object with all the available information.
Timetable timetable = initializeTimetable();
main 方法的顶部现在应该看起来像这样:
public class TimetableGA {
public static void main(String[] args) {
// Get a Timetable object with all the available information.
Timetable timetable = initializeTimetable();
// Initialize GA ... (and the rest of the class, unchanged from before!)
GeneticAlgorithm ga = new GeneticAlgorithm(100, 0.01, 0.9, 2, 5);
这为我们提供了一个包含所有必要信息的时间表实例,我们创建的 GeneticAlgorithm 对象类似于前面章节中的对象:一个人口为 100 的遗传算法,变异率为 0.01,交叉率为 0.9,2 个精英个体,锦标赛规模为 5。
我们现在还有七个 TODOs。下一个待办事项与初始化填充相关。为了创造一个群体,我们需要知道我们需要的染色体的长度;这是由时间表中小组和模块的数量决定的。
我们需要能够从一个时间表对象初始化一个群体,这意味着我们也需要能够从一个时间表对象初始化一个个体。因此,为了解决这个 TODO,我们必须做三件事:向 GeneticAlgorithm 类添加 initPopulation(Timetable)方法,向接受时间表的群体添加构造函数,向接受时间表的个体添加构造函数。
让我们从底层开始,一步一步往上走。通过添加新的构造函数来更新单个类,该构造函数根据时间表构建单个类。构造函数使用时间表对象来确定必须安排的课程数量,这决定了染色体的长度。染色体本身是通过从时间表中随机抽取房间、时间段和教授来构建的。
将下面的方法添加到单个类中的任意位置:
public Individual(Timetable timetable) {
int numClasses = timetable.getNumClasses();
// 1 gene for room, 1 for time, 1 for professor
int chromosomeLength = numClasses * 3;
// Create random individual
int newChromosome[] = new int[chromosomeLength];
int chromosomeIndex = 0;
// Loop through groups
for (Group group : timetable.getGroupsAsArray()) {
// Loop through modules
for (int moduleId : group.getModuleIds()) {
// Add random time
int timeslotId = timetable.getRandomTimeslot().getTimeslotId();
newChromosome[chromosomeIndex] = timeslotId;
chromosomeIndex++;
// Add random room
int roomId = timetable.getRandomRoom().getRoomId();
newChromosome[chromosomeIndex] = roomId;
chromosomeIndex++;
// Add random professor
Module module = timetable.getModule(moduleId);
newChromosome[chromosomeIndex] = module.getRandomProfessorId();
chromosomeIndex++;
}
}
this.chromosome = newChromosome;
}
这个构造函数接受一个时间表对象,并遍历每个学生组和该组注册的每个模块(给出需要安排的班级总数)。对于每个班级,随机选择一个教室、教授和时间段,并将相应的 ID 添加到染色体中。
接下来,将这个构造函数方法添加到 Population 类中。这个构造函数通过简单地调用我们刚刚创建的个体构造函数,从用时间表初始化的个体中构建一个群体。
public Population(int populationSize, Timetable timetable) {
// Initial population
this.population = new Individual[populationSize];
// Loop over population size
for (int individualCount = 0; individualCount < populationSize; individualCount++) {
// Create individual
Individual individual = new Individual(timetable);
// Add individual to population
this.population[individualCount] = individual;
}
}
接下来,在 GeneticAlgorithm 类中重新实现 initPopulation 方法,以使用新的 Population 构造函数:
public Population initPopulation(Timetable timetable) {
// Initialize population
Population population = new Population(this.populationSize, timetable);
return population;
}
我们最终可以解析下一个 TODO:替换 executive 类的 main 方法中的“TODO: Initialize Population ”,并调用 GeneticAlgorithm 的 initPopulation 方法:
// Initialize population
Population population = ga.initPopulation(timetable);
executive TimetableGA 类的主要方法现在应该如下所示。由于我们还没有实现终止条件,这段代码还不会做任何有趣的事情,事实上 Java 编译器可能会抱怨循环内无法到达的代码。我们会尽快解决这个问题。
public static void main(String[] args) {
// Get a Timetable object with all the available information.
Timetable timetable = initializeTimetable();
// Initialize GA
GeneticAlgorithm ga = new GeneticAlgorithm(100, 0.01, 0.9, 2, 5);
// Initialize population
Population population = ga.initPopulation(timetable);
// TODO: Evaluate population
// Keep track of current generation
int generation = 1;
// Start evolution loop
// TODO: Add termination condition
while (false) {
// Print fitness
System.out.println("G" + generation + " Best fitness: " + population.getFittest(0).getFitness());
// Apply crossover
population = ga.crossoverPopulation(population);
// TODO: Apply mutation
// TODO: Evaluate population
// Increment the current generation
generation++;
}
// TODO: Print final fitness
// TODO: Print final timetable
}
估价
我们的初始种群已经创建,我们需要评估这些个体,并为它们分配适合度值。从前面我们知道,我们的目标是优化我们的课表,以避免打破尽可能多的限制。这意味着个人的适应值将与违反多少约束成反比。
打开并检查时间表类的“createClasses”方法。利用它对需要在特定时间安排到有教授的教室的所有组和模块的了解,它将一个染色体转换成一组类对象,并将它们藏起来以供评估。这个方法不做任何实际的评估,但是它是一个染色体和评估步骤之间的桥梁。
接下来,检查同一个类中的“calcClashes”方法。这种方法将每个班级与其他班级进行比较,如果违反了任何硬约束,例如:如果选定的房间太小,如果该房间的时间安排有冲突,或者如果教授的时间安排有冲突,则添加一个“冲突”。该方法返回它找到的冲突总数。
现在,我们已经准备好创建我们的适应度函数,并最终评估人口中个体的适应度。
打开 GeneticAlgorithm 类,首先添加以下 calcFitness 方法。
public double calcFitness(Individual individual, Timetable timetable) {
// Create new timetable object to use -- cloned from an existing timetable
Timetable threadTimetable = new Timetable(timetable);
threadTimetable.createClasses(individual);
// Calculate fitness
int clashes = threadTimetable.calcClashes();
double fitness = 1 / (double) (clashes + 1);
individual.setFitness(fitness);
return fitness;
}
calcFitness 方法克隆给它的时间表对象,调用 createClasses 方法,然后通过 calcClashes 方法计算冲突的数量。适应度定义为碰撞数的倒数-0 碰撞将导致适应度为 1。
也向 GeneticAlgorithm 类添加一个 evalPopulation 方法。和前面的章节一样,这个方法简单地遍历所有的样本,并为每个样本调用 calcFitness。
public void evalPopulation(Population population, Timetable timetable) {
double populationFitness = 0;
// Loop over population evaluating individuals and summing population
// fitness
for (Individual individual : population.getIndividuals()) {
populationFitness += this.calcFitness(individual, timetable);
}
population.setPopulationFitness(populationFitness);
}
最后,我们可以在 executive TimetableGA 类的 main 方法中评估总体并解决一些 TODOs。更新具有“TODO:评估人口”的两个位置,改为显示:
// Evaluate population
ga.evalPopulation(population, timetable);
此时,应该还有四个 TODOs。此时程序仍然不可运行,因为终止条件尚未定义,循环尚未启用。
结束
构建类调度器的下一步是设置终止检查。以前,我们使用世代数和适应度来决定是否要终止我们的遗传算法。这一次,我们将结合这两个终止条件,或者在经过一定数量的代之后,或者如果它找到了有效的解决方案,就终止我们的遗传算法。
因为适应值是基于破坏的约束的数量,所以我们知道完美的解决方案将具有 1 的适应值。保持前面的终止检查不变,并将第二个终止检查添加到 GeneticAlgorithm 类中。我们将在执行循环中使用这两种检查。
public boolean isTerminationConditionMet(Population population) {
return population.getFittest(0).getFitness() == 1.0;
}
此时,确认第二个 isTerminationConditionMet 方法(应该已经在 GeneticAlgorithm 类中)如下所示:
public boolean isTerminationConditionMet(int generationsCount, int maxGenerations) {
return (generationsCount > maxGenerations);
}
现在,我们可以将两个终止检查添加到我们的 main 方法中,并启用演化循环。打开 executive TimetableGA 类,并按如下方式解决“TODO:Add termination condition”TODO:
// Start evolution loop
while (ga.isTerminationConditionMet(generation, 1000) == false
&& ga.isTerminationConditionMet(population) == false) {
// Rest of the loop in here...
第一个 isTerminationConditionMet 调用将我们限制在 1,000 代,而第二个调用检查在群体中是否有任何适合度为 1 的个体。
让我们快速解决另外两个 TODOs。当循环结束时,我们有一些简单的报告要呈现。删除循环后的两个 TODOs(“打印最终健身”和“打印最终时间表”),替换为以下内容:
// Print fitness
timetable.createClasses(population.getFittest(0));
System.out.println();
System.out.println("Solution found in " + generation + " generations");
System.out.println("Final solution fitness: " + population.getFittest(0).getFitness());
System.out.println("Clashes: " + timetable.calcClashes());
// Print classes
System.out.println();
Class classes[] = timetable.getClasses();
int classIndex = 1;
for (Class bestClass : classes) {
System.out.println("Class " + classIndex + ":");
System.out.println("Module: " +
timetable.getModule(bestClass.getModuleId()).getModuleName());
System.out.println("Group: " +
timetable.getGroup(bestClass.getGroupId()).getGroupId());
System.out.println("Room: " +
timetable.getRoom(bestClass.getRoomId()).getRoomNumber());
System.out.println("Professor: " +
timetable.getProfessor(bestClass.getProfessorId()).getProfessorName());
System.out.println("Time: " +
timetable.getTimeslot(bestClass.getTimeslotId()).getTimeslot());
System.out.println("-----");
classIndex++;
}
此时,您应该能够运行程序,观察进化循环,并得到一个结果。没有突变,你可能永远找不到解决方案,但是我们从第二章和第三章中重新利用的现有交叉方法通常足以找到解决方案。然而,如果你运行这个程序很多次,但在不到 1000 代的时间里,你从来没有找到一个解决方案,你可能要重新阅读这一章,并确保你没有犯任何错误。
我们把熟悉的“交叉”部分从本章中去掉,因为这里没有新的技术。回想一下第二章中的均匀交叉,随机选择染色体并与父代交换,而不保留基因组内的任何连续性。对于这个问题,这是一个很好的方法,因为在这种情况下,基因组(代表教授、房间和时间段的组合)更有可能是有害的,而不是有益的。
变化
回想一下,染色体上的约束通常决定了人们为遗传算法选择的突变和交叉技术。在这种情况下,染色体由特定的房间、教授和时间段 id 组成;我们不能简单地选择随机数。此外,由于房间、教授和时间段都有不同的 id 范围,我们也不能简单地在 1 和“X”之间选择一个随机数。潜在地,我们可以为我们正在编码的每一种不同类型的对象(房间、教授和时间段)选择随机数,但这也假设 id 是连续的,它们可能不是!
我们可以从均匀交叉中得到一个提示来解决我们的变异问题。在均匀交叉中,从现有的有效亲本中随机选择基因。父母可能不是群体中最合适的个体,但至少是有效的。
突变可以以类似的方式实现。我们可以创建一个新的随机但有效的个体,并在本质上运行均匀交叉来实现变异,而不是为染色体中的随机基因选择一个随机数!也就是我们可以用我们的个体(时间表)构造器创造一个全新的随机个体,然后从随机个体中选择基因复制到要变异的个体中。这种技术被称为统一突变,它确保我们所有突变的个体都是完全有效的,永远不会选择没有意义的基因。在 GeneticAlgorithm 类中的任意位置添加以下方法:
public Population mutatePopulation(Population population, Timetable timetable) {
// Initialize new population
Population newPopulation = new Population(this.populationSize);
// 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);
// 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 mutation?
if (this.mutationRate > 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;
}
在这种方法中,像前几章中的突变一样,群体通过在群体中的非精英个体上循环来突变。与其他倾向于直接修改基因的突变技术不同,这种突变算法创建一个随机但有效的个体,并从中随机复制基因。
我们现在可以在执行类的 main 方法中解析最终的 TODO。将这个一行程序添加到主循环中:
// Apply mutation
population = ga.mutatePopulation(population, timetable);
我们现在应该一切就绪,可以运行我们的遗传算法,并创建一个新的大学时间表。如果您的 Java IDE 显示错误,或者如果它此时不能编译,请回顾本章并解决您发现的任何问题。
执行
确保您的 TimetableGA 类如下所示:
package chapter``5
public class TimetableGA {
public static void main(String[] args) {
// Get a Timetable object with all the available information.
Timetable timetable = initializeTimetable();
// Initialize GA
GeneticAlgorithm ga = new GeneticAlgorithm(100, 0.01, 0.9, 2, 5);
// Initialize population
Population population = ga.initPopulation(timetable);
// Evaluate population
ga.evalPopulation(population, timetable);
// Keep track of current generation
int generation = 1;
// Start evolution loop
while (ga.isTerminationConditionMet(generation, 1000) == false
&& ga.isTerminationConditionMet(population) == false) {
// Print fitness
System.out.println("G" + generation + " Best fitness: " + population.getFittest(0).getFitness());
// Apply crossover
population = ga.crossoverPopulation(population);
// Apply mutation
population = ga.mutatePopulation(population, timetable);
// Evaluate population
ga.evalPopulation(population, timetable);
// Increment the current generation
generation++;
}
// Print fitness
timetable.createClasses(population.getFittest(0));
System.out.println();
System.out.println("Solution found in " + generation + " generations");
System.out.println("Final solution fitness: " + population.getFittest(0).getFitness());
System.out.println("Clashes: " + timetable.calcClashes());
// Print classes
System.out.println();
Class classes[] = timetable.getClasses();
int classIndex = 1;
for (Class bestClass : classes) {
System.out.println("Class " + classIndex + ":");
System.out.println("Module: " +
timetable.getModule(bestClass.getModuleId()).getModuleName());
System.out.println("Group: " +
timetable.getGroup(bestClass.getGroupId()).getGroupId());
System.out.println("Room: " +
timetable.getRoom(bestClass.getRoomId()).getRoomNumber());
System.out.println("Professor: " +
timetable.getProfessor(bestClass.getProfessorId()).getProfessorName());
System.out.println("Time: " +
timetable.getTimeslot(bestClass.getTimeslotId()).getTimeslot());
System.out.println("-----");
classIndex++;
}
}
/**
* Creates a Timetable with all the necessary course information.
* @return
*/
private static Timetable initializeTimetable() {
// Create timetable
Timetable timetable = new Timetable();
// Set up rooms
timetable.addRoom(1, "A1", 15);
timetable.addRoom(2, "B1", 30);
timetable.addRoom(4, "D1", 20);
timetable.addRoom(5, "F1", 25);
// Set up timeslots
timetable.addTimeslot(1, "Mon 9:00 - 11:00");
timetable.addTimeslot(2, "Mon 11:00 - 13:00");
timetable.addTimeslot(3, "Mon 13:00 - 15:00");
timetable.addTimeslot(4, "Tue 9:00 - 11:00");
timetable.addTimeslot(5, "Tue 11:00 - 13:00");
timetable.addTimeslot(6, "Tue 13:00 - 15:00");
timetable.addTimeslot(7, "Wed 9:00 - 11:00");
timetable.addTimeslot(8, "Wed 11:00 - 13:00");
timetable.addTimeslot(9, "Wed 13:00 - 15:00");
timetable.addTimeslot(10, "Thu 9:00 - 11:00");
timetable.addTimeslot(11, "Thu 11:00 - 13:00");
timetable.addTimeslot(12, "Thu 13:00 - 15:00");
timetable.addTimeslot(13, "Fri 9:00 - 11:00");
timetable.addTimeslot(14, "Fri 11:00 - 13:00");
timetable.addTimeslot(15, "Fri 13:00 - 15:00");
// Set up professors
timetable.addProfessor(1, "Dr P Smith");
timetable.addProfessor(2, "Mrs E Mitchell");
timetable.addProfessor(3, "Dr R Williams");
timetable.addProfessor(4, "Mr A Thompson");
// Set up modules and define the professors that teach them
timetable.addModule(1, "cs1", "Computer Science", new int[] { 1, 2 });
timetable.addModule(2, "en1", "English", new int[] { 1, 3 });
timetable.addModule(3, "ma1", "Maths", new int[] { 1, 2 });
timetable.addModule(4, "ph1", "Physics", new int[] { 3, 4 });
timetable.addModule(5, "hi1", "History", new int[] { 4 });
timetable.addModule(6, "dr1", "Drama", new int[] { 1, 4 });
// Set up student groups and the modules they take.
timetable.addGroup(1, 10, new int[] { 1, 3, 4 });
timetable.addGroup(2, 30, new int[] { 2, 3, 5, 6 });
timetable.addGroup(3, 18, new int[] { 3, 4, 5 });
timetable.addGroup(4, 25, new int[] { 1, 4 });
timetable.addGroup(5, 20, new int[] { 2, 3, 5 });
timetable.addGroup(6, 22, new int[] { 1, 4, 5 });
timetable.addGroup(7, 16, new int[] { 1, 3 });
timetable.addGroup(8, 18, new int[] { 2, 6 });
timetable.addGroup(9, 24, new int[] { 1, 6 });
timetable.addGroup(10, 25, new int[] { 3, 4 });
return timetable;
}
}
按原样运行类调度器应该生成大约 50 代的解决方案,并且在所有情况下应该呈现零冲突(硬约束)的解决方案。如果您的算法反复达到 1,000 代的限制,或者如果它提供了有冲突的解决方案,那么您的实现可能有问题!
花一分钟时间直观地检查算法返回的时间表结果。确认教授、房间和时间段之间没有实际冲突。
此时,您可能还想尝试在 TimetableGA 的“initializeTimetable”方法中为时间表初始化添加更多教授、模块、时隙、组和房间。能不能强制算法失效?
分析和提炼
排课问题是一个很好的例子,它使用遗传算法在解空间中搜索有效解,而不是最优解。这个问题可以有许多适合度为 1 的解,我们所要做的就是找到这些有效解中的一个。当只考虑硬约束时,任何两个有效的解决方案之间没有真正的区别,我们可以简单地选择我们找到的第一个解决方案。
与第四章中的旅行推销员问题不同,排课问题的这一特性意味着算法实际上可以返回无效解。旅行推销员问题中的一个解决方案如果没有访问每个城市一次就可能是无效的,但是因为我们非常小心地设计了我们的初始化、交叉和变异算法,所以使用来自第四章的代码,我们不会遇到无效的解决方案。我们的 TSP 求解器返回的所有路径都是有效的,这只是一个寻找最短可能路径的问题。如果我们在任何一代中的任何一点停止 TSP 算法,并随机选择一个群体成员,这将是一个有效的解决方案。
然而,在这一章中,大多数解决方案都是无效的,我们只有在找到第一个有效的解决方案或时间用完时才停下来。这两个问题的区别如下:在旅行推销员问题中,很容易创建一个有效的解决方案(只要确保每个城市都被访问一次;但是,不能保证解决方案的适用性!),但是在班级调度器中,创建有效的解决方案是困难的部分。
此外,如果没有任何软约束,由类调度器返回的任何两个有效解之间的适合度没有差别。在这种情况下,硬约束决定解决方案是否有效,而软约束决定解决方案的质量。上面的实现并不偏好任何特定的有效解决方案,因为它无法确定解决方案的质量——它只知道解决方案是否有效。
向类调度器添加软约束会显著改变这个问题。我们不再只是寻找任何有效的解决方案,而是想要最好的有效解决方案。
幸运的是,遗传算法特别擅长这种类型的约束杂耍。事实上,一个人只由一个单一的数字来判断——它的适应性——这对我们有利。决定个体适应度的算法对遗传算法来说是完全不透明的——就遗传算法而言,这是一个黑箱。虽然适应值对于遗传算法非常重要,不能随意实现,但它的简单性和不透明性也让我们可以用它来协调各种约束和条件。因为一切都可以归结为一个无量纲的适应度分数,所以我们能够缩放和转换尽可能多的约束,并且该约束的重要性由它对适应度分数的贡献程度来表示。
上面实现的类调度器仅使用硬约束,并将适合度分数限制在 0-1 的范围内。当组合不同类型的约束时,应该确保硬约束对适应性分数具有压倒性的影响,而软约束做出更适度的贡献。
例如,假设您需要向类调度器添加一些软约束,每个软约束的重要性略有不同。当然,硬约束仍然适用。你如何调和软约束和硬约束?现有的适应度分数“1 /(冲突+ 1)”显然不包含软约束,即使它将破坏的软约束视为“冲突”,仍会将它们与硬约束置于同等地位。在该模型下,有可能选择一个无效的解决方案,因为它可能有许多满足的软约束,这些软约束弥补了由于硬约束被破坏而导致的适应性损失。
相反,考虑一个新的适应度评分系统:每个打破的硬约束从适应度分数中减去 100,而任何满足的软约束可能根据其重要性给适应度分数增加 1、2 或 3 分。在这个方案下,我们应该只考虑得分为零或以上的解决方案,因为任何负值都有一个破坏的硬约束。该方案还确保了一个被破坏的硬约束不可能被大量满足的软约束抵消——一个硬约束对适应性分数的贡献如此巨大,以至于软约束不可能弥补一个被破坏的硬约束所扣掉的 100 分。最后,该方案还允许您对软约束进行优先级排序——越重要的约束对适应度分数的贡献越大。
为了进一步说明适合度分数归一化约束的思想;考虑任何地图和方向工具(如谷歌地图)。当您搜索两个位置之间的方向时,健身分数的主要贡献者是从一个地方到另一个地方所需的时间。一个简单的算法可能使用以分钟为单位的旅行时间作为其适应度得分(在这种情况下,我们将它称为“成本得分”,因为越低越好,而适应度的倒数通常称为“成本”)。
花费 60 分钟的路线比花费 70 分钟的路线要好——但是我们知道现实生活中并不总是这样。也许更短的路线有 20 美元的高昂费用。用户可以选择“避免通行费”选项,现在算法必须协调驾驶分钟数和通行费。一美元值多少分钟?如果你决定每一美元给成本分数加一分,那么较短的路线现在的成本是 80,输给了较长但更便宜的路线。另一方面,如果你减少了避免通行费的权重,并决定 1 美元的通行费只给路线增加了 0.25 英镑的成本,则较短的路线仍将以 65 英镑的成本获胜。
最后,当在遗传算法中处理硬约束和软约束时,一定要理解适应度分数代表什么,以及每个约束将如何影响个人的分数。
练习
Add soft constraints to the class scheduler. This could include preferred professor time and preferred classrooms. Implement support for a config file or database connection to add initial timetable data. Build a class scheduler for a school timetable that requires students to have a class scheduled for each period.
摘要
在这一章中,我们已经讲述了使用遗传算法安排课程的基础知识。我们没有使用遗传算法来寻找最优解,而是使用遗传算法来寻找满足许多硬约束的第一个有效解。
我们还探索了一种新的突变策略,确保突变的染色体仍然有效。我们没有直接修改染色体并增加随机性——在这种情况下,这可能导致无效的染色体——而是创建了一个已知有效的随机个体,并以一种类似于均匀交叉的方式与其交换基因。该算法仍然被认为是一致变异,但是本章中使用的新方法使得确保有效变异变得更加容易。
我们还结合了第二章的的统一交叉和第三章的的锦标赛选择,展示了遗传算法的许多方面是模块化的、独立的,并且能够以不同的方式组合。
最后,我们讨论了遗传算法中适应值的灵活性。我们了解到,无量纲适合度分数可用于引入软约束,并使它们与硬约束相协调,最终目标是不仅产生有效的结果,还产生高质量的结果。