genetic-algorithm

用python写一个遗传算法的例子

以下是一个用Python实现的简单遗传算法的例子,该算法用于优化一个简单的数学函数。这个例子中,我们尝试最大化函数 ( f(x) = x^2 ),其中 ( x ) 是一个整数,范围在 [0, 31] 之间。

遗传算法步骤

  1. 初始化:生成随机初始种群。
  2. 适应度计算:计算每个个体的适应度。
  3. 选择:根据适应度选择父代。
  4. 交叉:通过交叉产生新的后代。
  5. 变异:对后代进行随机变异。
  6. 替换:用新种群替换旧种群。
  7. 循环:重复上述步骤,直到达到终止条件。

以下是完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import random

# 遗传算法参数
POPULATION_SIZE = 10 # 种群大小
GENE_LENGTH = 5 # 基因长度(二进制表示的位数)
MUTATION_RATE = 0.1 # 变异概率
GENERATIONS = 20 # 迭代次数

# 目标函数:f(x) = x^2
def fitness_function(x):
return x ** 2

# 初始化种群
def initialize_population():
return [random.randint(0, 2**GENE_LENGTH - 1) for _ in range(POPULATION_SIZE)]

# 计算适应度
def calculate_fitness(population):
return [fitness_function(individual) for individual in population]

# 选择(轮盘赌法)
def select_parents(population, fitness):
total_fitness = sum(fitness)
probabilities = [f / total_fitness for f in fitness]
selected = random.choices(population, weights=probabilities, k=2)
return selected

# 单点交叉
def crossover(parent1, parent2):
point = random.randint(1, GENE_LENGTH - 1)
mask = (1 << point) - 1
offspring1 = (parent1 & mask) | (parent2 & ~mask)
offspring2 = (parent2 & mask) | (parent1 & ~mask)
return offspring1, offspring2

# 变异
def mutate(individual):
for bit in range(GENE_LENGTH):
if random.random() < MUTATION_RATE:
individual ^= (1 << bit)
return individual

# 主函数
def genetic_algorithm():
# 初始化种群
population = initialize_population()
for generation in range(GENERATIONS):
# 计算适应度
fitness = calculate_fitness(population)

# 输出当前代最佳个体
best_individual = population[fitness.index(max(fitness))]
print(f"Generation {generation}: Best = {best_individual}, Fitness = {max(fitness)}")

# 新种群
new_population = []
while len(new_population) < POPULATION_SIZE:
# 选择父代
parent1, parent2 = select_parents(population, fitness)
# 交叉
offspring1, offspring2 = crossover(parent1, parent2)
# 变异
offspring1 = mutate(offspring1)
offspring2 = mutate(offspring2)
# 添加到新种群
new_population.extend([offspring1, offspring2])

# 更新种群
population = new_population[:POPULATION_SIZE]

# 运行遗传算法
genetic_algorithm()

代码说明

  1. 基因表示:个体用一个整数表示,范围为 [0, 31],二进制长度为 5。
  2. 适应度函数:目标是最大化 ( f(x) = x^2 )。
  3. 选择:使用轮盘赌法选择父代。
  4. 交叉:单点交叉。
  5. 变异:随机翻转基因位。

运行代码后,可以看到遗传算法逐步寻找最优解 ( x = 31 )。

为什么初始化种群时随机数的范围时0到2**GENE_LENGTH - 1

初始化种群时,随机数的范围是 ( [0, 2^{\text{GENE_LENGTH}} - 1] ),因为个体的基因表示是用二进制的固定位数来表示的。具体原因如下:

1. 二进制编码

  • 基因长度 (( \text{GENE_LENGTH} )) 表示个体的基因位数。例如,如果基因长度是 5,那么每个个体的基因可以表示 5 位二进制数。
  • 一个 5 位二进制数的所有可能值是从 ( 00000 ) 到 ( 11111 ),对应十进制范围 ( 0 ) 到 ( 2^5 - 1 = 31 )。

2. 覆盖所有可能解

  • 使用随机数范围 ( [0, 2^{\text{GENE_LENGTH}} - 1] ),可以保证种群中的每个个体有机会覆盖所有可能的基因组合。
  • 例如:
    • 如果基因长度是 3,范围是 ( [0, 7] ),可能的个体有 ( 000, 001, 010, 011, 100, 101, 110, 111 )。
    • 如果基因长度是 5,范围是 ( [0, 31] )。

3. 灵活性

  • 遗传算法常用二进制编码来表示个体的基因,因为这种表示方式易于实现交叉和变异操作。
  • 使用整数代替直接的二进制字符串,简化了随机生成、计算适应度和基因操作的实现。

代码中的对应部分

1
2
def initialize_population():
return [random.randint(0, 2**GENE_LENGTH - 1) for _ in range(POPULATION_SIZE)]
  • random.randint(0, 2**GENE_LENGTH - 1) 确保生成的随机数在合法范围内。
  • 例如,若 GENE_LENGTH = 5,生成的随机数范围是 ( [0, 31] )。

总结

将随机数的范围设置为 ( [0, 2^{\text{GENE_LENGTH}} - 1] ) 是为了准确表示所有可能的基因组合,使初始化种群具有良好的多样性,并为遗传算法提供完整的搜索空间。

这段代码时如何限制x的范围在 [0, 31] 之间

在代码中,限制 ( x ) 的范围在 ([0, 31]) 之间主要是通过以下几个步骤实现的:

1. 基因编码

每个个体的基因表示为一个整数,范围为 ([0, 2^{\text{GENE_LENGTH}} - 1])。通过控制 GENE_LENGTH(基因长度),可以限制整数的最大值。例如:

  • 如果 GENE_LENGTH = 5,则最大值为 ( 2^5 - 1 = 31 )。

  • 初始化种群时,代码中使用了:

    1
    random.randint(0, 2**GENE_LENGTH - 1)

    这确保了生成的初始个体值在 ([0, 31]) 之间。

2. 交叉操作

交叉操作本质上是在二进制级别对两个父代进行组合,得到的新个体仍然是一个合法的整数,并且不会超出 ([0, 31])。例如:

1
2
3
4
point = random.randint(1, GENE_LENGTH - 1)
mask = (1 << point) - 1
offspring1 = (parent1 & mask) | (parent2 & ~mask)
offspring2 = (parent2 & mask) | (parent1 & ~mask)
  • mask 控制交叉点,使得结果保持在 ([0, 31]) 之间。

3. 变异操作

变异操作是对个体的二进制位随机翻转,但翻转后的值依然是一个合法的 5 位二进制数,因此范围仍然在 ([0, 31])。例如:

1
2
3
for bit in range(GENE_LENGTH):
if random.random() < MUTATION_RATE:
individual ^= (1 << bit)
  • 1 << bit 仅作用于某个位,而不会引入额外的位。

4. 适应度函数(隐式约束)

虽然适应度函数 ( f(x) = x^2 ) 不直接限制 ( x ) 的范围,但由于所有的个体都是在 ([0, 31]) 内生成的,因此不会超出限制。

关键设计

整个代码通过以下方法始终保持 ( x ) 的范围在 ([0, 31]):

  1. 初始化种群:生成的个体值在 ([0, 31])。
  2. 交叉和变异:保持二进制位数为 GENE_LENGTH(即 5 位)。
  3. 控制基因长度:基因长度 GENE_LENGTH = 5 限制了搜索空间的上限。

测试输出

运行代码时,每一代的最佳个体及其适应度都在这个范围内。例如:

1
2
3
Generation 0: Best = 25, Fitness = 625
Generation 1: Best = 31, Fitness = 961
...

如果需要更改范围,比如限制在 ([0, 63]),只需调整 GENE_LENGTH = 6 即可。

解释 select_parents 函数

select_parents 函数负责从当前种群中选择两个父代,用于生成下一代的后代。这个过程基于轮盘赌选择(Roulette Wheel Selection),一种常用的选择方法,它根据个体的适应度值来确定选择概率,适应度高的个体更有可能被选中。

函数代码

1
2
3
4
5
def select_parents(population, fitness):
total_fitness = sum(fitness)
probabilities = [f / total_fitness for f in fitness]
selected = random.choices(population, weights=probabilities, k=2)
return selected

工作原理

  1. 计算总适应度

    1
    total_fitness = sum(fitness)
    • 将种群中所有个体的适应度求和。
    • 这个值是后续计算每个个体选择概率的基础。
  2. 计算选择概率

    1
    probabilities = [f / total_fitness for f in fitness]
    • 每个个体的选择概率是其适应度 ( f ) 除以总适应度 ( \text{total_fitness} )。
    • 适应度越高,选择概率越大。
  3. 轮盘赌选择

    1
    selected = random.choices(population, weights=probabilities, k=2)
    • 使用 Python 的 random.choices 函数,根据概率 weights=probabilities 随机选择 ( k=2 ) 个个体作为父代。
    • random.choices 会根据权重分布随机抽取,而不是简单的均匀随机选择。
  4. 返回父代

    1
    return selected
    • 返回选中的两个个体。

示例说明

假设有以下种群和适应度值:

1
2
population = [5, 10, 15, 20]
fitness = [25, 100, 225, 400] # 对应个体的适应度

计算总适应度:

[
\text{total_fitness} = 25 + 100 + 225 + 400 = 750
]

计算选择概率:

[
\text{probabilities} = [\frac{25}{750}, \frac{100}{750}, \frac{225}{750}, \frac{400}{750}]
= [0.033, 0.133, 0.3, 0.533]
]

轮盘赌选择:

根据上述概率分布:

  • 个体 20 (( p = 0.533 )) 的选择概率最大。
  • 个体 5 (( p = 0.033 )) 的选择概率最小。

执行 random.choices(population, weights=probabilities, k=2) 可能选中:

  • ( [20, 15] )
  • ( [20, 20] )
  • ( [15, 10] ) 等结果。

优点

  • 简单易用:直接利用适应度比例进行选择。
  • 保留优秀个体:高适应度的个体更容易被选中。
  • 多样性:低适应度的个体也有机会被选中,但概率较小。

可能的改进

  1. 锦标赛选择:替代方法,随机选取一小部分个体,选择适应度最高的。
  2. 轮盘赌的改进:如排名选择或基于线性缩放的选择,避免适应度差异过大时强者垄断。

更新新种群为什么要这样? population = new_population[:POPULATION_SIZE]

在遗传算法中,更新种群的目的是从当前种群中替换为新一代的种群,以模拟进化过程。代码中:

1
population = new_population[:POPULATION_SIZE]

这一行代码的作用是确保新种群的大小始终等于预设的 POPULATION_SIZE。具体原因如下:


1. 控制种群大小

遗传算法通常规定一个固定的种群大小 POPULATION_SIZE,在每一代中:

  • 保持种群大小不变有助于算法的稳定性和计算效率。
  • 新生成的个体可能多于需要的个体,切片操作 [:POPULATION_SIZE] 会限制最终的种群大小。

2. 生成个体数可能超标

在更新种群的过程中,新个体通过交叉和变异产生。代码中:

1
2
3
4
5
6
while len(new_population) < POPULATION_SIZE:
parent1, parent2 = select_parents(population, fitness)
offspring1, offspring2 = crossover(parent1, parent2)
offspring1 = mutate(offspring1)
offspring2 = mutate(offspring2)
new_population.extend([offspring1, offspring2])
  • 问题:每次生成两个新个体,而种群大小可能不是偶数。例如:
    • 如果 POPULATION_SIZE = 10,可能会生成 12 个个体(超出目标)。
    • 如果 POPULATION_SIZE = 9,可能会生成 10 个个体(多了 1 个)。
  • 解决方法:通过 [:POPULATION_SIZE] 截断多余的个体。

3. 适应度选择机制的延续

即使种群更新,新种群需要与之前种群保持一致的结构和大小:

  • 遗传算法的许多操作(例如适应度计算和选择)假设种群大小固定。
  • 如果不控制种群大小,适应度计算可能因为个体数量不同而出错。

4. 可扩展性

通过切片控制种群大小,使代码更通用:

  • 若未来需求变化(如需要引入精英保留策略或多样性约束),可以轻松扩展而无需改变核心逻辑。
  • 例如:允许部分最优个体直接保留到下一代。

总结

使用 [:POPULATION_SIZE] 的原因主要是为了:

  1. 控制种群大小,保持种群数目固定。
  2. 防止多余个体影响后续操作
  3. 维持算法的计算效率和稳定性

这是遗传算法实现中的一个标准操作。

为什么添加到新种群时要添加两个 new_population.extend([offspring1, offspring2])

在遗传算法中,每次通过交叉操作生成两个后代,而不仅仅是一个。代码中的这行:

1
new_population.extend([offspring1, offspring2])

表示将两个新生成的后代个体同时添加到新种群中。这样设计有以下原因:


1. 效率提高:交叉生成两个后代

在单点交叉(或其他类型的交叉操作)中,父代的基因通过组合可以同时生成两个后代。例如:

父代基因:

  • Parent1: ( 10101 )(十进制 21)
  • Parent2: ( 11011 )(十进制 27)

单点交叉:

如果交叉点在第 3 位:

  • Offspring1: ( 10111 )(十进制 23)

  • Offspring2: ( 11001 )(十进制 25)

  • 特点:通过一次交叉操作,利用两个父代,能够有效生成两个具有差异化基因的后代。

  • 效率:相比只生成一个后代,这样可以更快地填充种群,减少交叉操作的调用次数。


2. 保持种群多样性

  • 添加两个后代能够从每对父代中引入更多的遗传信息,保持基因的多样性。
  • 如果每次只生成一个后代,则种群可能过早趋于收敛,导致陷入局部最优。

3. 种群大小的匹配

在代码中,更新种群时:

1
2
3
while len(new_population) < POPULATION_SIZE:
...
new_population.extend([offspring1, offspring2])
  • 目标:填充新种群,使其大小达到 POPULATION_SIZE
  • 逻辑
    • 每次交叉生成两个后代,因此迭代次数为 POPULATION_SIZE / 2
    • 如果只生成一个后代,则需要额外的迭代,影响效率。

例如:

  • POPULATION_SIZE = 10
    • 每次生成 2 个后代,需 5 次交叉。
    • 如果每次生成 1 个后代,则需 10 次交叉,运行效率降低。

4. 生物学模拟

  • 在自然界中,父母的交配通常会产生多个后代,而不是一个。
  • 这种设计符合生物学中的遗传规律,交叉操作会从父代基因中产生多个可能的后代。

代码逻辑的配合

由于 new_population 被限制为最终种群大小 POPULATION_SIZE,每次添加两个后代可以快速填充种群并满足约束:

1
population = new_population[:POPULATION_SIZE]

总结

new_population.extend([offspring1, offspring2]) 的主要原因包括:

  1. 效率高:一次交叉生成两个后代,减少循环次数。
  2. 增加多样性:每对父代贡献更多遗传信息。
  3. 自然启发:模拟真实生物的繁殖方式。
  4. 逻辑匹配:更快达到固定的种群大小 POPULATION_SIZE

这种方法是遗传算法中生成新种群的常见设计。

如果把 fitness_function 改成另一个函数,代码需要做哪些改动?

如果将 fitness_function 替换为另一个函数,遗传算法代码可能需要以下几个方面的调整,确保新适应度函数能够正确工作,并且算法逻辑保持一致。


1. 定义新的适应度函数

新的适应度函数可能有不同的输入、输出或意义。例如:

  • 当前适应度函数:

    1
    2
    def fitness_function(x):
    return x ** 2 # 输入 x 输出适应度值
  • 新适应度函数(如优化函数 ( f(x) = -x^2 + 10x + 5 )):

    1
    2
    def fitness_function(x):
    return -x ** 2 + 10 * x + 5

确保新函数接受遗传算法中的基因表示(通常是整数或浮点数)作为输入,并返回一个适应度值(通常是非负数)。


2. 调整适应度值范围

适应度值需要满足以下要求:

  • 非负性:大多数遗传算法实现要求适应度值为非负数,因为负值会导致选择概率计算错误。

    • 如果新适应度函数可能返回负值,需要将其调整为非负。例如:
      1
      2
      def fitness_function(x):
      return max(0, -x ** 2 + 10 * x + 5) # 避免负值
  • 相对大小有效:适应度值的相对大小决定了选择概率。如果新函数的值范围过大或过小,可以进行归一化处理:

    1
    2
    max_fit = max(fitness)
    probabilities = [f / max_fit for f in fitness]

3. 更新种群初始化范围

新适应度函数可能对基因的输入范围有特殊要求:

  • 当前代码假设基因的取值范围是 ([0, 2^{\text{GENE_LENGTH}} - 1])。
  • 如果新函数需要不同的输入范围(如浮点数或更大的整数范围),需要调整初始化函数:
    1
    2
    def initialize_population():
    return [random.uniform(lower_bound, upper_bound) for _ in range(POPULATION_SIZE)] # 如果需要浮点数

4. 修改适应度计算逻辑

在选择父代时,代码使用了以下逻辑:

1
probabilities = [f / total_fitness for f in fitness]
  • 如果新函数返回负值或适应度范围变化,total_fitnessprobabilities 需要重新计算或调整。
  • 可能需要归一化处理:
    1
    2
    min_fitness = min(fitness)
    normalized_fitness = [f - min_fitness for f in fitness] # 将最小值移到 0

5. 交叉和变异操作的调整

新的适应度函数可能会影响基因的表示方式(如整数、浮点数或特殊编码),从而需要调整交叉和变异逻辑:

  • 当前实现基于整数:

    1
    offspring1 = (parent1 & mask) | (parent2 & ~mask)
  • 如果需要优化浮点数,则需调整为:

    1
    offspring1 = parent1 * alpha + parent2 * (1 - alpha)  # 权重平均
  • 对变异操作,也需要支持新类型:

    1
    offspring1 += random.uniform(-mutation_range, mutation_range)  # 对浮点数变异

6. 适应度目标的调整

当前算法可能默认优化适应度值越高越好。如果新函数需要最小化目标值,可能需要在适应度函数中处理:

  • 将目标函数转换为适应度函数:
    1
    2
    def fitness_function(x):
    return 1 / (1 + objective_function(x)) # 将目标值较小时的适应度值变大

7. 调试和验证

新的适应度函数可能会影响遗传算法的行为,需要验证:

  • 种群收敛性:观察种群是否过早收敛或陷入局部最优。
  • 适应度分布:检查适应度值是否符合预期范围,是否存在异常(如极端偏差)。

示例调整

假设将适应度函数更改为 ( f(x) = -x^2 + 10x + 5 ):

1
2
3
4
5
6
7
# 新的适应度函数
def fitness_function(x):
return max(0, -x**2 + 10*x + 5)

# 初始化种群
def initialize_population():
return [random.randint(0, 10) for _ in range(POPULATION_SIZE)] # 输入范围调整为 [0, 10]

通过上述调整,可以使代码适应新的优化目标和输入范围。

用遗传算法写一个排课系统

需求大约如下:

基于多种群演进的遗传算法。该系统提供功能有

  • a) 基本约束: 一个教师不能同时上多个班的课,一个班也不可以同时上多节课;
  • b) 高级设定: 系统提供了课程级别的硬约束软约束,以及教师级别的硬约束软约束。
    • 硬约束指的是,该时间被禁止的。
    • 软约束指的是该时间是喜欢的时间或者讨厌的时间。
      • (比如 音乐课程不要设置为早上第一节课,最好设置为下午第一节课,最好不要设置为上午第二节课。
      • 张老师周五上午有教研活动不要安排课程,最好安排上午的课程, 最好不要安排周三的课程。

做如下假定

  • 1)一个班只在一个地方上课。
  • 2)一个地方只有一个班级和一位老师上课。
  • 3)一个班级的课程的教师是固定的。
  • 4)合班上课暂未考虑,一个教师同时在多个地方授课(通常是早晚自习)暂未考虑。
    1. 假定每个课程每周至少有一节课, 两周或者更长时间才有一节课的暂未考虑。

编码

  • 拿到任何一张中小学的课表, 我们会发现什么, 课表不就是课程按照时间和班级用一定的方式排列起来吗?
  • 对于一个班级而言,一周的课程类型和数目是固定的, 就看怎么排列了。 那么一个班级一周的课程序列就自然组成了一个基因,多个班级一周的课程就组成了基因组。
  • 一个基因组就是全校的课表, 基因组中的一个基因就是某个班级的课表。(这里基因组称为染色体更合适, 个人习惯, 后面都叫基因组)。

交叉

  • 编码的规则清楚了,交叉就很简单了, 现在有两个基因组, 那么如何交叉呢, 随机或者固定选定一个或者多个班级进行交换就可以了。
  • 比如现在有基因组 A, 基因组B, 把基因组A中三(1)班的课表用基因组B中三(1)班的课表替换就可以形成新的基因组了。

变异

  • 变异针对的是基因,也就是一个班级的课表。 如何进行变异,很简单,
  • 比如 比如二( )班周三第二节课和周四第5节课互换,这就是变异。

适应度

  • 针对课表计算各个维度就很简单了,
  • 冲突: 不能上课的时间安排了课,同时安排了多节课。
  • 时间喜好: 很好处理,对于喜欢的时间加一个权值,不喜欢的时间减一个权值就可以了。
  • 时间均匀度: 按照一周课时算出一个最佳课程间隔,然后用实际课程间隔计算标准差就可以了 。

基于需求描述,以下是一个实现遗传算法解决排课问题的代码框架。这个框架将包括初始化种群、编码、交叉、变异和适应度计算,并支持时间约束(硬约束和软约束)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
import random
import numpy as np

# Constants
NUM_CLASSES = 5 # 班级数量
NUM_PERIODS = 5 * 6 # 一周的时间段数 (5 天,每天 6 节课)
POPULATION_SIZE = 20 # 种群大小
MAX_GENERATIONS = 100 # 最大迭代代数
MUTATION_RATE = 0.1 # 变异概率
CROSSOVER_RATE = 0.7 # 交叉概率

# 示例教师和课程数据
TEACHERS = ["张老师", "李老师", "王老师"]
COURSES = ["语文", "数学", "英语", "音乐", "体育"]
TEACHER_AVAILABILITY = {
"张老师": {"hard": [("周五", "上午")], "soft": [("周三", "全天")]},
"李老师": {"hard": [("周三", "下午")], "soft": [("周一", "上午")]},
"王老师": {"hard": [], "soft": []},
}
COURSE_PREFERENCES = {
"音乐": {"soft": [("下午", "第一节")], "hard": [("上午", "第一节")]},
}

# 编码
def initialize_population():
population = []
for _ in range(POPULATION_SIZE):
schedule = [
[
(random.choice(COURSES), random.choice(TEACHERS))
for _ in range(NUM_PERIODS)
]
for _ in range(NUM_CLASSES)
]
population.append(schedule)
return population

# 适应度计算
def calculate_fitness(schedule):
fitness = 0
for class_schedule in schedule:
for period, (course, teacher) in enumerate(class_schedule):
day = period // 6
time = period % 6

# 检查硬约束
if teacher in TEACHER_AVAILABILITY:
for hard in TEACHER_AVAILABILITY[teacher]["hard"]:
if (day, time) in hard:
fitness -= 100

if course in COURSE_PREFERENCES:
for hard in COURSE_PREFERENCES[course]["hard"]:
if (day, time) in hard:
fitness -= 100

# 软约束
if teacher in TEACHER_AVAILABILITY:
for soft in TEACHER_AVAILABILITY[teacher]["soft"]:
if (day, time) in soft:
fitness += 10

if course in COURSE_PREFERENCES:
for soft in COURSE_PREFERENCES[course]["soft"]:
if (day, time) in soft:
fitness += 10

return fitness

# 选择父代
def select_parents(population, fitness):
probabilities = [f / sum(fitness) for f in fitness]
return random.choices(population, weights=probabilities, k=2)

# 交叉
def crossover(parent1, parent2):
child1, child2 = parent1[:], parent2[:]
for i in range(NUM_CLASSES):
if random.random() < CROSSOVER_RATE:
child1[i], child2[i] = parent2[i], parent1[i]
return child1, child2

# 变异
def mutate(schedule):
for class_schedule in schedule:
if random.random() < MUTATION_RATE:
i, j = random.sample(range(NUM_PERIODS), 2)
class_schedule[i], class_schedule[j] = class_schedule[j], class_schedule[i]
return schedule

# 遗传算法主循环
def genetic_algorithm():
population = initialize_population()
for generation in range(MAX_GENERATIONS):
fitness = [calculate_fitness(schedule) for schedule in population]
new_population = []
while len(new_population) < POPULATION_SIZE:
parent1, parent2 = select_parents(population, fitness)
child1, child2 = crossover(parent1, parent2)
new_population.extend([mutate(child1), mutate(child2)])
population = new_population[:POPULATION_SIZE]

# 打印当前最佳适应度
best_fitness = max(fitness)
print(f"Generation {generation}: Best Fitness = {best_fitness}")

# 如果满足终止条件,提前结束
if best_fitness >= 0:
break

# 返回最终最佳课表
best_schedule = population[np.argmax(fitness)]
return best_schedule

# 运行遗传算法
best_schedule = genetic_algorithm()
print("最佳课表:")
print(best_schedule)

功能概述

  1. 编码:每个班级的课表作为基因,一个基因组代表全校课表。
  2. 初始化:随机生成初始种群,每个个体为一个可能的课表。
  3. 适应度计算
    • 硬约束(时间冲突、不可用时间)扣分。
    • 软约束(喜欢或讨厌的时间)加分。
  4. 选择:通过轮盘赌选择父代。
  5. 交叉:随机交换班级的课表。
  6. 变异:随机调整某个班级的课程时间。
  7. 进化:迭代更新种群,直到达到最大代数或适应度达到目标。

可扩展性

  • 多种群:可增加不同子种群,并允许偶尔迁移个体。
  • 约束调整:可以动态调整软硬约束权重。
  • 性能优化:并行计算适应度或分布式执行。

可以根据实际需求进一步细化!

上面代码报错: ZeroDivisionError: division by zero

出现 ZeroDivisionError: division by zero 的原因是,适应度值计算后,可能所有个体的适应度值都为 0(或者是负值经过归一化后为 0)。这会导致在选择父代时,分母为零的情况。

问题出在以下代码:

1
probabilities = [f / sum(fitness) for f in fitness]

sum(fitness) == 0 时,就会抛出 ZeroDivisionError

修复方法

我们可以通过以下方式修复:


1. 避免适应度和为零

在适应度计算时,确保所有适应度值都大于零,例如:

  • 对所有适应度值加一个较大的常数(如 1000),以避免负值:

    1
    fitness = [f + 1000 for f in fitness]
  • 如果适应度值为负,统一转化为正值:

    1
    fitness = [max(0, f) for f in fitness]

2. 检查适应度和为零的情况

在选择父代前,检查适应度和是否为零。如果为零,可以随机选择父代以继续算法:

1
2
3
4
5
if sum(fitness) == 0:
parent1, parent2 = random.choices(population, k=2)
else:
probabilities = [f / sum(fitness) for f in fitness]
parent1, parent2 = random.choices(population, weights=probabilities, k=2)

3. 归一化适应度值

将适应度值归一化,使它们始终处于一个合理范围内。例如:

1
2
3
4
5
6
7
min_fitness = min(fitness)
normalized_fitness = [f - min_fitness for f in fitness] # 最小值变为 0
if sum(normalized_fitness) == 0:
parent1, parent2 = random.choices(population, k=2)
else:
probabilities = [f / sum(normalized_fitness) for f in normalized_fitness]
parent1, parent2 = random.choices(population, weights=probabilities, k=2)

4. 初始种群质量控制

在生成初始种群时,可以添加一定的约束条件,确保适应度函数不会全为零或负值。例如:

  • 避免完全随机初始化,尽可能生成满足基本约束的个体。
  • 初始种群的基因随机性减少,部分个体可能为模板课表的变异版本。

修正后的代码示例

以下是修正后的完整选择父代逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
def select_parents(population, fitness):
# 确保适应度值非负并归一化
min_fitness = min(fitness)
normalized_fitness = [f - min_fitness for f in fitness]

# 检查适应度和是否为零
if sum(normalized_fitness) == 0:
# 随机选择父代
return random.choices(population, k=2)
else:
# 按概率选择父代
probabilities = [f / sum(normalized_fitness) for f in normalized_fitness]
return random.choices(population, weights=probabilities, k=2)

这样可以有效避免 ZeroDivisionError 的问题,同时保证算法的稳定性。

解释排课系统 initialize_population 的代码

initialize_population 函数负责生成排课系统的初始种群。它的核心目标是创建多个可能的课表,每个课表表示一个全校的排课方案。

以下是代码及其详细解释:

1
2
3
4
5
6
7
8
9
10
11
12
def initialize_population():
population = []
for _ in range(POPULATION_SIZE):
schedule = [
[
(random.choice(COURSES), random.choice(TEACHERS))
for _ in range(NUM_PERIODS)
]
for _ in range(NUM_CLASSES)
]
population.append(schedule)
return population

分步解析

1. 初始化种群(population

1
population = []

population 是一个列表,用于存储整个种群。种群中的每个个体是一个完整的排课方案(即一个全校的课表)。


2. 创建多个排课方案

1
for _ in range(POPULATION_SIZE):

这里的循环生成了 POPULATION_SIZE 个个体(课表),每个个体代表一种可能的排课方式。


3. 生成一个排课方案(schedule

1
2
3
4
5
6
7
schedule = [
[
(random.choice(COURSES), random.choice(TEACHERS))
for _ in range(NUM_PERIODS)
]
for _ in range(NUM_CLASSES)
]
3.1 按班级生成课表
1
for _ in range(NUM_CLASSES)

对于每个班级,生成一个包含所有时间段的课表。 NUM_CLASSES 表示学校的班级数量,例如 5 个班级。

3.2 按时间段分配课程和教师
1
for _ in range(NUM_PERIODS)

对于每个班级的一周时间段(NUM_PERIODS 表示一周的总时间段数,例如 5 天 × 6 节课 = 30 个时间段),随机分配一门课程和一位教师。

3.3 随机分配课程和教师
1
(random.choice(COURSES), random.choice(TEACHERS))

random.choice(COURSES):随机从课程列表中选择一门课程。
random.choice(TEACHERS):随机从教师列表中选择一位教师。


4. 将课表添加到种群中

1
population.append(schedule)

生成的每个 schedule(课表)被添加到种群列表 population 中。


5. 返回种群

1
return population

最后返回完整的种群,供遗传算法的后续操作(如适应度计算、交叉和变异)使用。


数据结构示例

假设:

  • NUM_CLASSES = 2(两个班级),
  • NUM_PERIODS = 6(一天的时间段数),
  • 课程为 ["语文", "数学", "英语"]
  • 教师为 ["张老师", "李老师"]

生成的 population 示例:

1
2
3
4
5
6
7
8
9
10
11
[
[ # 第一个课表
[("语文", "张老师"), ("数学", "李老师"), ...], # 班级 1 的课表
[("英语", "张老师"), ("数学", "张老师"), ...], # 班级 2 的课表
],
[ # 第二个课表
[("英语", "李老师"), ("语文", "张老师"), ...], # 班级 1 的课表
[("数学", "张老师"), ("英语", "李老师"), ...], # 班级 2 的课表
],
...
]

优化与改进

  1. 初始种群质量:目前随机生成的课表可能存在大量冲突(如一个老师同时给多个班上课)。可以在初始化时加入简单的约束检查,减少无效个体的比例。
  2. 教师分配权重:根据课程类型为教师分配权重,例如某些课程只能由特定教师教授。
  3. 课程分布均匀性:确保每门课程在一周内的时间分布较均匀(如尽量避免连续安排同一课程)。

这种设计的随机性为后续遗传算法的进化操作(如交叉和变异)提供了多样性基础。

解释 crossover 和 mutate 两个函数

下面是 crossovermutate 函数的详细解释,这两个函数分别用于遗传算法中的交叉操作和变异操作。它们帮助种群不断进化,生成更好的解决方案。


crossover 函数

交叉操作模拟了自然界中基因重组的过程,通过交换两个父代个体的部分基因来产生新个体。交叉是遗传算法中的核心操作之一,它能结合两个父代个体的优势特征,生成新的后代。

代码:

1
2
3
4
5
6
def crossover(parent1, parent2):
child1, child2 = parent1[:], parent2[:] # 深拷贝父代,避免直接修改父代
for i in range(NUM_CLASSES): # 对每个班级进行交叉操作
if random.random() < CROSSOVER_RATE: # 如果随机值小于交叉概率
child1[i], child2[i] = parent2[i], parent1[i] # 交换两个父代的课表
return child1, child2 # 返回两个新的子代

解释:

  1. 深拷贝父代

    1
    child1, child2 = parent1[:], parent2[:]

    使用切片 [:] 对父代 parent1parent2 进行深拷贝,这样可以避免直接修改父代个体。

  2. 交叉操作

    1
    2
    3
    for i in range(NUM_CLASSES):
    if random.random() < CROSSOVER_RATE:
    child1[i], child2[i] = parent2[i], parent1[i]
    • 通过遍历每个班级 (NUM_CLASSES 表示班级数量),我们尝试在每个班级进行交叉操作。
    • random.random() < CROSSOVER_RATE:生成一个介于 0 到 1 之间的随机数。如果该随机数小于设定的 CROSSOVER_RATE(交叉概率),则进行交叉。
    • 交叉操作的方式是:交换两个父代个体在当前班级(i)的排课方案。
  3. 返回子代

    1
    return child1, child2

    最终返回两个新的子代 child1child2

举例:

假设有两个父代课表,班级数量为 2,课程和教师安排如下:

  • parent1

    1
    2
    班级1:[("语文", "张老师"), ("数学", "李老师")]
    班级2:[("英语", "李老师"), ("音乐", "张老师")]
  • parent2

    1
    2
    班级1:[("英语", "张老师"), ("体育", "李老师")]
    班级2:[("数学", "李老师"), ("英语", "张老师")]

交叉后,可能生成两个新的子代:

  • child1

    1
    2
    班级1:[("英语", "张老师"), ("体育", "李老师")]
    班级2:[("数学", "李老师"), ("音乐", "张老师")]
  • child2

    1
    2
    班级1:[("语文", "张老师"), ("数学", "李老师")]
    班级2:[("英语", "李老师"), ("英语", "张老师")]

mutate 函数

变异操作模拟了基因突变的过程,它随机改变个体的一些基因,增加种群的多样性,有助于避免算法陷入局部最优解。

代码:

1
2
3
4
5
6
def mutate(schedule):
for class_schedule in schedule: # 对每个班级进行变异
if random.random() < MUTATION_RATE: # 如果随机值小于变异概率
i, j = random.sample(range(NUM_PERIODS), 2) # 随机选择两个时间段
class_schedule[i], class_schedule[j] = class_schedule[j], class_schedule[i] # 交换课程安排
return schedule # 返回变异后的课表

解释:

  1. 遍历每个班级的课表

    1
    for class_schedule in schedule:

    这段代码遍历每个班级的课表,schedule 是一个班级的课程安排(一个一周的课表)。

  2. 变异判断

    1
    if random.random() < MUTATION_RATE:

    使用 random.random() 生成一个介于 0 到 1 之间的随机数。如果随机数小于设定的 MUTATION_RATE(变异概率),则进行变异。

  3. 随机交换时间段的课程

    1
    2
    i, j = random.sample(range(NUM_PERIODS), 2)
    class_schedule[i], class_schedule[j] = class_schedule[j], class_schedule[i]
    • random.sample(range(NUM_PERIODS), 2):从班级的所有时间段中随机选择两个不同的时间段(ij)。
    • class_schedule[i], class_schedule[j] = class_schedule[j], class_schedule[i]:交换这两个时间段的课程安排。即变异操作是通过交换课表中的两节课,达到调整的目的。
  4. 返回变异后的课表

    1
    return schedule

举例:

假设有一个班级的课表如下:

1
班级1:[("语文", "张老师"), ("数学", "李老师"), ("英语", "张老师")]

假设变异概率 MUTATION_RATE = 0.5,随机选择时间段 0 和 2 进行交换,变异后的课表可能是:

1
班级1:[("英语", "张老师"), ("数学", "李老师"), ("语文", "张老师")]

总结

  • crossover:通过交换两个父代的课表部分(班级之间的课表)来生成新的后代,目的是将父代的优良特性结合起来。
  • mutate:随机改变某个班级课表中的时间安排(如交换两节课),增强种群的多样性,有助于跳出局部最优解。

这两个操作通过模拟自然界中的遗传和变异过程,不断生成新的排课方案,最终通过适应度函数评估其优劣,并在多代进化中找到最佳解决方案。

解释calculate_fitness的逻辑和过程

calculate_fitness 函数用于评估每个排课方案的质量。适应度(fitness)是遗传算法中的关键概念,用来衡量个体(即课表)的优劣。通过适应度的计算,遗传算法能够选择出表现较好的个体,进行交叉和变异操作,进而优化解。

calculate_fitness 函数的逻辑

适应度计算的过程通常根据多个因素来评估排课方案的质量。根据你提供的需求,适应度值通常由以下几个方面组成:

  • 冲突:一个教师不能同时上多个班的课,一个班级不能同时上多节课。
  • 时间约束:例如某些课程和教师对时间段的喜好或厌恶。
  • 时间均匀度:确保每个班级的课程在一周内均匀分布,避免课程安排过于集中或分散。
  • 硬约束与软约束:硬约束是必须遵守的规则,软约束是可以优化的目标。

以下是一个可能的 calculate_fitness 函数实现的框架,解释了这些计算如何影响最终的适应度值。

calculate_fitness 伪代码框架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def calculate_fitness(schedule):
fitness = 0 # 初始适应度为 0

# 1. 检查冲突:教师和班级时间重叠
fitness -= calculate_conflict_penalty(schedule)

# 2. 计算时间约束(硬约束):如教师不在禁用时间授课
fitness -= calculate_hard_constraints_penalty(schedule)

# 3. 计算时间偏好(软约束):如教师和班级的时间喜好
fitness += calculate_soft_constraints_penalty(schedule)

# 4. 计算时间均匀度:课程间隔的标准差
fitness -= calculate_time_uniformity_penalty(schedule)

return fitness # 返回适应度

1. 检查冲突:教师和班级的时间重叠

冲突是最重要的硬约束之一。如果一个教师在同一时间段同时被安排在多个班级上课,或者一个班级的课程被安排在同一时间段,都会导致冲突。

示例:

1
2
3
4
5
6
7
8
def calculate_conflict_penalty(schedule):
penalty = 0
for i in range(NUM_CLASSES):
for j in range(i + 1, NUM_CLASSES):
for period in range(NUM_PERIODS):
if schedule[i][period] == schedule[j][period]: # 同一时间段,课程冲突
penalty += 1
return penalty
  • schedule[i][period] == schedule[j][period] 检查是否存在教师或班级冲突。
  • 如果有冲突,适应度会减少(通过 penalty 计算)。

2. 硬约束:教师不在禁用时间授课

教师有时会有不可授课的时间段。例如,张老师周五上午有教研活动,这时就不应该安排他的课程。

示例:

1
2
3
4
5
6
7
8
9
def calculate_hard_constraints_penalty(schedule):
penalty = 0
for teacher in range(NUM_TEACHERS):
for period in range(NUM_PERIODS):
if is_teacher_unavailable(teacher, period): # 判断教师是否不可用
for class_schedule in schedule:
if class_schedule[period][teacher] is not None: # 如果在不可用时间安排课程
penalty += 1
return penalty
  • is_teacher_unavailable(teacher, period) 判断该教师是否在特定时间段不可用。
  • 如果安排了课程,违反了硬约束,适应度会受到惩罚。

3. 软约束:教师和班级的时间偏好

软约束通常是关于教师或班级的时间偏好。例如,音乐课最好安排在下午,教师喜欢在某个时间段授课。

示例:

1
2
3
4
5
6
7
8
9
def calculate_soft_constraints_penalty(schedule):
penalty = 0
for class_schedule in schedule:
for period in range(NUM_PERIODS):
if is_preferred_time(class_schedule[period]):
penalty += 1 # 偏好时间
if is_disliked_time(class_schedule[period]):
penalty -= 1 # 不喜欢的时间
return penalty
  • is_preferred_time()is_disliked_time() 判断时间段是否符合偏好或不喜欢的时间。
  • 偏好时间给予奖励(适应度增加),不喜欢的时间会扣分(适应度减少)。

4. 时间均匀度:课程间隔的标准差

为了确保班级的课程在一周内均匀分布,可以计算所有课程的时间间隔,并使用标准差来评估其均匀性。

示例:

1
2
3
4
5
6
7
8
def calculate_time_uniformity_penalty(schedule):
intervals = []
for class_schedule in schedule:
for period in range(1, NUM_PERIODS):
if class_schedule[period] != class_schedule[period - 1]: # 课程之间的间隔
intervals.append(period - (period - 1)) # 记录课程之间的间隔
std_dev = np.std(intervals) # 计算标准差
return std_dev # 标准差越大,表示课程不均匀
  • 课程之间的时间间隔越大,标准差越大,意味着时间分布不均匀。
  • 适应度减少,表示排课不理想。

总结

calculate_fitness 函数的目的是通过综合评估各种约束条件(冲突、硬约束、软约束、时间均匀度等)来确定每个排课方案的优劣。最终,适应度值越高的个体表示排课方案越优,适应度值越低的个体则意味着排课方案有待优化。

为什么best_fitness >= 0要提前终止

在遗传算法中,提前终止是为了优化计算效率。当发现已经找到足够好的解时,可以终止算法,避免不必要的计算。

在这个排课问题中,best_fitness >= 0 作为提前终止的条件,通常基于以下逻辑:


1. 适应度值的定义

适应度值通常由以下部分组成:

  • 硬约束的惩罚(负值):如冲突、教师不可用时间安排课程等。
  • 软约束的奖励或惩罚(正值或负值):如课程时间的偏好。
  • 其他优化目标的惩罚或奖励(正值或负值)。

best_fitness >= 0 表示硬约束被完全满足(没有冲突或硬性错误),且可能还有一定的软约束奖励,这意味着当前排课方案已经是一个可行解。


2. 提前终止的原因

  • 硬约束满足即可接受:在实际排课问题中,硬约束通常是必须满足的条件。如果当前解已经满足所有硬约束,继续优化仅仅是为了满足软约束,而软约束通常可以妥协。
  • 节省计算时间:遗传算法可能需要较多代数(iterations)来达到全局最优解。如果当前解已经是一个可行的解,提前终止可以节省计算时间。
  • 目标达成:当 best_fitness >= 0 时,适应度值表明硬约束已经满足,解已经足够好,可以满足需求。

3. 适应度值为正的意义

如果 fitness >= 0

  • 硬约束的总惩罚为 0
    • 没有教师或班级的时间冲突。
    • 没有违反教师的不可用时间。
  • 可能满足部分软约束
    • 一些教师或班级的时间偏好得到了优化,增加了适应度值。
    • 时间分布可能较为均匀,减少了均匀度的惩罚。

例如:

  • 硬约束冲突得分 = -10 分,软约束奖励 = +15 分,fitness = 15 - 10 = 5,表示当前解是可行解,且有部分软约束优化。

4. 如果不提前终止的风险

  • 浪费时间:如果解已经足够好,继续迭代可能无法显著改善解,反而浪费计算资源。
  • 陷入局部最优:遗传算法的随机特性可能导致解围绕当前局部最优来回变化,难以显著提升适应度。

5. 改进的提前终止条件

如果希望进一步优化,还可以引入改进的终止条件,比如:

  • 连续若干代(如 50 代)适应度没有显著提升。
  • 达到最大代数(如 1000 代)。
  • 硬约束满足后,尝试优化软约束的一个目标值。

总结

best_fitness >= 0 作为提前终止条件是合理的,因为它确保了解已经满足硬约束的基本要求,同时可能在一定程度上优化了软约束。这种终止条件适合在硬约束较为严格的排课问题中,帮助节省计算时间并快速得到可用的排课方案。