14 Commits

Author SHA1 Message Date
b4fcf6562e lab6 2025-11-21 20:35:50 +03:00
Artem
93ab829cff Use matplotlib for lab6 visuals and expand report 2025-11-21 17:29:02 +03:00
Artem
9f591dadda Refine lab6 assets and report comparison 2025-11-21 17:00:45 +03:00
7394e5b9fb Данные по коммивояжёру 2025-11-21 16:29:45 +03:00
1f80f2f7dc Заготовка lab6 2025-11-21 16:24:30 +03:00
b6c19c5240 Предупреждение 2025-11-13 14:32:23 +03:00
cf9fc98376 теории добавил 2025-11-13 14:29:26 +03:00
6400996fcf Таблицы 2025-11-12 15:46:37 +03:00
957de42e16 Мелкие правки 2025-11-12 15:23:03 +03:00
Artem
f213bc3fb5 Document visualization workflow for lab 5 report 2025-11-12 15:06:53 +03:00
ca1095671e Обновил импорт 2025-11-10 10:38:03 +03:00
7ec38a3385 Отчёт 2025-11-08 23:22:02 +03:00
4b2398ae05 рисование дерева 2025-11-08 23:21:42 +03:00
bacfa20061 mutation 2025-11-07 12:54:27 +03:00
45 changed files with 3472 additions and 178 deletions

7
.gitignore vendored
View File

@@ -3,4 +3,9 @@
!**/
!*.gitignore
!*.py
!lab4/*
!.gitkeep
!lab3/data.txt
!lab4/*
!lab5/report/report.tex
!lab5/README.md
!lab6/README.md

89
lab3/data.txt Normal file
View File

@@ -0,0 +1,89 @@
1 11511.3889 42106.3889
2 11503.0556 42855.2778
3 11438.3333 42057.2222
4 11438.3333 42057.2222
5 11438.3333 42057.2222
6 11785.2778 42884.4444
7 11785.2778 42884.4444
8 11785.2778 42884.4444
9 11785.2778 42884.4444
10 12363.3333 43189.1667
11 11846.9444 42660.5556
12 11503.0556 42855.2778
13 11963.0556 43290.5556
14 11963.0556 43290.5556
15 12300.0000 42433.3333
16 11973.0556 43026.1111
17 11973.0556 43026.1111
18 11461.1111 43252.7778
19 11461.1111 43252.7778
20 11461.1111 43252.7778
21 11461.1111 43252.7778
22 11600.0000 43150.0000
23 12386.6667 43334.7222
24 12386.6667 43334.7222
25 11595.0000 43148.0556
26 11595.0000 43148.0556
27 11569.4444 43136.6667
28 11310.2778 42929.4444
29 11310.2778 42929.4444
30 11310.2778 42929.4444
31 11963.0556 43290.5556
32 11416.6667 42983.3333
33 11416.6667 42983.3333
34 11595.0000 43148.0556
35 12149.4444 42477.5000
36 11595.0000 43148.0556
37 11595.0000 43148.0556
38 11108.6111 42373.8889
39 11108.6111 42373.8889
40 11108.6111 42373.8889
41 11108.6111 42373.8889
42 11183.3333 42933.3333
43 12372.7778 42711.3889
44 11583.3333 43150.0000
45 11583.3333 43150.0000
46 11583.3333 43150.0000
47 11583.3333 43150.0000
48 11583.3333 43150.0000
49 11822.7778 42673.6111
50 11822.7778 42673.6111
51 12058.3333 42195.5556
52 11003.6111 42102.5000
53 11003.6111 42102.5000
54 11003.6111 42102.5000
55 11522.2222 42841.9444
56 12386.6667 43334.7222
57 12386.6667 43334.7222
58 12386.6667 43334.7222
59 11569.4444 43136.6667
60 11569.4444 43136.6667
61 11569.4444 43136.6667
62 11155.8333 42712.5000
63 11155.8333 42712.5000
64 11155.8333 42712.5000
65 11155.8333 42712.5000
66 11133.3333 42885.8333
67 11133.3333 42885.8333
68 11133.3333 42885.8333
69 11133.3333 42885.8333
70 11133.3333 42885.8333
71 11003.6111 42102.5000
72 11770.2778 42651.9444
73 11133.3333 42885.8333
74 11690.5556 42686.6667
75 11690.5556 42686.6667
76 11751.1111 42814.4444
77 12645.0000 42973.3333
78 12421.6667 42895.5556
79 12421.6667 42895.5556
80 11485.5556 43187.2222
81 11423.8889 43000.2778
82 11423.8889 43000.2778
83 11715.8333 41836.1111
84 11297.5000 42853.3333
85 11297.5000 42853.3333
86 11583.3333 43150.0000
87 11569.4444 43136.6667
88 12286.9444 43355.5556
89 12355.8333 43156.3889

50
lab4/draw_tree.py Normal file
View File

@@ -0,0 +1,50 @@
from graphviz import Digraph
def make_pow2_sum_tree(n=8):
dot = Digraph("FullTree")
dot.attr(rankdir="TB") # направление сверху вниз
dot.attr("node", shape="circle", style="filled", fillcolor="lightgray")
node_count = 0
def new_node(label):
nonlocal node_count
node_id = f"n{node_count}"
node_count += 1
dot.node(node_id, label)
return node_id
def pow2_node(xi):
n1 = new_node("pow2")
n2 = new_node(xi)
dot.edge(n1, n2)
return n1
def plus(a, b):
n = new_node("+")
dot.edge(n, a)
dot.edge(n, b)
return n
all_terms = []
for i in range(1, n + 1):
terms = [pow2_node(f"x{j}") for j in range(1, i + 1)]
s = terms[0]
for t in terms[1:]:
s = plus(s, t)
all_terms.append(s)
root = all_terms[0]
for t in all_terms[1:]:
root = plus(root, t)
dot.node("root", "f(x)")
dot.edge("root", root)
return dot
if __name__ == "__main__":
g = make_pow2_sum_tree(8)
g.render("original_tree", format="png", cleanup=True)

View File

@@ -1,3 +0,0 @@
from .chromosome import Chromosome
__all__ = ["Chromosome"]

View File

@@ -6,18 +6,19 @@ from copy import deepcopy
from dataclasses import asdict, dataclass
from typing import Callable
import graphviz
import numpy as np
from matplotlib import pyplot as plt
from numpy.typing import NDArray
from .chromosome import Chromosome
from .node import Node
from .types import Fitnesses, Population
type FitnessFn = Callable[[Chromosome], float]
type InitializePopulationFn = Callable[[int], Population]
type CrossoverFn = Callable[[Chromosome, Chromosome], tuple[Chromosome, Chromosome]]
type MutationFn = Callable[[Chromosome, int], Chromosome]
type MutationFn = Callable[[Chromosome], Chromosome]
type SelectionFn = Callable[[Population, Fitnesses], Population]
@@ -66,6 +67,7 @@ class Generation:
number: int
best: Chromosome
best_fitness: float
avg_fitness: float
population: Population
fitnesses: Fitnesses
@@ -132,7 +134,7 @@ def mutation(
next_population = []
for chrom in population:
next_population.append(
mutation_fn(chrom, gen_num) if np.random.random() <= pm else chrom
mutation_fn(chrom) if np.random.random() <= pm else chrom
)
return next_population
@@ -148,27 +150,45 @@ def eval_population(population: Population, fitness_func: FitnessFn) -> Fitnesse
return np.array([fitness_func(chrom) for chrom in population])
def render_tree_to_graphviz(
node: Node, graph: graphviz.Digraph, node_id: str = "0"
) -> None:
"""Рекурсивно добавляет узлы дерева в graphviz граф."""
graph.node(node_id, label=node.value.name)
for i, child in enumerate(node.children):
child_id = f"{node_id}_{i}"
render_tree_to_graphviz(child, graph, child_id)
graph.edge(node_id, child_id)
def save_generation(
generation: Generation, history: list[Generation], config: GARunConfig
) -> None:
"""Сохраняет визуализацию лучшей хромосомы поколения в виде дерева."""
os.makedirs(config.results_dir, exist_ok=True)
fig = plt.figure(figsize=(7, 7))
fig.suptitle(
f"Поколение #{generation.number}. "
f"Лучшая особь: {generation.best_fitness:.0f}. "
f"Среднее значение: {np.mean(generation.fitnesses):.0f}",
fontsize=14,
y=0.95,
# Создаем граф для визуализации дерева
dot = graphviz.Digraph(comment=f"Generation {generation.number}")
dot.attr(rankdir="TB") # Top to Bottom direction
dot.attr("node", shape="circle", style="filled", fillcolor="lightblue")
# Добавляем заголовок
depth = generation.best.root.get_depth()
title = (
f"Поколение #{generation.number}\\n"
f"Лучшая особь: {generation.best_fitness:.4f}\\n"
f"Глубина дерева: {depth}"
)
dot.attr(label=title, labelloc="t", fontsize="14")
# Рисуем
...
# Рендерим дерево
render_tree_to_graphviz(generation.best.root, dot)
filename = f"generation_{generation.number:03d}.png"
path_png = os.path.join(config.results_dir, filename)
fig.savefig(path_png, dpi=150, bbox_inches="tight")
plt.close(fig)
# Сохраняем
filename = f"generation_{generation.number:03d}"
filepath = os.path.join(config.results_dir, filename)
dot.render(filepath, format="png", cleanup=True)
def genetic_algorithm(config: GARunConfig) -> GARunResult:
@@ -216,6 +236,7 @@ def genetic_algorithm(config: GARunConfig) -> GARunResult:
number=generation_number,
best=population[best_index],
best_fitness=fitnesses[best_index],
avg_fitness=float(np.mean(fitnesses)),
# population=deepcopy(population),
population=[],
# fitnesses=deepcopy(fitnesses),
@@ -301,41 +322,61 @@ def genetic_algorithm(config: GARunConfig) -> GARunResult:
end = time.perf_counter()
assert best is not None, "Best was never set"
return GARunResult(
result = GARunResult(
len(history),
best,
history,
(end - start) * 1000.0,
)
# Автоматически строим графики истории фитнеса
if config.save_generations:
plot_fitness_history(result, save_dir=config.results_dir)
def plot_fitness_history(result: GARunResult, save_path: str | None = None) -> None:
"""Рисует график изменения лучших и средних значений фитнеса по поколениям."""
return result
def plot_fitness_history(result: GARunResult, save_dir: str | None = None) -> None:
"""Рисует графики изменения лучших и средних значений фитнеса по поколениям.
Создает два отдельных графика:
- fitness_best.png - график лучших значений
- fitness_avg.png - график средних значений
"""
generations = [gen.number for gen in result.history]
best_fitnesses = [gen.best_fitness for gen in result.history]
avg_fitnesses = [np.mean(gen.fitnesses) for gen in result.history]
avg_fitnesses = [gen.avg_fitness for gen in result.history]
fig, ax = plt.subplots(figsize=(10, 6))
# График лучших значений
fig_best, ax_best = plt.subplots(figsize=(10, 6))
ax_best.plot(generations, best_fitnesses, linewidth=2, color="blue")
ax_best.set_xlabel("Поколение", fontsize=12)
ax_best.set_ylabel("Лучшее значение фитнес-функции", fontsize=12)
ax_best.set_title("Лучшее значение фитнеса по поколениям", fontsize=14)
ax_best.grid(True, alpha=0.3)
ax.plot(
generations, best_fitnesses, label="Лучшее значение", linewidth=2, color="blue"
)
ax.plot(
generations,
avg_fitnesses,
label="Среднее значение",
linewidth=2,
color="orange",
)
ax.set_xlabel("Поколение", fontsize=12)
ax.set_ylabel("Значение фитнес-функции", fontsize=12)
ax.legend(fontsize=11)
ax.grid(True, alpha=0.3)
if save_path:
fig.savefig(save_path, dpi=150, bbox_inches="tight")
print(f"График сохранен в {save_path}")
if save_dir:
best_path = os.path.join(save_dir, "fitness_best.png")
fig_best.savefig(best_path, dpi=150, bbox_inches="tight")
print(f"График лучших значений сохранен в {best_path}")
else:
plt.show()
plt.close(fig)
plt.close(fig_best)
# График средних значений
fig_avg, ax_avg = plt.subplots(figsize=(10, 6))
ax_avg.plot(generations, avg_fitnesses, linewidth=2, color="orange")
ax_avg.set_xlabel("Поколение", fontsize=12)
ax_avg.set_ylabel("Среднее значение фитнес-функции", fontsize=12)
ax_avg.set_title("Среднее значение фитнеса по поколениям", fontsize=14)
ax_avg.grid(True, alpha=0.3)
if save_dir:
avg_path = os.path.join(save_dir, "fitness_avg.png")
fig_avg.savefig(avg_path, dpi=150, bbox_inches="tight")
print(f"График средних значений сохранен в {avg_path}")
else:
plt.show()
plt.close(fig_avg)

View File

@@ -1,45 +1,58 @@
import random
from abc import ABC, abstractmethod
from typing import Sequence
from .chromosome import Chromosome
def shrink_mutation(chromosome: Chromosome) -> Chromosome:
class BaseMutation(ABC):
@abstractmethod
def mutate(self, chromosome: Chromosome) -> Chromosome: ...
def __call__(self, chromosome: Chromosome) -> Chromosome:
chromosome = chromosome.copy()
return self.mutate(chromosome)
class ShrinkMutation(BaseMutation):
"""Усекающая мутация. Заменяет случайно выбранную операцию на случайный терминал."""
chromosome = chromosome.copy()
operation_nodes = [n for n in chromosome.root.list_nodes() if n.value.arity > 0]
def mutate(self, chromosome: Chromosome) -> Chromosome:
operation_nodes = [n for n in chromosome.root.list_nodes() if n.value.arity > 0]
if not operation_nodes:
return chromosome
target_node = random.choice(operation_nodes)
target_node.prune(chromosome.terminals, max_depth=1)
if not operation_nodes:
return chromosome
target_node = random.choice(operation_nodes)
target_node.prune(chromosome.terminals, max_depth=1)
return chromosome
def grow_mutation(chromosome: Chromosome, max_depth: int) -> Chromosome:
class GrowMutation(BaseMutation):
"""Растущая мутация. Заменяет случайно выбранный узел на случайное поддерево."""
chromosome = chromosome.copy()
target_node = random.choice(chromosome.root.list_nodes())
def __init__(self, max_depth: int):
self.max_depth = max_depth
max_subtree_depth = max_depth - target_node.get_level() + 1
def mutate(self, chromosome: Chromosome) -> Chromosome:
target_node = random.choice(chromosome.root.list_nodes())
subtree = Chromosome.grow_init(
chromosome.terminals, chromosome.operations, max_subtree_depth
).root
max_subtree_depth = self.max_depth - target_node.get_level() + 1
if target_node.parent:
target_node.parent.replace_child(target_node, subtree)
else:
chromosome.root = subtree
subtree = Chromosome.grow_init(
chromosome.terminals, chromosome.operations, max_subtree_depth
).root
return chromosome
if target_node.parent:
target_node.parent.replace_child(target_node, subtree)
else:
chromosome.root = subtree
return chromosome
def node_replacement_mutation(chromosome: Chromosome) -> Chromosome:
class NodeReplacementMutation(BaseMutation):
"""Мутация замены операции (Node Replacement Mutation).
Выбирает случайный узел и заменяет его
@@ -47,48 +60,72 @@ def node_replacement_mutation(chromosome: Chromosome) -> Chromosome:
Если подходящей альтернативы нет — возвращает копию без изменений.
"""
chromosome = chromosome.copy()
target_node = random.choice(chromosome.root.list_nodes())
current_arity = target_node.value.arity
def mutate(self, chromosome: Chromosome) -> Chromosome:
target_node = random.choice(chromosome.root.list_nodes())
current_arity = target_node.value.arity
same_arity = [
op
for op in list(chromosome.operations) + list(chromosome.terminals)
if op.arity == current_arity and op != target_node.value
]
if not same_arity:
return chromosome
new_operation = random.choice(same_arity)
target_node.value = new_operation
same_arity = [
op
for op in list(chromosome.operations) + list(chromosome.terminals)
if op.arity == current_arity and op != target_node.value
]
if not same_arity:
return chromosome
new_operation = random.choice(same_arity)
target_node.value = new_operation
class HoistMutation(BaseMutation):
def mutate(self, chromosome: Chromosome) -> Chromosome:
"""Hoist-мутация (анти-bloat).
return chromosome
Выбирает случайное поддерево, затем внутри него — случайное поддерево меньшей
глубины, и заменяет исходное поддерево на это внутреннее.
В результате дерево становится короче, сохраняя часть структуры.
"""
operation_nodes = [n for n in chromosome.root.list_nodes() if n.value.arity > 0]
if not operation_nodes:
return chromosome
outer_subtree = random.choice(operation_nodes)
outer_nodes = outer_subtree.list_nodes()[1:] # исключаем корень
inner_subtree = random.choice(outer_nodes).copy_subtree()
if outer_subtree.parent:
outer_subtree.parent.replace_child(outer_subtree, inner_subtree)
else:
chromosome.root = inner_subtree
return chromosome
def hoist_mutation(chromosome: Chromosome) -> Chromosome:
"""Hoist-мутация (анти-bloat).
class CombinedMutation(BaseMutation):
"""Комбинированная мутация.
Выбирает случайное поддерево, затем внутри него — случайное поддерево меньшей глубины,
и заменяет исходное поддерево на это внутреннее.
В результате дерево становится короче, сохраняя часть структуры.
Принимает список (или словарь) мутаций и случайно выбирает одну из них
для применения. Можно задать веса вероятностей.
"""
chromosome = chromosome.copy()
operation_nodes = [n for n in chromosome.root.list_nodes() if n.value.arity > 0]
if not operation_nodes:
return chromosome
def __init__(
self, mutations: Sequence[BaseMutation], probs: Sequence[float] | None = None
):
if probs is not None:
assert abs(sum(probs) - 1.0) < 1e-8, (
"Сумма вероятностей должна быть равна 1"
)
assert len(probs) == len(mutations), (
"Число вероятностей должно совпадать с числом мутаций"
)
self.mutations = mutations
self.probs = probs
outer_subtree = random.choice(operation_nodes)
outer_nodes = outer_subtree.list_nodes()[1:] # исключаем корень
inner_subtree = random.choice(outer_nodes).copy_subtree()
if outer_subtree.parent:
outer_subtree.parent.replace_child(outer_subtree, inner_subtree)
else:
chromosome.root = inner_subtree
return chromosome
def mutate(self, chromosome: Chromosome) -> Chromosome:
mutation = random.choices(self.mutations, weights=self.probs, k=1)[0]
return mutation(chromosome)

View File

@@ -1,30 +0,0 @@
from typing import Sequence
from .chromosome import Chromosome
from .primitive import Primitive
type Population = list[Chromosome]
def ramped_initialization(
chromosomes_per_variation: int,
depths: list[int],
terminals: Sequence[Primitive],
operations: Sequence[Primitive],
) -> Population:
"""Комбинация методов grow и full инициализации хромосом для инициализации начальной
популяции.
"""
population: Population = []
for depth in depths:
population.extend(
Chromosome.full_init(terminals, operations, depth)
for _ in range(chromosomes_per_variation)
)
population.extend(
Chromosome.grow_init(terminals, operations, depth)
for _ in range(chromosomes_per_variation)
)
return population

View File

@@ -1,29 +1,34 @@
"""
graphviz должен быть доступен в PATH (недостаточно просто установить через pip)
Можно проверить командой
dot -V
"""
import random
from math import log
import numpy as np
from numpy.typing import NDArray
from gp import Chromosome
from gp.crossovers import crossover_subtree
from gp.fitness import (
MAEFitness,
MSEFitness,
NRMSEFitness,
PenalizedFitness,
RMSEFitness,
)
from gp.ga import GARunConfig, genetic_algorithm
from gp.mutations import (
grow_mutation,
hoist_mutation,
node_replacement_mutation,
shrink_mutation,
CombinedMutation,
GrowMutation,
HoistMutation,
NodeReplacementMutation,
ShrinkMutation,
)
from gp.ops import ADD, COS, DIV, EXP, MUL, NEG, POW, SIN, SQUARE, SUB
from gp.population import ramped_initialization
from gp.primitive import Const, Var
from gp.selection import roulette_selection, tournament_selection
from gp.ops import ADD, COS, DIV, EXP, MUL, POW, SIN, SQUARE, SUB
from gp.primitive import Var
from gp.selection import tournament_selection
from gp.utils import ramped_initialization
NUM_VARS = 8
TEST_POINTS = 10000
@@ -33,10 +38,7 @@ SEED = 17
np.random.seed(SEED)
random.seed(SEED)
X = np.random.uniform(-5.536, 5.536, size=(TEST_POINTS, NUM_VARS))
# axes = [np.linspace(-5.536, 5.536, TEST_POINTS) for _ in range(NUM_VARS)]
# X = np.array(np.meshgrid(*axes)).T.reshape(-1, NUM_VARS)
operations = [SQUARE, SIN, COS, EXP, ADD, SUB, MUL, DIV, POW]
# operations = [SQUARE, ADD, SUB, MUL]
terminals = [Var(f"x{i}") for i in range(1, NUM_VARS + 1)]
@@ -53,36 +55,16 @@ def target_function(x: NDArray[np.float64]) -> NDArray[np.float64]:
return np.sum(prefix_sums, axis=1)
# fitness_function = MSEFitness(target_function, lambda: X)
# fitness_function = HuberFitness(target_function, lambda: X, delta=0.5)
# fitness_function = PenalizedFitness(
# target_function, lambda: X, base_fitness=fitness, lambda_=0.1
# )
# fitness_function = NRMSEFitness(target_function, lambda: X)
fitness_function = RMSEFitness(target_function, lambda: X)
# fitness_function = PenalizedFitness(
# target_function, lambda: X, base_fitness=fitness_function, lambda_=0.0001
# )
def adaptive_mutation(
chromosome: Chromosome,
generation: int,
max_generations: int,
max_depth: int,
) -> Chromosome:
r = random.random()
if r < 0.4:
return grow_mutation(chromosome, max_depth=max_depth)
elif r < 0.7:
return node_replacement_mutation(chromosome)
elif r < 0.85:
return hoist_mutation(chromosome)
return shrink_mutation(chromosome)
combined_mutation = CombinedMutation(
mutations=[
GrowMutation(max_depth=MAX_DEPTH),
NodeReplacementMutation(),
HoistMutation(),
ShrinkMutation(),
],
probs=[0.4, 0.3, 0.15, 0.15],
)
init_population = ramped_initialization(
20, [i for i in range(MAX_DEPTH - 9, MAX_DEPTH + 1)], terminals, operations
@@ -93,10 +75,7 @@ print("Population size:", len(init_population))
config = GARunConfig(
fitness_func=fitness_function,
crossover_fn=lambda p1, p2: crossover_subtree(p1, p2, max_depth=MAX_DEPTH),
mutation_fn=lambda chrom, gen_num: adaptive_mutation(
chrom, gen_num, MAX_GENERATIONS, MAX_DEPTH
),
# selection_fn=roulette_selection,
mutation_fn=combined_mutation,
selection_fn=lambda p, f: tournament_selection(p, f, k=3),
init_population=init_population,
seed=SEED,
@@ -105,6 +84,7 @@ config = GARunConfig(
elitism=15,
max_generations=MAX_GENERATIONS,
log_every_generation=True,
save_generations=[1, 10, 20, 30, 40, 50, 100, 150, 200],
)
result = genetic_algorithm(config)

BIN
lab4/original_tree.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

View File

@@ -3,6 +3,7 @@ name = "lab4"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
"graphviz>=0.21",
"matplotlib>=3.10.7",
"numpy>=2.3.4",
]

6
lab4/report/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
*
!**/
!.gitignore
!report.tex
!img/**/*.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

705
lab4/report/report.tex Normal file
View File

@@ -0,0 +1,705 @@
\documentclass[a4paper, final]{article}
%\usepackage{literat} % Нормальные шрифты
\usepackage[14pt]{extsizes} % для того чтобы задать нестандартный 14-ый размер шрифта
\usepackage{tabularx}
\usepackage{booktabs}
\usepackage[T2A]{fontenc}
\usepackage[utf8]{inputenc}
\usepackage[russian]{babel}
\usepackage{amsmath}
\usepackage[left=25mm, top=20mm, right=20mm, bottom=20mm, footskip=10mm]{geometry}
\usepackage{ragged2e} %для растягивания по ширине
\usepackage{setspace} %для межстрочно го интервала
\usepackage{moreverb} %для работы с листингами
\usepackage{indentfirst} % для абзацного отступа
\usepackage{moreverb} %для печати в листинге исходного кода программ
\usepackage{pdfpages} %для вставки других pdf файлов
\usepackage{tikz}
\usepackage{graphicx}
\usepackage{afterpage}
\usepackage{longtable}
\usepackage{float}
\usepackage{xcolor}
% \usepackage[paper=A4,DIV=12]{typearea}
\usepackage{pdflscape}
% \usepackage{lscape}
\usepackage{array}
\usepackage{multirow}
\renewcommand\verbatimtabsize{4\relax}
\renewcommand\listingoffset{0.2em} %отступ от номеров строк в листинге
\renewcommand{\arraystretch}{1.4} % изменяю высоту строки в таблице
\usepackage[font=small, singlelinecheck=false, justification=centering, format=plain, labelsep=period]{caption} %для настройки заголовка таблицы
\usepackage{listings} %листинги
\usepackage{xcolor} % цвета
\usepackage{hyperref}% для гиперссылок
\usepackage{enumitem} %для перечислений
\newcommand{\specialcell}[2][l]{\begin{tabular}[#1]{@{}l@{}}#2\end{tabular}}
\setlist[enumerate,itemize]{leftmargin=1.2cm} %отступ в перечислениях
\hypersetup{colorlinks,
allcolors=[RGB]{010 090 200}} %красивые гиперссылки (не красные)
% подгружаемые языки — подробнее в документации listings (это всё для листингов)
% включаем кириллицу и добавляем кое−какие опции
\lstset{tabsize=2,
breaklines,
basicstyle=\footnotesize,
columns=fullflexible,
flexiblecolumns,
numbers=left,
numberstyle={\footnotesize},
keywordstyle=\color{blue},
inputencoding=cp1251,
extendedchars=true
}
\textheight=24cm % высота текста
\textwidth=16cm % ширина текста
\oddsidemargin=0pt % отступ от левого края
\topmargin=-1.5cm % отступ от верхнего края
\parindent=24pt % абзацный отступ
\parskip=5pt % интервал между абзацами
\tolerance=2000 % терпимость к "жидким" строкам
\flushbottom % выравнивание высоты страниц
% Настройка листингов
\lstset{
language=python,
extendedchars=\true,
inputencoding=utf8,
keepspaces=true,
% captionpos=b, % подписи листингов снизу
}
\begin{document} % начало документа
% НАЧАЛО ТИТУЛЬНОГО ЛИСТА
\begin{center}
\hfill \break
\hfill \break
\normalsize{МИНИСТЕРСТВО НАУКИ И ВЫСШЕГО ОБРАЗОВАНИЯ РОССИЙСКОЙ ФЕДЕРАЦИИ\\
федеральное государственное автономное образовательное учреждение высшего образования «Санкт-Петербургский политехнический университет Петра Великого»\\[10pt]}
\normalsize{Институт компьютерных наук и кибербезопасности}\\[10pt]
\normalsize{Высшая школа технологий искусственного интеллекта}\\[10pt]
\normalsize{Направление: 02.03.01 <<Математика и компьютерные науки>>}\\
\hfill \break
\hfill \break
\hfill \break
\hfill \break
\large{Лабораторная работа №4}\\
\large{по дисциплине}\\
\large{<<Генетические алгоритмы>>}\\
\large{Вариант 18}\\
% \hfill \break
\hfill \break
\end{center}
\small{
\begin{tabular}{lrrl}
\!\!\!Студент, & \hspace{2cm} & & \\
\!\!\!группы 5130201/20101 & \hspace{2cm} & \underline{\hspace{3cm}} &Тищенко А. А. \\\\
\!\!\!Преподаватель & \hspace{2cm} & \underline{\hspace{3cm}} & Большаков А. А. \\\\
&&\hspace{4cm}
\end{tabular}
\begin{flushright}
<<\underline{\hspace{1cm}}>>\underline{\hspace{2.5cm}} 2025г.
\end{flushright}
}
\hfill \break
% \hfill \break
\begin{center} \small{Санкт-Петербург, 2025} \end{center}
\thispagestyle{empty} % выключаем отображение номера для этой страницы
% КОНЕЦ ТИТУЛЬНОГО ЛИСТА
\newpage
\tableofcontents
\newpage
\section {Постановка задачи}
В данной работе были поставлены следующие задачи:
\begin{itemize}
\item Разработать эволюционный алгоритм, реализующий ГП для нахождения заданной по варианту функции.
\begin{itemize}
\item Структура для представления программы древовидное представление.
\item Терминальное множество: переменные $x_1, x_2, x_3, \ldots, x_n$, и константы в соответствии с заданием по варианту.
\item Функциональное множество: $+$, $-$, $*$, $/$, $abs()$, $sin()$, $cos()$, $exp()$, возведение в степень.
\item Фитнесс-функция мера близости между реальными значениями выхода и требуемыми.
\end{itemize}
\item Представить графически найденное решение на каждой итерации.
\item Сравнить найденное решение с представленным в условии задачи.
\end{itemize}
\textbf{Индивидуальное задание вариант 18:}
\textbf{Дано:} Функция
$$f(x) = \sum_{i=1}^{n} \sum_{j=1}^{i} x_j^2, \text{ где } x_j \in [-5.536, 5.536] \text{ для всех } j = 1, \ldots, n, \text{ а } n = 8.$$
\newpage
\section{Теоретические сведения}
\subsection{Генетическое программирование}
\textbf{Генетическое программирование} (ГП) — разновидность эволюционных алгоритмов, в которых особь представляет собой программу, автоматически создаваемую для решения задачи. В отличие от генетических алгоритмов с фиксированной структурой хромосом, в ГП особи имеют переменную длину, что требует специальных методов кодирования, инициализации и генетических операторов. Ключевая идея ГП — представление программы на высоком уровне абстракции с учётом структуры компьютерных программ.
Оценка программ выполняется с помощью фитнесс-функции, отражающей степень соответствия решения требованиям задачи. Обычно используются метрики ошибки: среднеквадратичная ошибка, абсолютная ошибка или другие функции рассогласования между вычисленным и ожидаемым значением. Чем ниже ошибка, тем выше приспособленность особи.
\subsection{Терминальное и функциональное множества}
Программы формируются из \textbf{переменных}, \textbf{констант} и \textbf{функций}, связанных синтаксическими правилами. Для их описания необходимо определить два базовых множества:
\begin{itemize}
\item \textbf{Терминальное множество}, включающее константы и переменные.
\item \textbf{Функциональное множество}, состоящее из операторов и элементарных функций, таких как \( \exp(x) \), \( \sin(x) \) и других.
\end{itemize}
\subsubsection{Терминальное множество}
Терминальное множество включает:
\begin{enumerate}
\item Внешние входы программы.
\item Константы, используемые в программе.
\item Функции без аргументов.
\end{enumerate}
Термин «терминал» используется потому, что эти элементы соответствуют концевым (висячим) узлам в древовидных структурах и терминалам формальных грамматик. Терминал предоставляет численное значение, не требуя входных аргументов, то есть имеет нулевую арность. В классическом ГП на основе деревьев множество числовых констант выбирается для всей популяции и остается неизменным.
\subsubsection{Функциональное множество}
Функциональное множество состоит из операторов и различных функций. Оно может быть очень широким и включать типичные конструкции языков программирования, такие как:
\begin{itemize}
\item Логические функции: AND, OR, NOT;
\item Арифметические операции: $+$, $-$, $\times$, $\div$;
\item Трансцендентные функции: $\sin$, $\cos$, $\tan$, $\log$;
\item Операции присваивания: $a := 2$;
\item Условные операторы: if-then-else, switch/case;
\item Операторы переходов: go to, jump, call;
\item Операторы циклов: while, repeat-until, for;
\item Подпрограммы и пользовательские функции.
\end{itemize}
\subsection{Виды представления программ. Древовидное представление}
Среди наиболее распространённых структур для представления особей (потенциальных решений) в современном генетическом программировании можно выделить:
\begin{enumerate}
\item \textbf{Древовидное представление} — классический подход, где программы представляются в виде деревьев с операторами в узлах и терминалами в листьях
\item \textbf{Линейная структура} — программы записываются как последовательности инструкций, аналогично ассемблерному коду
\item \textbf{Графоподобная структура} — расширенное представление, допускающее множественные связи и переиспользование компонентов
\end{enumerate}
Древовидная форма представления является классической для ГП. Программа представляется в виде дерева, где внутренние узлы — это функции из функционального множества, а листья (терминальные узлы) — это переменные и константы из терминального
множества. Такая структура позволяет гибко работать с выражениями различной длины
и сложности
\subsection{Инициализация древовидных структур}
Сложность древовидных структур оценивается через максимальную глубину дерева $D_m$ или общее количество узлов. Процесс инициализации древовидных структур основан на случайном выборе функциональных и терминальных символов при заданном ограничении максимальной глубины. Рассмотрим пример с терминальным множеством:
Существуют два основных метода инициализации:
\subsubsection*{Полный метод (full)}
На всех уровнях, кроме последнего, выбираются только функциональные символы. Терминальные символы размещаются исключительно на уровне максимальной глубины $D_m$. Это гарантирует создание сбалансированных деревьев регулярной структуры.
\subsubsection*{Растущий метод (grow)}
На каждом шаге случайным образом выбирается либо функциональный, либо терминальный символ. Выбор терминала прекращает рост ветви, что приводит к формированию нерегулярных деревьев с различной глубиной листьев.
\subsection{Оператор кроссинговера на древовидных структурах}
Для древовидной формы представления программ в генетическом программировании применяются три основных типа операторов кроссинговера:
\begin{enumerate}[label=\alph*)]
\item Узловой ОК
\item Кроссинговер поддеревьев
\item Смешанный
\end{enumerate}
\subsubsection{Узловой оператор кроссинговера}
В узловом операторе кроссинговера выбираются два родителя (два дерева) и внутри них — узлы. Первый родитель называется доминантом, второй — рецессивом. Узлы могут различаться по типу, поэтому сначала необходимо проверить, что выбранные узлы взаимозаменяемы. Если типы не совпадают, выбирается другой узел во втором родителе, и проверка повторяется. После этого осуществляется обмен выбранных узлов между деревьями.
\subsubsection{Кроссинговер поддеревьев}
В кроссинговере поддеревьев не происходит обмен отдельными узлами, а определяется обмен поддеревьями. Он осуществляется следующим образом:
\begin{enumerate}
\item Выбираются два родителя (\textit{один — доминантный, другой — рецессивный}). Необходимо убедиться, что выбранные узлы взаимозаменяемы, то есть принадлежат одному типу. В противном случае выбирается другой узел в рецессивном дереве.
\item Производится обмен соответствующими поддеревьями.
\item Далее вычисляется предполагаемый размер потомков. Если он не превышает установленный порог, то обмен ветвями запоминается.
\end{enumerate}
При смешанном операторе кроссинговера для некоторых узлов выполняется узловой ОК, а для других - кроссинговер поддеревьев. В целом ОК выполняется следующим образом:
\begin{enumerate}
\item Выбор точек скрещивания \( P_1, P_2 \) в обоих родителях
\item Выбор типа кроссинговера с заданной вероятностью:
\begin{itemize}
\item Первый тип (обмен подграфами) с вероятностью \( P_G \)
\item Второй тип (линейный обмен) с вероятностью \( 1 - P_G \)
\end{itemize}
\item Если выбран первый тип и размер потомка не превышает порог, выполняется кроссинговер подграфами
\item Если выбран второй тип и размер потомка не превышает порог, выполняется линейный кроссинговер
\end{enumerate}
\subsection{Мутационные операторы для древовидных структур}
В контексте древовидного представления программ применяются следующие мутационные операторы:
\begin{enumerate}[label=\alph*)]
\item Мутация узлов (узловая)
\item Мутация с усечением (усекающая)
\item Мутация с ростом (растущая)
\item Hoist-мутация
\end{enumerate}
\textbf{Процедура узловой мутации} включает следующие шаги:
\begin{enumerate}
\item Случайный выбор целевого узла в дереве программы и идентификация его типа
\item Случайный выбор заменяющего узла того же типа из соответствующего множества (функционального или терминального)
\item Замена исходного узла на выбранный вариант
\end{enumerate}
\textbf{Алгоритм усекающей мутации} реализуется следующим образом:
\begin{enumerate}
\item Выбор узла, который будет подвергнут мутации
\item Случайный выбор терминального символа из допустимого множества
\item Удаление поддерева, корнем которого является выбранный узел
\item Замена удаленного поддерева терминальным символом
\end{enumerate}
\textbf{Алгоритм растущей мутации} реализуется следующим образом:
\begin{enumerate}
\item Определение узла, подвергаемого мутации
\item Если узел является терминальным, выбирается другой узел; для нетерминального узла производится удаление всех исходящих ветвей
\item Вычисление размера и сложности оставшейся части дерева
\item Генерация нового случайного поддерева, размер которого не превышает заданного порогового значения, и его размещение вместо удалённой части
\end{enumerate}
\textbf{Алгоритм Hoist-мутации} предназначен для борьбы с избыточным ростом деревьев (bloat) и реализуется следующим образом:
\begin{enumerate}
\item Случайный выбор поддерева с функциональным узлом в корне
\item Выбор случайного узла внутри этого поддерева (исключая корень выбранного поддерева)
\item Замена исходного поддерева на поддерево, начинающееся с выбранного внутреннего узла
\item В результате дерево становится короче, сохраняя при этом часть исходной структуры
\end{enumerate}
Данная мутация всегда уменьшает размер дерева, что помогает контролировать сложность программ и предотвращает неконтролируемый рост деревьев в процессе эволюции.
\textbf{Комбинированная мутация.} В реализованном алгоритме используется стратегия комбинированной мутации, которая на каждом шаге случайно выбирает один из четырёх описанных операторов с заданными вероятностями:
\begin{itemize}
\item Растущая мутация: $p = 0.40$
\item Узловая мутация: $p = 0.30$
\item Hoist-мутация: $p = 0.15$
\item Усекающая мутация: $p = 0.15$
\end{itemize}
Такой подход обеспечивает баланс между увеличением разнообразия популяции (растущая мутация), локальными изменениями (узловая мутация) и контролем размера деревьев (Hoist-мутация и усекающая мутация).
\subsection{Фитнес-функции в генетическом программировании}
В отличие от генетических алгоритмов, где фитнес-функция часто совпадает с исходной целевой функцией, в генетическом программировании фитнес-функция обычно измеряет степень соответствия между фактическими выходными значениями $y_i$ и целевыми значениями $d_i$. В качестве фитнес-функций часто используются метрики ошибок, такие как абсолютное отклонение или среднеквадратичная ошибка.
\newpage
\section{Особенности реализации}
В рамках работы создана библиотека \texttt{gp} для генетического программирования с древовидным представлением программ. Реализация выполнена на языке Python с использованием NumPy для векторизованных вычислений.
\subsection{Примитивы и операции (primitive.py, ops.py)}
Базовый класс \texttt{Primitive} представляет атомарные элементы дерева программы:
\begin{lstlisting}
@dataclass(frozen=True)
class Primitive:
name: str
arity: int # арность: 0 для терминалов, >0 для операций
operation_fn: OperationFn | None
\end{lstlisting}
Реализованы конструкторы для создания терминалов и операций: \texttt{Var(name: str)}, \texttt{Const(name: str, val: Value)}, \texttt{Operation(name: str, arity: int, fn)}.
Модуль \texttt{ops.py} содержит набор безопасных векторизованных операций. Функция \texttt{make\_safe} оборачивает операции для обработки некорректных значений:
\begin{lstlisting}
def make_safe(fn: Callable) -> Callable:
def wrapped(args: Sequence[Value]) -> Value:
with np.errstate(over="ignore", invalid="ignore",
divide="ignore", under="ignore"):
res = fn(args)
res = np.nan_to_num(res, nan=0.0, posinf=1e6, neginf=-1e6)
return np.clip(res, -1e6, 1e6)
return wrapped
\end{lstlisting}
Реализованы унарные операции (\texttt{NEG, SIN, COS, SQUARE, EXP}) и бинарные (\texttt{ADD, SUB, MUL, DIV, POW}). Для деления используется защита от деления на ноль, для возведения в степень -- ограничение показателя.
\subsection{Узлы дерева (node.py)}
Класс \texttt{Node} представляет узел дерева программы:
\begin{lstlisting}
class Node:
value: Primitive
parent: Node | None
children: list[Node]
\end{lstlisting}
Реализованы методы для манипуляций с деревом: \texttt{add\_child}, \texttt{replace\_child}, \texttt{copy\_subtree}. Метод \texttt{list\_nodes} возвращает список всех узлов поддерева (обход в глубину). Для контроля размера реализован метод \texttt{prune}, который усекает дерево до заданной глубины, заменяя операции на случайные терминалы.
Вычисление программы выполняется методом \texttt{eval}, который рекурсивно вычисляет значения поддеревьев и применяет операцию узла:
\begin{lstlisting}
def eval(self, context: Context) -> Value:
return self.value.eval(
[child.eval(context) for child in self.children],
context
)
\end{lstlisting}
Для кроссовера реализована функция \texttt{swap\_subtrees(a: Node, b: Node)}, которая обменивает два поддерева, корректно обновляя ссылки на родителей.
\subsection{Хромосомы (chromosome.py)}
Класс \texttt{Chromosome} инкапсулирует дерево программы вместе с множествами терминалов и операций:
\begin{lstlisting}
class Chromosome:
terminals: Sequence[Primitive]
operations: Sequence[Primitive]
root: Node
\end{lstlisting}
Реализованы два метода инициализации случайных деревьев:
\begin{itemize}
\item \texttt{full\_init(terminals, operations, max\_depth)} -- полная инициализация, где на каждом уровне до максимальной глубины выбираются только операции, а на последнем -- только терминалы.
\item \texttt{grow\_init(terminals, operations, max\_depth, terminal\_probability)} -- растущая инициализация с вероятностным выбором терминалов на каждом уровне, что создаёт деревья различной формы.
\end{itemize}
Комбинация этих методов (\textit{ramped half-and-half}) реализована в функции \texttt{ramped\_initialization}, которая создаёт начальную популяцию из деревьев различных глубин, используя оба метода поровну.
\subsection{Кроссовер (crossovers.py)}
Реализован оператор кроссовера поддеревьев:
\begin{lstlisting}
def crossover_subtree(parent1: Chromosome, parent2: Chromosome,
max_depth: int) -> tuple[Chromosome, Chromosome]:
\end{lstlisting}
Алгоритм выбирает случайные узлы в каждом родителе (кроме корня) и обменивает соответствующие поддеревья. Если глубина потомков превышает \texttt{max\_depth}, деревья усекаются методом \texttt{prune}.
\subsection{Мутации (mutations.py)}
Все мутации наследуются от базового класса \texttt{BaseMutation} с методом \texttt{mutate}. Реализованы четыре типа мутаций:
\begin{itemize}
\item \texttt{NodeReplacementMutation} -- заменяет узел на другой той же арности
\item \texttt{ShrinkMutation} -- заменяет случайную операцию на терминал (усечение)
\item \texttt{GrowMutation} -- заменяет узел на случайное поддерево с контролем глубины
\item \texttt{HoistMutation} -- заменяет поддерево на его случайную внутреннюю часть (уменьшает размер)
\end{itemize}
Класс \texttt{CombinedMutation} позволяет комбинировать мутации с заданными вероятностями, случайно выбирая одну из них на каждом шаге.
\subsection{Фитнес-функции (fitness.py)}
Базовый класс \texttt{BaseFitness} определяет интерфейс для вычисления ошибки:
\begin{lstlisting}
class BaseFitness(ABC):
def __call__(self, chromosome: Chromosome) -> float:
test_points = self.test_points_fn()
context = {t: test_points[:, i]
for i, t in enumerate(chromosome.terminals)}
predicted = chromosome.root.eval(context)
true_values = self.target_function(test_points)
return self.fitness_fn(chromosome, predicted, true_values)
\end{lstlisting}
Реализованы метрики ошибок: \texttt{MSEFitness} (среднеквадратичная), \texttt{RMSEFitness} (корень из MSE), \texttt{MAEFitness} (средняя абсолютная), \texttt{NRMSEFitness} (нормализованная RMSE). Класс \texttt{PenalizedFitness} добавляет штраф за размер и глубину дерева для борьбы с bloat.
\subsection{Селекция (selection.py)}
Реализованы три метода селекции:
\begin{itemize}
\item \texttt{roulette\_selection} -- селекция рулеткой со сдвигом для обработки отрицательных значений
\item \texttt{tournament\_selection(k)} -- турнирная селекция размера $k$
\item \texttt{stochastic\_tournament\_selection(k, p\_best)} -- стохастическая турнирная с вероятностью выбора лучшего
\end{itemize}
Для минимизации фитнес-функции используется инверсия знака при передаче фитнесов в селекцию.
\subsection{Генетический алгоритм (ga.py)}
Основная функция \texttt{genetic\_algorithm(config: GARunConfig)} реализует классический цикл ГА:
\begin{enumerate}
\item Вычисление фитнеса: \texttt{eval\_population(population, fitness\_func)}
\item Сохранение элиты (если \texttt{config.elitism > 0})
\item Селекция родителей: \texttt{config.selection\_fn(population, fitnesses)}
\item Кроссовер с вероятностью $p_c$: попарный обмен поддеревьями
\item Мутация с вероятностью $p_m$
\item Замещение популяции с восстановлением элиты
\end{enumerate}
Поддерживаются критерии остановки: по числу поколений, повторению лучшего результата, достижению порогового значения. История поколений сохраняется в виде списка объектов \texttt{Generation}.
Функция \texttt{save\_generation} использует библиотеку Graphviz для визуализации лучшего дерева поколения. Функция \texttt{plot\_fitness\_history} строит графики динамики лучших и средних значений фитнеса по поколениям и сохраняет их отдельно в \texttt{fitness\_best.png} и \texttt{fitness\_avg.png}.
\newpage
\section{Результаты работы}
На Рис.~\ref{fig:gen1}--\ref{fig:lastgen} представлены результаты работы генетического алгоритма со следующими параметрами:
\begin{itemize}
\item $N = 400$ -- размер популяции.
\item $10$ -- максимальная глубина дерева.
\item $p_c = 0.85$ -- вероятность кроссинговера поддеревьев.
\item $p_m = 0.15$ -- вероятность мутации, при этом использовалась комбинация различных вариантов:
\begin{itemize}
\item Растущая мутация: $p = 0.40$
\item Узловая мутация: $p = 0.30$
\item Hoist-мутация: $p = 0.15$
\item Усекающая мутация: $p = 0.15$
\end{itemize}
\item $200$ -- максимальное количество поколений.
\item $15$ -- количество "элитных" особей, переносимых без изменения в следующее поколение.
\item $3$ -- размер турнира для селекции.
\end{itemize}
На Рис.~\ref{fig:fitness_avg} и Рис.~\ref{fig:fitness_best} показаны графики изменения среднего и лучшего значения фитнеса по поколениям.
\begin{figure}[h!]
\centering
\includegraphics[width=0.95\linewidth]{img/results/fitness_avg.png}
\caption{График среднего значения фитнеса по поколениям}
\label{fig:fitness_avg}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=0.95\linewidth]{img/results/fitness_best.png}
\caption{График лучшего значения фитнеса по поколениям}
\label{fig:fitness_best}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=0.25\linewidth]{img/results/generation_001.png}
\caption{Лучшая особь поколения №1}
\label{fig:gen1}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=0.25\linewidth]{img/results/generation_010.png}
\caption{Лучшая особь поколения №10}
\label{fig:gen10}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=0.25\linewidth]{img/results/generation_020.png}
\caption{Лучшая особь поколения №20}
\label{fig:gen20}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=0.25\linewidth]{img/results/generation_030.png}
\caption{Лучшая особь поколения №30}
\label{fig:gen30}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=0.5\linewidth]{img/results/generation_040.png}
\caption{Лучшая особь поколения №40}
\label{fig:gen40}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=1\linewidth]{img/results/generation_050.png}
\caption{Лучшая особь поколения №50}
\label{fig:gen50}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=1\linewidth]{img/results/generation_100.png}
\caption{Лучшая особь поколения №100}
\label{fig:gen100}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=1\linewidth]{img/results/generation_150.png}
\caption{Лучшая особь поколения №150}
\label{fig:gen300}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=1\linewidth]{img/results/generation_200.png}
\caption{Лучшая особь поколения №200}
\label{fig:lastgen}
\end{figure}
\newpage
\phantom{text}
\newpage
\phantom{text}
\newpage
\phantom{text}
\newpage
\phantom{text}
\newpage
\phantom{text}
\newpage
\phantom{text}
\subsection{Анализ результатов}
\subsubsection*{Сравнение полученных деревьев}
На Рис.~\ref{fig:original_tree} представлено исходное дерево, на Рис.~\ref{fig:best_tree} представлено лучшее дерево, найденное алгоритмом.
\begin{figure}[h!]
\centering
\includegraphics[width=0.9\linewidth]{img/original_tree.png}
\caption{Дерево целевой функции}
\label{fig:original_tree}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=0.9\linewidth]{img/best_tree.png}
\caption{Лучшая особь, найденная алгоритмом}
\label{fig:best_tree}
\end{figure}
\subsubsection*{Сравнение полученных формул}
Перед сравнением, упростим исходную формулу, раскрыв знаки суммирования и перегруппировав слагаемые.
$$f(x) = \sum_{i=1}^{n} \sum_{j=1}^{i} x_j^2, \text{ для всех } j = 1, \ldots, n, \text{ при этом n }= 8.$$
$$
f(x) = \underbrace{(x_1^2)
+ (x_1^2 + x_2^2)
+ \ldots
+ (x_1^2 + x_2^2 + x_3^2 + x_4^2 + x_5^2 + x_6^2 + x_7^2 + x_8^2)}_{\text{ всего } n = 8 \text{ слагаемых}}
$$
$$
f(x) = 8 x_1^2 + 7 x_2^2 + 6 x_3^2 + 5 x_4^2 + 4 x_5^2 + 3 x_6^2 + 2 x_7^2 + x_8^2
$$
В программе реализован метод преобразования особи (дереве) в строковую формулу. Вывод программы для лучшей особи представлен ниже:
\begin{lstlisting}[label={lst:}]
(((((pow2(x3) + ((pow2(x1) + pow2(x2)) + pow2(x1))) + pow2(x6)) +
((pow2(x2) + pow2(x2)) + ((sin(((x6 + x2) + sin(x6))) + ((pow2(x4) +
pow2(x2)) + pow2(x4))) + (((pow2(x3) + pow2(x4)) + pow2(x7)) + (pow2(x6) +
pow2(x4)))))) + (((pow2(x2) + ((pow2(x8) + pow2((x5 + x5))) + pow2(x3))) +
pow2(x1)) + (pow2(x6) + pow2(x4)))) + (((((pow2(x3) + pow2(x3))
+ ((pow2(x7) + pow2(x2)) + pow2(x1))) + pow2(x1)) + (pow2(x2) + ((pow2(x3) +
pow2(x1)) + pow2(x1)))) + (sin(x2) + pow2(x1))))
\end{lstlisting}
Программный метод автоматически обрамляет функции и переменные в скобки, чтобы правильно расставить приоритеты операций. Однако в данном случае они избыточны, поэтому их можно убрать:
\begin{lstlisting}[label={lst:}]
pow2(x3) + pow2(x1) + pow2(x2) + pow2(x1) + pow2(x6) + pow2(x2) + pow2(x2) +
sin(x6 + x2) + sin(x6) + pow2(x4) + pow2(x2) + pow2(x4) + pow2(x3) + pow2(x4) +
pow2(x7) + pow2(x6) + pow2(x4) + pow2(x2) + pow2(x8) + pow2(x5 + x5) + pow2(x3) +
pow2(x1) + pow2(x6) + pow2(x4) + pow2(x3) + pow2(x3) + pow2(x7) + pow2(x2) + pow2(x1) +
pow2(x1) + pow2(x2) + pow2(x3) + pow2(x1) + pow2(x1) + sin(x2) + pow2(x1)
\end{lstlisting}
Переставим слагаемые:
\begin{lstlisting}[label={lst:}]
pow2(x1) + pow2(x1) + pow2(x1) + pow2(x1) + pow2(x1) + pow2(x1) + pow2(x1) + pow2(x1) +
pow2(x2) + pow2(x2) + pow2(x2) + pow2(x2) + pow2(x2) + pow2(x2) + pow2(x2) +
pow2(x3) + pow2(x3) + pow2(x3) + pow2(x3) + pow2(x3) + pow2(x3) +
pow2(x4) + pow2(x4) + pow2(x4) + pow2(x4) + pow2(x4) +
pow2(x5 + x5) +
pow2(x6) + pow2(x6) + pow2(x6) +
pow2(x7) + pow2(x7) +
pow2(x8) +
sin(x6 + x2) + sin(x6) + sin(x2)
\end{lstlisting}
Заметим, что $(x_5 + x_5)^2 = (2x_5)^2 = 4x_5^2$, а также сгруппируем слагаемые, чтобы получить финальный вид формулы, найденной алгоритмом:
$$
\hat{f}(x) = \textcolor{green!70!black}{8x_1^2 + 7x_2^2 + 6x_3^2 + 5x_4^2 + 4x_5^2 + 3x_6^2 + 2x_7^2 + x_8^2} + \textcolor{red!90!black}{sin(x_6 + x_2) + sin(x_6) + sin(x_2)}
$$
Найденная формула полностью включает в себя целевую и содержит лишь несколько лишних слагаемых.
\newpage
\section{Ответ на контрольный вопрос}
\textbf{Вопрос}: Опишите древовидное представление.
\textbf{Ответ}:
Древовидное представление — классический подход в генетическом программировании, где программы представляются в виде синтаксических деревьев. Внутренние узлы содержат функции из функционального множества (арифметические операции, математические функции), а листья — терминалы из терминального множества (переменные и константы). Вычисление происходит рекурсивно от листьев к корню. Сложность дерева оценивается через максимальную глубину $D_m$ (расстояние от корня до самого дальнего листа) или общее количество узлов.
Основные преимущества: естественное отображение синтаксической структуры математических выражений, гибкость в работе с выражениями различной длины и сложности, простота реализации генетических операторов (кроссовер поддеревьев, узловая мутация, растущая и усекающая мутации), автоматическое соблюдение синтаксической корректности при генерации и модификации программ. Инициализация выполняется полным методом (full) или растущим методом (grow), либо их комбинацией (ramped half-and-half).
\newpage
\section*{Заключение}
\addcontentsline{toc}{section}{Заключение}
В ходе четвёртой лабораторной работы была успешно решена задача нахождения формулы целевой функции вида $f(x) = \sum_{i=1}^{n} \sum_{j=1}^{i} x_j^2$ с использованием генетического программирования:
\begin{enumerate}
\item Изучен теоретический материал о представлениях программ в генетическом программировании (древовидное, линейное, графовое) и специализированных операторах кроссинговера и мутации для древовидных структур;
\item Создана программная библиотека \texttt{gp} на языке Python с реализацией древовидного представления хромосом, кроссовера поддеревьев, четырёх типов мутаций (узловая, усекающая, растущая, Hoist-мутация), турнирной селекции и безопасных векторизованных операций;
\item Реализованы методы инициализации популяции (full, grow, ramped half-and-half), фитнес-функции на основе метрик ошибок (MSE, RMSE, MAE, NRMSE), механизм элитизма и визуализация деревьев с помощью Graphviz;
\item Проведён эксперимент с популяцией из 400 особей на 10000 тестовых точках для 8 переменных. За 200 поколений (~5.9 минут) получено решение с MSE = 0.412 и RMSE = 0.642, полностью включающее целевую функцию с небольшими дополнительными слагаемыми.
\end{enumerate}
\newpage
\section*{Список литературы}
\addcontentsline{toc}{section}{Список литературы}
\vspace{-1.5cm}
\begin{thebibliography}{0}
\bibitem{vostrov}
Методические указания по выполнению лабораторных работ к курсу «Генетические алгоритмы», 119 стр.
\end{thebibliography}
\end{document}

11
lab4/uv.lock generated
View File

@@ -69,6 +69,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/93/0dd45cd283c32dea1545151d8c3637b4b8c53cdb3a625aeb2885b184d74d/fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb", size = 1143175, upload-time = "2025-09-29T21:13:24.134Z" },
]
[[package]]
name = "graphviz"
version = "0.21"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/b3/3ac91e9be6b761a4b30d66ff165e54439dcd48b83f4e20d644867215f6ca/graphviz-0.21.tar.gz", hash = "sha256:20743e7183be82aaaa8ad6c93f8893c923bd6658a04c32ee115edb3c8a835f78", size = 200434, upload-time = "2025-06-15T09:35:05.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl", hash = "sha256:54f33de9f4f911d7e84e4191749cac8cc5653f815b06738c54db9a15ab8b1e42", size = 47300, upload-time = "2025-06-15T09:35:04.433Z" },
]
[[package]]
name = "kiwisolver"
version = "1.4.9"
@@ -108,12 +117,14 @@ name = "lab4"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "graphviz" },
{ name = "matplotlib" },
{ name = "numpy" },
]
[package.metadata]
requires-dist = [
{ name = "graphviz", specifier = ">=0.21" },
{ name = "matplotlib", specifier = ">=3.10.7" },
{ name = "numpy", specifier = ">=2.3.4" },
]

3
lab5/README.md Normal file
View File

@@ -0,0 +1,3 @@
# Attention!
lab5 is fully AI generated slop.

3
lab5/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Evolution strategy toolkit for lab 5."""
__all__ = []

327
lab5/csv_to_tex.py Normal file
View File

@@ -0,0 +1,327 @@
"""
Скрипт для конвертации результатов экспериментов из CSV в LaTeX таблицы для lab5.
Адаптирован из lab2/csv_to_tex.py для работы с форматом эволюционных стратегий.
Формат входных данных: "время±стд (поколения±стд) фитнес"
"""
import re
from pathlib import Path
# Настройка цвета для выделения лучших результатов
# None - только жирным, строка (например "magenta") - жирным и цветом
HIGHLIGHT_COLOR = "magenta"
def parse_csv_file(csv_path: str) -> tuple[str, list[list[str]]]:
"""
Парсит CSV файл с результатами эксперимента.
Args:
csv_path: Путь к CSV файлу
Returns:
Tuple с заголовком и данными таблицы
"""
with open(csv_path, "r", encoding="utf-8") as file:
lines = file.readlines()
# Удаляем пустые строки и берём только строки с данными
clean_lines = [line.strip() for line in lines if line.strip()]
# Первая строка - заголовки
header = clean_lines[0]
# Остальные строки - данные
data_lines = clean_lines[1:]
# Парсим данные
data_rows = []
for line in data_lines:
parts = line.split(",")
if len(parts) >= 2: # mu + хотя бы одно значение
data_rows.append(parts)
return header, data_rows
def extract_time_value(value: str) -> float | None:
"""
Извлекает значение времени из строки формата "X.Y±Z.W (...)".
Args:
value: Строка с результатом
Returns:
Время выполнения как float или None если значение пустое
"""
value = value.strip()
if value == "" or value == "" or value == "":
return None
# Ищем паттерн "число.число±число"
match = re.match(r"(\d+\.?\d*)±", value)
if match:
return float(match.group(1))
# Если нет ±, пробуем просто число перед скобкой
match = re.match(r"(\d+\.?\d*)\s*\(", value)
if match:
return float(match.group(1))
return None
def extract_generations_value(value: str) -> float | None:
"""
Извлекает среднее число поколений из строки формата "... (X±Y) ...".
Args:
value: Строка с результатом
Returns:
Среднее число поколений как float или None если значение пустое
"""
value = value.strip()
if value == "" or value == "" or value == "":
return None
# Ищем паттерн "(число±число)" и берём первое число
match = re.search(r"\((\d+\.?\d*)±", value)
if match:
return float(match.group(1))
# Если нет ±, пробуем просто число в скобках
match = re.search(r"\((\d+\.?\d*)\)", value)
if match:
return float(match.group(1))
return None
def find_best_time(data_rows: list[list[str]]) -> float | None:
"""
Находит минимальное время выполнения среди всех значений в таблице.
Args:
data_rows: Строки данных таблицы
Returns:
Минимальное время или None если нет валидных значений
"""
min_time = None
for row in data_rows:
for i in range(1, len(row)): # Пропускаем первую колонку (mu)
time_value = extract_time_value(row[i])
if time_value is not None:
if min_time is None or time_value < min_time:
min_time = time_value
return min_time
def find_best_generations(data_rows: list[list[str]]) -> float | None:
"""
Находит минимальное число поколений среди всех значений в таблице.
Args:
data_rows: Строки данных таблицы
Returns:
Минимальное число поколений или None если нет валидных значений
"""
min_gens = None
for row in data_rows:
for i in range(1, len(row)): # Пропускаем первую колонку (mu)
gens_value = extract_generations_value(row[i])
if gens_value is not None:
if min_gens is None or gens_value < min_gens:
min_gens = gens_value
return min_gens
def format_value(
value: str, best_time: float | None = None, best_gens: float | None = None
) -> str:
"""
Форматирует значение для LaTeX таблицы, выделяя лучшие результаты жирным.
Args:
value: Строковое значение из CSV
best_time: Лучшее время в таблице для сравнения
best_gens: Лучшее число поколений для сравнения
Returns:
Отформатированное значение для LaTeX
"""
value = value.strip()
if value == "" or value == "" or value == "":
return ""
# Парсим значение: "время±стд (поколения±стд) фитнес"
# Пример: "60.6±47.9 (37±29) 0.0000"
pattern = r"(\d+\.?\d*)±(\d+\.?\d*)\s*\((\d+\.?\d*)±(\d+\.?\d*)\)\s+(\d+\.?\d+)"
match = re.match(pattern, value)
if not match:
# Если не удалось распарсить, возвращаем как есть
return value
time_avg = float(match.group(1))
time_std = float(match.group(2))
gens_avg = float(match.group(3))
gens_std = float(match.group(4))
fitness = match.group(5)
# Формируем части БЕЗ стандартных отклонений
time_part = f"{time_avg:.1f}"
gens_part = f"{gens_avg:.0f}"
# Проверяем, является ли время лучшим
is_best_time = best_time is not None and abs(time_avg - best_time) < 0.1
is_best_gens = best_gens is not None and abs(gens_avg - best_gens) < 0.1
# Выделяем лучшее время
if is_best_time:
if HIGHLIGHT_COLOR is not None:
time_part = f"\\textcolor{{{HIGHLIGHT_COLOR}}}{{\\textbf{{{time_part}}}}}"
else:
time_part = f"\\textbf{{{time_part}}}"
# Выделяем лучшее число поколений
if is_best_gens:
if HIGHLIGHT_COLOR is not None:
gens_part = f"\\textcolor{{{HIGHLIGHT_COLOR}}}{{\\textbf{{{gens_part}}}}}"
else:
gens_part = f"\\textbf{{{gens_part}}}"
# Не показываем фитнес в таблице, т.к. он всегда близок к нулю
return f"{time_part} ({gens_part})"
def generate_latex_table(dimension: str, header: str, data_rows: list[list[str]]) -> str:
"""
Генерирует LaTeX код таблицы.
Args:
dimension: Размерность задачи (2 или 3)
header: Заголовок таблицы
data_rows: Строки данных
Returns:
LaTeX код таблицы
"""
# Находим лучшее время и лучшее число поколений в таблице
best_time = find_best_time(data_rows)
best_gens = find_best_generations(data_rows)
# Извлекаем заголовки колонок из header
header_parts = header.split(",")
p_mut_values = header_parts[1:] # Пропускаем "mu \ p_mut"
num_cols = len(p_mut_values)
latex_code = f""" \\begin{{table}}[h!]
\\centering
\\small
\\caption{{Результаты для $n = {dimension}$. Формат: время в мс (число поколений)}}
\\begin{{tabularx}}{{{0.95 if num_cols <= 5 else 1.0}\\linewidth}}{{l *{{{num_cols}}}{{Y}}}}
\\toprule
$\\mathbf{{\\mu \\;\\backslash\\; p_{{mut}}}}$"""
# Добавляем заголовки p_mut
for p_mut in p_mut_values:
latex_code += f" & \\textbf{{{p_mut.strip()}}}"
latex_code += " \\\\\n \\midrule\n"
# Добавляем строки данных
for row in data_rows:
mu_value = row[0].strip()
latex_code += f" \\textbf{{{mu_value}}}"
# Добавляем значения для каждого p_mut
for i in range(1, len(row)):
value = format_value(row[i], best_time, best_gens)
latex_code += f" & {value}"
# Заполняем недостающие колонки если их меньше чем в заголовке
for i in range(len(row) - 1, num_cols):
latex_code += " & —"
latex_code += " \\\\\n"
latex_code += f""" \\bottomrule
\\end{{tabularx}}
\\label{{tab:es_results_{dimension}}}
\\end{{table}}"""
return latex_code
def main():
"""Основная функция скрипта."""
experiments_path = Path("lab5_experiments")
if not experiments_path.exists():
print("Папка lab5_experiments не найдена!")
return
tables = []
# Обрабатываем файлы dimension_2.csv и dimension_3.csv
for dimension in [2, 3]:
csv_file = experiments_path / f"dimension_{dimension}.csv"
if csv_file.exists():
print(f"Обрабатываем {csv_file}...")
try:
header, data_rows = parse_csv_file(str(csv_file))
best_time = find_best_time(data_rows)
best_gens = find_best_generations(data_rows)
latex_table = generate_latex_table(str(dimension), header, data_rows)
tables.append(latex_table)
print(
f"[OK] Таблица для n={dimension} готова (лучшее время: {best_time:.1f} мс, лучшее число поколений: {best_gens:.0f})"
)
except Exception as e:
print(f"[ERROR] Ошибка при обработке {csv_file}: {e}")
else:
print(f"[ERROR] Файл {csv_file} не найден")
# Сохраняем все таблицы в файл
if tables:
output_file = experiments_path / "tables.tex"
with open(output_file, "w", encoding="utf-8") as f:
f.write("% Автоматически сгенерированные LaTeX таблицы\n")
f.write(
"% Лучший результат по времени и по числу поколений выделены жирным отдельно\n"
)
f.write("% Убедитесь, что подключен \\usepackage{tabularx}\n")
if HIGHLIGHT_COLOR is not None:
f.write(
"% ВНИМАНИЕ: Убедитесь, что подключен \\usepackage{xcolor} для цветового выделения\n"
)
f.write(
"% Используйте \\newcolumntype{Y}{>{\\centering\\arraybackslash}X} перед таблицами\n\n"
)
for i, table in enumerate(tables):
if i > 0:
f.write("\n \n")
f.write(table + "\n")
print(f"\n[OK] Все таблицы сохранены в файл '{output_file}'")
print(f"Сгенерировано таблиц: {len(tables)}")
else:
print("Не найдено данных для генерации таблиц!")
if __name__ == "__main__":
main()

423
lab5/es.py Normal file
View File

@@ -0,0 +1,423 @@
"""Evolution strategy implementation for laboratory work #5."""
from __future__ import annotations
import math
import os
import random
import shutil
import time
from collections import deque
from dataclasses import dataclass
from typing import Callable, Iterable, Literal, Sequence
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.axes import Axes
from mpl_toolkits.mplot3d import Axes3D # noqa: F401 - required for 3D plotting
from numpy.typing import NDArray
Array = NDArray[np.float64]
FitnessFn = Callable[[Array], float]
@dataclass
class Individual:
"""Single individual of the evolution strategy population."""
x: Array
sigma: Array
fitness: float
def copy(self) -> "Individual":
return Individual(self.x.copy(), self.sigma.copy(), float(self.fitness))
@dataclass(frozen=True)
class Generation:
number: int
population: tuple[Individual, ...]
best: Individual
mean_fitness: float
sigma_scale: float
@dataclass
class EvolutionStrategyResult:
generations_count: int
best_generation: Generation
history: list[Generation]
time_ms: float
@dataclass
class EvolutionStrategyConfig:
fitness_func: FitnessFn
dimension: int
x_min: Array
x_max: Array
mu: int
lambda_: int
mutation_probability: float
initial_sigma: Array | float
max_generations: int
selection: Literal["plus", "comma"] = "comma"
recombination: Literal["intermediate", "discrete", "none"] = "intermediate"
parents_per_offspring: int = 2
success_rule_window: int = 10
success_rule_target: float = 0.2
sigma_increase: float = 1.22
sigma_decrease: float = 0.82
sigma_scale_min: float = 1e-3
sigma_scale_max: float = 100.0
tau: float | None = None
tau_prime: float | None = None
sigma_min: float = 1e-6
sigma_max: float = 10.0
best_value_threshold: float | None = None
max_stagnation_generations: int | None = None
save_generations: list[int] | None = None
results_dir: str = "results"
log_every_generation: bool = False
seed: int | None = None
def __post_init__(self) -> None:
assert self.dimension == self.x_min.shape[0] == self.x_max.shape[0], (
"Bounds dimensionality must match the dimension of the problem"
)
assert 0 < self.mu <= self.lambda_, "Require mu <= lambda and positive"
assert 0.0 < self.mutation_probability <= 1.0, (
"Mutation probability must be within (0, 1]"
)
if isinstance(self.initial_sigma, (int, float)):
if self.initial_sigma <= 0:
raise ValueError("Initial sigma must be positive")
else:
if self.initial_sigma.shape != (self.dimension,):
raise ValueError("initial_sigma must be scalar or an array of given dimension")
if np.any(self.initial_sigma <= 0):
raise ValueError("All sigma values must be positive")
if self.tau is None:
object.__setattr__(self, "tau", 1.0 / math.sqrt(2.0 * math.sqrt(self.dimension)))
if self.tau_prime is None:
object.__setattr__(self, "tau_prime", 1.0 / math.sqrt(2.0 * self.dimension))
def make_initial_sigma(self) -> Array:
if isinstance(self.initial_sigma, (int, float)):
return np.full(self.dimension, float(self.initial_sigma), dtype=np.float64)
return self.initial_sigma.astype(np.float64, copy=True)
# ---------------------------------------------------------------------------
# Helper utilities
# ---------------------------------------------------------------------------
def clear_results_directory(results_dir: str) -> None:
if os.path.exists(results_dir):
shutil.rmtree(results_dir)
os.makedirs(results_dir, exist_ok=True)
def evaluate_population(population: Iterable[Individual], fitness_func: FitnessFn) -> None:
for individual in population:
individual.fitness = float(fitness_func(individual.x))
def recombine(
parents: Sequence[Individual],
config: EvolutionStrategyConfig,
) -> tuple[Array, Array, float]:
"""Recombine parent individuals before mutation.
Returns the base vector, sigma and the best parent fitness.
"""
if config.recombination == "none" or config.parents_per_offspring == 1:
parent = random.choice(parents)
return parent.x.copy(), parent.sigma.copy(), parent.fitness
selected = random.choices(parents, k=config.parents_per_offspring)
if config.recombination == "intermediate":
x = np.mean([p.x for p in selected], axis=0)
sigma = np.mean([p.sigma for p in selected], axis=0)
elif config.recombination == "discrete":
mask = np.random.randint(0, len(selected), size=config.dimension)
indices = np.arange(config.dimension)
x = np.array([selected[mask[i]].x[i] for i in indices], dtype=np.float64)
sigma = np.array([selected[mask[i]].sigma[i] for i in indices], dtype=np.float64)
else: # pragma: no cover - defensive
raise ValueError(f"Unsupported recombination type: {config.recombination}")
parent_fitness = min(p.fitness for p in selected)
return x, sigma, parent_fitness
def mutate(
x: Array,
sigma: Array,
config: EvolutionStrategyConfig,
sigma_scale: float,
) -> tuple[Array, Array]:
"""Apply log-normal mutation with optional per-coordinate masking."""
global_noise = np.random.normal()
coordinate_noise = np.random.normal(size=config.dimension)
sigma_new = sigma * np.exp(config.tau_prime * global_noise + config.tau * coordinate_noise)
sigma_new = np.clip(sigma_new, config.sigma_min, config.sigma_max)
sigma_new = np.clip(sigma_new * sigma_scale, config.sigma_min, config.sigma_max)
steps = np.random.normal(size=config.dimension) * sigma_new
if config.mutation_probability < 1.0:
mask = np.random.random(config.dimension) < config.mutation_probability
if not np.any(mask):
mask[np.random.randint(0, config.dimension)] = True
steps = steps * mask
sigma_new = np.where(mask, sigma_new, sigma)
x_new = np.clip(x + steps, config.x_min, config.x_max)
return x_new, sigma_new
def create_offspring(
parents: Sequence[Individual],
config: EvolutionStrategyConfig,
sigma_scale: float,
) -> tuple[list[Individual], list[bool]]:
offspring: list[Individual] = []
successes: list[bool] = []
for _ in range(config.lambda_):
base_x, base_sigma, best_parent_fitness = recombine(parents, config)
mutated_x, mutated_sigma = mutate(base_x, base_sigma, config, sigma_scale)
fitness = float(config.fitness_func(mutated_x))
child = Individual(mutated_x, mutated_sigma, fitness)
offspring.append(child)
successes.append(fitness < best_parent_fitness)
return offspring, successes
def select_next_generation(
parents: list[Individual],
offspring: list[Individual],
config: EvolutionStrategyConfig,
) -> list[Individual]:
if config.selection == "plus":
pool = parents + offspring
else:
pool = offspring
pool.sort(key=lambda ind: ind.fitness)
next_generation = [ind.copy() for ind in pool[: config.mu]]
return next_generation
def compute_best(population: Sequence[Individual]) -> Individual:
best = min(population, key=lambda ind: ind.fitness)
return best.copy()
def build_generation(
number: int,
population: list[Individual],
sigma_scale: float,
) -> Generation:
copies = tuple(ind.copy() for ind in population)
best = compute_best(copies)
mean_fitness = float(np.mean([ind.fitness for ind in copies]))
return Generation(number, copies, best, mean_fitness, sigma_scale)
def save_generation(generation: Generation, config: EvolutionStrategyConfig) -> None:
if config.dimension != 2:
raise ValueError("Visualization is only supported for 2D problems")
os.makedirs(config.results_dir, exist_ok=True)
fig = plt.figure(figsize=(21, 7))
fig.suptitle(
(
f"Поколение #{generation.number}. "
f"Лучшее значение: {generation.best.fitness:.6f}. "
f"Среднее: {generation.mean_fitness:.6f}. "
f"Масштаб σ: {generation.sigma_scale:.4f}"
),
fontsize=14,
y=0.88,
)
ax_contour = fig.add_subplot(1, 3, 1)
plot_fitness_contour(config.fitness_func, config.x_min, config.x_max, ax_contour)
arr = np.array([ind.x for ind in generation.population])
ax_contour.scatter(arr[:, 1], arr[:, 0], c="red", s=20, alpha=0.9)
ax_contour.scatter(
generation.best.x[1], generation.best.x[0], c="black", s=60, marker="*", label="Лучший"
)
ax_contour.legend(loc="upper right")
ax_contour.text(0.5, -0.25, "(a)", transform=ax_contour.transAxes, ha="center", fontsize=16)
views = [(50, -45), (60, 30)]
fitnesses = np.array([ind.fitness for ind in generation.population])
for idx, (elev, azim) in enumerate(views, start=1):
ax = fig.add_subplot(1, 3, idx + 1, projection="3d", computed_zorder=False)
plot_fitness_surface(config.fitness_func, config.x_min, config.x_max, ax)
ax.scatter(arr[:, 0], arr[:, 1], fitnesses, c="red", s=12, alpha=0.9)
ax.scatter(
generation.best.x[0],
generation.best.x[1],
generation.best.fitness,
c="black",
s=60,
marker="*",
)
ax.view_init(elev=elev, azim=azim)
label = chr(ord("a") + idx)
ax.text2D(0.5, -0.15, f"({label})", transform=ax.transAxes, ha="center", fontsize=16)
ax.set_xlabel("X₁")
ax.set_ylabel("X₂")
ax.set_zlabel("f(x)")
filename = os.path.join(config.results_dir, f"generation_{generation.number:03d}.png")
fig.savefig(filename, dpi=150, bbox_inches="tight")
plt.close(fig)
def plot_fitness_surface(
fitness_func: FitnessFn,
x_min: Array,
x_max: Array,
ax: Axes3D,
num_points: int = 100,
) -> None:
if x_min.shape != (2,) or x_max.shape != (2,):
raise ValueError("Surface plotting is only available for 2D functions")
xs = np.linspace(x_min[0], x_max[0], num_points)
ys = np.linspace(x_min[1], x_max[1], num_points)
X, Y = np.meshgrid(xs, ys)
vectorized = np.vectorize(lambda a, b: fitness_func(np.array([a, b])))
Z = vectorized(X, Y)
ax.plot_surface(X, Y, Z, cmap="viridis", edgecolor="none", alpha=0.7, shade=False)
def plot_fitness_contour(
fitness_func: FitnessFn,
x_min: Array,
x_max: Array,
ax: Axes,
num_points: int = 100,
) -> None:
xs = np.linspace(x_min[0], x_max[0], num_points)
ys = np.linspace(x_min[1], x_max[1], num_points)
X, Y = np.meshgrid(xs, ys)
vectorized = np.vectorize(lambda a, b: fitness_func(np.array([a, b])))
Z = vectorized(X, Y)
contour = ax.contourf(Y, X, Z, levels=25, cmap="viridis", alpha=0.8)
plt.colorbar(contour, ax=ax, shrink=0.6)
ax.set_aspect("equal")
ax.set_xlabel("X₂")
ax.set_ylabel("X₁")
# ---------------------------------------------------------------------------
# Main algorithm
# ---------------------------------------------------------------------------
def run_evolution_strategy(config: EvolutionStrategyConfig) -> EvolutionStrategyResult:
if config.seed is not None:
random.seed(config.seed)
np.random.seed(config.seed)
if config.save_generations:
clear_results_directory(config.results_dir)
start = time.perf_counter()
parents = [
Individual(
np.random.uniform(config.x_min, config.x_max),
config.make_initial_sigma(),
0.0,
)
for _ in range(config.mu)
]
evaluate_population(parents, config.fitness_func)
sigma_scale = 1.0
success_window: deque[float] = deque()
history: list[Generation] = []
best_overall: Generation | None = None
stagnation_counter = 0
for generation_number in range(1, config.max_generations + 1):
current_generation = build_generation(generation_number, parents, sigma_scale)
history.append(current_generation)
if config.log_every_generation:
print(
f"Generation #{generation_number}: best={current_generation.best.fitness:.6f} "
f"mean={current_generation.mean_fitness:.6f}"
)
if (
best_overall is None
or current_generation.best.fitness < best_overall.best.fitness
):
best_overall = current_generation
stagnation_counter = 0
else:
stagnation_counter += 1
if (
config.best_value_threshold is not None
and current_generation.best.fitness <= config.best_value_threshold
):
break
if (
config.max_stagnation_generations is not None
and stagnation_counter >= config.max_stagnation_generations
):
break
offspring, successes = create_offspring(parents, config, sigma_scale)
success_ratio = sum(successes) / len(successes) if successes else 0.0
success_window.append(success_ratio)
if len(success_window) == config.success_rule_window:
average_success = sum(success_window) / len(success_window)
if average_success > config.success_rule_target:
sigma_scale = min(
sigma_scale * config.sigma_increase, config.sigma_scale_max
)
elif average_success < config.success_rule_target:
sigma_scale = max(
sigma_scale * config.sigma_decrease, config.sigma_scale_min
)
success_window.clear()
parents = select_next_generation(parents, offspring, config)
if config.save_generations and (
generation_number in config.save_generations
or generation_number == config.max_generations
):
save_generation(current_generation, config)
end = time.perf_counter()
assert best_overall is not None
# Сохраняем последнее поколение, если нужно
if config.save_generations and history:
last_number = history[-1].number
if last_number not in config.save_generations:
save_generation(history[-1], config)
return EvolutionStrategyResult(
generations_count=len(history),
best_generation=best_overall,
history=history,
time_ms=(end - start) * 1000.0,
)

129
lab5/experiments.py Normal file
View File

@@ -0,0 +1,129 @@
"""Parameter sweep experiments for the evolution strategy."""
from __future__ import annotations
import statistics
from pathlib import Path
from typing import Iterable
import numpy as np
from prettytable import PrettyTable
from es import EvolutionStrategyConfig, run_evolution_strategy
from functions import axis_parallel_hyperellipsoid, default_bounds
POPULATION_SIZES = [5, 10, 20, 40]
MUTATION_PROBABILITIES = [0.3, 0.5, 0.7, 0.9, 1.0]
NUM_RUNS = 5
LAMBDA_FACTOR = 5
RESULTS_DIR = Path("lab5_experiments")
def build_config(dimension: int, mu: int, mutation_probability: float) -> EvolutionStrategyConfig:
x_min, x_max = default_bounds(dimension)
search_range = x_max - x_min
initial_sigma = np.full(dimension, 0.15 * search_range[0], dtype=np.float64)
return EvolutionStrategyConfig(
fitness_func=axis_parallel_hyperellipsoid,
dimension=dimension,
x_min=x_min,
x_max=x_max,
mu=mu,
lambda_=mu * LAMBDA_FACTOR,
mutation_probability=mutation_probability,
initial_sigma=initial_sigma,
max_generations=300,
selection="comma",
recombination="intermediate",
parents_per_offspring=2,
success_rule_window=5,
success_rule_target=0.2,
sigma_increase=1.22,
sigma_decrease=0.82,
sigma_scale_min=1e-3,
sigma_scale_max=50.0,
sigma_min=1e-5,
sigma_max=2.0,
best_value_threshold=1e-6,
max_stagnation_generations=80,
save_generations=None,
results_dir=str(RESULTS_DIR / "tmp"),
log_every_generation=False,
seed=None,
)
def run_single_experiment(config: EvolutionStrategyConfig) -> tuple[float, int, float]:
result = run_evolution_strategy(config)
return result.time_ms, result.generations_count, result.best_generation.best.fitness
def summarize(values: Iterable[float]) -> tuple[float, float]:
values = list(values)
if not values:
return 0.0, 0.0
if len(values) == 1:
return values[0], 0.0
return statistics.mean(values), statistics.stdev(values)
def run_grid_for_dimension(dimension: int) -> PrettyTable:
table = PrettyTable()
table.field_names = ["mu \\ p_mut"] + [f"{pm:.2f}" for pm in MUTATION_PROBABILITIES]
for mu in POPULATION_SIZES:
row = [str(mu)]
for pm in MUTATION_PROBABILITIES:
times: list[float] = []
generations: list[int] = []
best_values: list[float] = []
for run_idx in range(NUM_RUNS):
config = build_config(dimension, mu, pm)
# Для воспроизводимости меняем seed для каждого запуска
config.seed = np.random.randint(0, 1_000_000)
time_ms, gens, best = run_single_experiment(config)
times.append(time_ms)
generations.append(gens)
best_values.append(best)
avg_time, std_time = summarize(times)
avg_gen, std_gen = summarize(generations)
avg_best, std_best = summarize(best_values)
cell = f"{avg_time:.1f}±{std_time:.1f} ({avg_gen:.0f}±{std_gen:.0f}) {avg_best:.4f}"
row.append(cell)
table.add_row(row)
return table
def save_table(table: PrettyTable, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
f.write(table.get_csv_string())
def main() -> None:
if RESULTS_DIR.exists():
for child in RESULTS_DIR.iterdir():
if child.is_file():
child.unlink()
print("=" * 80)
print("Исследование параметров эволюционной стратегии")
print("Популяции:", POPULATION_SIZES)
print("Вероятности мутации:", MUTATION_PROBABILITIES)
print(f"Каждая конфигурация запускается {NUM_RUNS} раз")
print("=" * 80)
for dimension in (2, 3):
print(f"\nРезультаты для размерности n={dimension}")
table = run_grid_for_dimension(dimension)
print(table)
save_table(table, RESULTS_DIR / f"dimension_{dimension}.csv")
print(f"Таблица сохранена в {RESULTS_DIR / f'dimension_{dimension}.csv'}")
if __name__ == "__main__":
main()

33
lab5/functions.py Normal file
View File

@@ -0,0 +1,33 @@
"""Benchmark functions used in lab 5."""
from __future__ import annotations
import numpy as np
from numpy.typing import NDArray
Array = NDArray[np.float64]
def axis_parallel_hyperellipsoid(x: Array) -> float:
"""Axis-parallel hyper-ellipsoid benchmark function.
Parameters
----------
x:
Point in :math:`\mathbb{R}^n`.
Returns
-------
float
The value of the hyper-ellipsoid function.
"""
indices = np.arange(1, x.shape[0] + 1, dtype=np.float64)
return float(np.sum(indices * np.square(x)))
def default_bounds(dimension: int, lower: float = -5.12, upper: float = 5.12) -> tuple[Array, Array]:
"""Construct symmetric bounds for each dimension."""
x_min = np.full(dimension, lower, dtype=np.float64)
x_max = np.full(dimension, upper, dtype=np.float64)
return x_min, x_max

View File

@@ -0,0 +1,34 @@
"""Utility script to regenerate visualization frames for the LaTeX report."""
from __future__ import annotations
import sys
from pathlib import Path
def _import_run_for_dimension():
base_dir = Path(__file__).resolve().parent
sys.path.insert(0, str(base_dir))
try:
from main import run_for_dimension as fn # type: ignore[import-not-found]
finally:
sys.path.pop(0)
return fn
def main() -> None:
base_dir = Path(__file__).resolve().parent
results_dir = base_dir / "report" / "img" / "results"
run_for_dimension = _import_run_for_dimension()
run_for_dimension(
2,
results_dir=str(results_dir),
save_generations=[1, 2, 3, 5, 7, 9, 10, 15, 19],
log=False,
)
if __name__ == "__main__":
main()

85
lab5/main.py Normal file
View File

@@ -0,0 +1,85 @@
"""Entry point for running the evolution strategy on the benchmark function."""
from __future__ import annotations
import numpy as np
from es import EvolutionStrategyConfig, run_evolution_strategy
from functions import axis_parallel_hyperellipsoid, default_bounds
def run_for_dimension(
dimension: int,
*,
results_dir: str,
max_generations: int = 200,
seed: int | None = 17,
save_generations: list[int] | None = None,
log: bool = False,
):
x_min, x_max = default_bounds(dimension)
search_range = x_max - x_min
initial_sigma = np.full(dimension, 0.15 * search_range[0], dtype=np.float64)
config = EvolutionStrategyConfig(
fitness_func=axis_parallel_hyperellipsoid,
dimension=dimension,
x_min=x_min,
x_max=x_max,
mu=20,
lambda_=80,
mutation_probability=0.7,
initial_sigma=initial_sigma,
max_generations=max_generations,
selection="comma",
recombination="intermediate",
parents_per_offspring=2,
success_rule_window=5,
success_rule_target=0.2,
sigma_increase=1.22,
sigma_decrease=0.82,
sigma_scale_min=1e-3,
sigma_scale_max=50.0,
sigma_min=1e-5,
sigma_max=2.0,
best_value_threshold=1e-6,
max_stagnation_generations=40,
save_generations=save_generations,
results_dir=results_dir,
log_every_generation=log,
seed=seed,
)
result = run_evolution_strategy(config)
print("=" * 80)
print(f"Результаты для размерности n={dimension}")
print(f"Лучшее решение: {result.best_generation.best.x}")
print(f"Лучшее значение функции: {result.best_generation.best.fitness:.8f}")
print(f"Количество поколений: {result.generations_count}")
print(f"Время выполнения: {result.time_ms:.2f} мс")
print("=" * 80)
return result
def main() -> None:
# Для n=2 дополнительно сохраняем графики поколений
run_for_dimension(
2,
results_dir="lab5_results_2d",
save_generations=[1, 2, 3, 5, 8, 10, 15, 20, 25, 30, 40, 50, 75, 100, 150, 200],
log=True,
)
# Для n=3 графики не строим, но выводим статистику
run_for_dimension(
3,
results_dir="lab5_results_3d",
save_generations=None,
log=False,
)
if __name__ == "__main__":
main()

0
lab5/report/img/.gitkeep Normal file
View File

704
lab5/report/report.tex Normal file
View File

@@ -0,0 +1,704 @@
\documentclass[a4paper, final]{article}
%\usepackage{literat} % Нормальные шрифты
\usepackage[14pt]{extsizes} % для того чтобы задать нестандартный 14-ый размер шрифта
\usepackage{tabularx}
\usepackage{booktabs}
\usepackage[T2A]{fontenc}
\usepackage[utf8]{inputenc}
\usepackage[russian]{babel}
\usepackage{amsmath}
\usepackage[left=25mm, top=20mm, right=20mm, bottom=20mm, footskip=10mm]{geometry}
\usepackage{ragged2e} %для растягивания по ширине
\usepackage{setspace} %для межстрочно го интервала
\usepackage{moreverb} %для работы с листингами
\usepackage{indentfirst} % для абзацного отступа
\usepackage{moreverb} %для печати в листинге исходного кода программ
\usepackage{pdfpages} %для вставки других pdf файлов
\usepackage{tikz}
\usepackage{graphicx}
\usepackage{afterpage}
\usepackage{longtable}
\usepackage{float}
\usepackage{xcolor}
% \usepackage[paper=A4,DIV=12]{typearea}
\usepackage{pdflscape}
% \usepackage{lscape}
\usepackage{array}
\usepackage{multirow}
\renewcommand\verbatimtabsize{4\relax}
\renewcommand\listingoffset{0.2em} %отступ от номеров строк в листинге
\renewcommand{\arraystretch}{1.4} % изменяю высоту строки в таблице
\usepackage[font=small, singlelinecheck=false, justification=centering, format=plain, labelsep=period]{caption} %для настройки заголовка таблицы
\usepackage{listings} %листинги
\usepackage{xcolor} % цвета
\usepackage{hyperref}% для гиперссылок
\usepackage{enumitem} %для перечислений
\newcommand{\specialcell}[2][l]{\begin{tabular}[#1]{@{}l@{}}#2\end{tabular}}
\setlist[enumerate,itemize]{leftmargin=1.2cm} %отступ в перечислениях
\hypersetup{colorlinks,
allcolors=[RGB]{010 090 200}} %красивые гиперссылки (не красные)
% подгружаемые языки — подробнее в документации listings (это всё для листингов)
\lstloadlanguages{ SQL}
% включаем кириллицу и добавляем кое−какие опции
\lstset{tabsize=2,
breaklines,
basicstyle=\footnotesize,
columns=fullflexible,
flexiblecolumns,
numbers=left,
numberstyle={\footnotesize},
keywordstyle=\color{blue},
inputencoding=cp1251,
extendedchars=true
}
\lstdefinelanguage{MyC}{
language=SQL,
% ndkeywordstyle=\color{darkgray}\bfseries,
% identifierstyle=\color{black},
% morecomment=[n]{/**}{*/},
% commentstyle=\color{blue}\ttfamily,
% stringstyle=\color{red}\ttfamily,
% morestring=[b]",
% showstringspaces=false,
% morecomment=[l][\color{gray}]{//},
keepspaces=true,
escapechar=\%,
texcl=true
}
\textheight=24cm % высота текста
\textwidth=16cm % ширина текста
\oddsidemargin=0pt % отступ от левого края
\topmargin=-1.5cm % отступ от верхнего края
\parindent=24pt % абзацный отступ
\parskip=5pt % интервал между абзацами
\tolerance=2000 % терпимость к "жидким" строкам
\flushbottom % выравнивание высоты страниц
% Настройка листингов
\lstset{
language=python,
extendedchars=\true,
inputencoding=utf8,
keepspaces=true,
% captionpos=b, % подписи листингов снизу
}
\begin{document} % начало документа
% НАЧАЛО ТИТУЛЬНОГО ЛИСТА
\begin{center}
\hfill \break
\hfill \break
\normalsize{МИНИСТЕРСТВО НАУКИ И ВЫСШЕГО ОБРАЗОВАНИЯ РОССИЙСКОЙ ФЕДЕРАЦИИ\\
федеральное государственное автономное образовательное учреждение высшего образования «Санкт-Петербургский политехнический университет Петра Великого»\\[10pt]}
\normalsize{Институт компьютерных наук и кибербезопасности}\\[10pt]
\normalsize{Высшая школа технологий искусственного интеллекта}\\[10pt]
\normalsize{Направление: 02.03.01 <<Математика и компьютерные науки>>}\\
\hfill \break
\hfill \break
\hfill \break
\hfill \break
\large{Лабораторная работа №5}\\
\large{по дисциплине}\\
\large{<<Генетические алгоритмы>>}\\
\large{Вариант 18}\\
% \hfill \break
\hfill \break
\end{center}
\small{
\begin{tabular}{lrrl}
\!\!\!Студент, & \hspace{2cm} & & \\
\!\!\!группы 5130201/20101 & \hspace{2cm} & \underline{\hspace{3cm}} &Тищенко А. А. \\\\
\!\!\!Преподаватель & \hspace{2cm} & \underline{\hspace{3cm}} & Большаков А. А. \\\\
&&\hspace{4cm}
\end{tabular}
\begin{flushright}
<<\underline{\hspace{1cm}}>>\underline{\hspace{2.5cm}} 2025г.
\end{flushright}
}
\hfill \break
% \hfill \break
\begin{center} \small{Санкт-Петербург, 2025} \end{center}
\thispagestyle{empty} % выключаем отображение номера для этой страницы
% КОНЕЦ ТИТУЛЬНОГО ЛИСТА
\newpage
\tableofcontents
\newpage
\section {Постановка задачи}
В данной работе были поставлены следующие задачи:
\begin{itemize}
\item Изучить теоретический материал;
\item Ознакомиться с вариантами кодирования хромосомы;
\item Рассмотреть способы выполнения операторов репродукции,
кроссинговера и мутации;
\item Выполнить индивидуальное задание на любом языке высокого
уровня
\end{itemize}
\textbf{Индивидуальное задание вариант 18:}
\textbf{Дано:} Функция Axis parallel hyper-ellipsoid function.
Общая формула для n-мерного случая:
$$f(\mathbf{x}) = \sum_{i=1}^{n} i \cdot x_i^2$$
где $\mathbf{x} = (x_1, x_2, \ldots, x_n)$, область определения $x_i \in [-5.12, 5.12]$ для всех $i = 1, \ldots, n$.
Для двумерного случая (n=2):
$$f(x, y) = 1 \cdot x^2 + 2 \cdot y^2 = x^2 + 2y^2$$
область нахождения решения $x \in [-5.12, 5.12], y \in [-5.12, 5.12]$.
Глобальный минимум: $f(\mathbf{x}) = 0$ в точке $x_i = 0$ для всех $i = 1, \ldots, n$. Для двумерного случая: $\min f(x, y) = f(0, 0) = 0$.
\vspace{0.3cm}
\textbf{Требуется:}
\begin{enumerate}
\item Реализовать программу на языке Python, использующую эволюционную стратегию для поиска минимума функции axis parallel hyper-ellipsoid;
\item Для $n = 2$ построить визуализацию поверхности и траектории поиска: отображать найденный экстремум и расположение популяции на каждом шаге, обеспечить пошаговый режим;
\item Исследовать влияние основных параметров ЭС (размер популяции, стратегия мутации, вероятность рекомбинации) на скорость сходимости, число поколений и точность результата;
\item Повторить вычислительный эксперимент для $n = 3$ и сопоставить затраты времени и качество найденного решения.
\end{enumerate}
\newpage
\section{Теоретические сведения}
\subsection{Общие сведения}
Эволюционные стратегии (ЭС), также как и генетические алгоритмы, основаны на эволюции популяции потенциальных решений, но, в отличие от них, здесь используются генетические операторы на уровне фенотипа, а не генотипа. Разница в том, что ГА работают в пространстве генотипа --- кодов решений, в то время как ЭС производят поиск в пространстве фенотипа --- векторном пространстве вещественных чисел.
В ЭС учитываются свойства хромосомы <<в целом>>, в отличие от ГА, где при поиске решений исследуются отдельные гены. В природе один ген может одновременно влиять на несколько свойств организма. С другой стороны, одно свойство особи может определяться несколькими генами. Естественная эволюция основана на исследовании совокупности генов, а не отдельного (изолированного) гена.
В эволюционных стратегиях целью является движение особей популяции по направлению к лучшей области ландшафта фитнесс-функции. ЭС изначально разработаны для решения многомерных оптимизационных задач, где пространство поиска --- многомерное пространство вещественных чисел.
Ранние эволюционные стратегии основывались на популяции, состоящей из одной особи, и в них использовался только один генетический оператор --- мутация. Здесь для представления особи (потенциального решения) была использована идея, которая заключается в следующем.
Особь представляется парой действительных векторов:
$$v = (\mathbf{x}, \boldsymbol{\sigma}),$$
где $\mathbf{x}$ --- точка в пространстве решений и $\boldsymbol{\sigma}$ --- вектор стандартных отклонений (вариабельность) от решения. В общем случае особь популяции определяется вектором потенциального решения и вектором <<стратегических параметров>> эволюции. Обычно это вектор стандартных отклонений (дисперсия), хотя допускаются и другие статистики.
Единственным генетическим оператором в классической ЭС является оператор мутации, который выполняется путём сложения координат вектора-родителя со случайными числами, подчиняющимися закону нормального распределения, следующим образом:
$$\mathbf{x}^{(t+1)} = \mathbf{x}^{(t)} + \mathcal{N}(\mathbf{0}, \boldsymbol{\sigma}),$$
где $\mathcal{N}(\mathbf{0}, \boldsymbol{\sigma})$ --- вектор независимых случайных чисел, генерируемых согласно распределению Гаусса с нулевым средним значением и стандартным отклонением $\boldsymbol{\sigma}$. Как видно из приведённой формулы, величина мутации управляется нетрадиционным способом. Иногда эволюционный процесс используется для изменения и самих стратегических параметров $\boldsymbol{\sigma}$, в этом случае величина мутации эволюционирует вместе с искомым потенциальным решением.
Интуитивно ясно, что увеличение отклонения подобно увеличению шага поиска на поверхности ландшафта. Высокая вариабельность способствует расширению пространства поиска и эффективна при нахождении потенциальных зон (суб)оптимальных решений и соответствует высоким значениям коэффициента мутации. В то же время малые значения вариабельности позволяют сфокусироваться на поиске решения в перспективной области. Стратегические параметры стохастически определяют величину шага поиска: большая вариабельность ведёт к большим шагам.
\subsection{Двукратная эволюционная (1+1)-стратегия}
Здесь потомок принимается в качестве нового члена популяции (он заменяет своего родителя), если значение фитнесс-функции (целевой функции) на нём лучше, чем у его родителя и выполняются все ограничения. Иначе (если значение фитнесс-функции на нём хуже, чем у родителя), потомок уничтожается и популяция остаётся неизменной.
Алгоритм процесса эволюции двукратной (1+1)-эволюционной стратегии можно сформулировать следующим образом:
\begin{enumerate}
\item Выбрать множество параметров $\mathbf{X}$, необходимых для представления решения данной проблемы, и определить диапазон допустимых изменений каждого параметра: $\{x_1^{min}, x_1^{max}\}, \{x_2^{min}, x_2^{max}\}, \ldots, \{x_P^{min}, x_P^{max}\}$. Установить номер поколения $t=0$; задать стандартное отклонение $\sigma_i$ для каждого параметра, функцию $f$, для которой необходимо найти оптимум, и максимальное число поколений $k$.
\item Для каждого параметра случайным образом выбрать начальное значение из допустимого диапазона: множество этих значений составляет начальную популяцию (из одной особи) $\mathbf{X}^{(t)} = (x_1, x_2, \ldots, x_P)$.
\item Вычислить значение оптимизируемой функции $f$ для родительской особи $F_p = f(\mathbf{X}^{(t)})$.
\item Создать новую особь-потомка: $\mathbf{X}^* = \mathbf{X}^{(t)} + \mathcal{N}(\mathbf{0}, \boldsymbol{\sigma})$.
\item Вычислить значение $f$ для особи-потомка $F_o = f(\mathbf{X}^*)$.
\item Сравнить значения функций $f$ для родителя и потомка; если значение потомка $F_o$ лучше, чем у родительской особи, то заменить родителя на потомка $\mathbf{X}^{(t)} = \mathbf{X}^*$, иначе оставить в популяции родителя.
\item Увеличить номер поколения $t = t + 1$.
\item Если не достигнуто максимальное число поколений $t < k$, то переход на шаг 4, иначе выдать найденное решение $\mathbf{X}^{(t)}$.
\end{enumerate}
Несмотря на то, что фактически здесь популяция состоит из одной особи, рассмотренная стратегия называется двукратной ЭС. Причина в том, что здесь фактически происходит конкуренция потомка и родителя.
\subsection{Правило успеха $1/5$}
Обычно вектор стандартных отклонений $\boldsymbol{\sigma}$ остаётся неизменным в течение всего процесса эволюции. Чтобы оптимизировать скорость сходимости этого процесса, И. Решенберг (основоположник ЭС) предложил правило успеха <<$1/5$>>.
Смысл его заключается в следующем --- правило применяется после каждых $k$ поколений процесса (где $k$ --- параметр этого метода):
$$\sigma^{(t+1)}_i = \begin{cases}
c_i \cdot \sigma^{(t)}_i, & \text{если } \varphi(k) > 1/5, \\
\sigma^{(t)}_i, & \text{если } \varphi(k) = 1/5, \\
c_d \cdot \sigma^{(t)}_i, & \text{если } \varphi(k) < 1/5,
\end{cases}$$
где $\varphi(k)$ --- отношение числа успешных мутаций к общему числу произведённых мутаций $k$ (число успехов, делённое на $k$), которое называется коэффициентом успеха для оператора мутации в течение $k$ последних поколений; величина $c_i > 1$, $c_d < 1$ --- регулирует увеличение/уменьшение отклонения мутации.
Обычно на практике оптимальные значения полагают равными следующим величинам: $c_d = 0.82$; $c_i = 1/0.82 = 1.22$. Смысл этого правила в следующем:
\begin{itemize}
\item если коэффициент успеха $\varphi(k) > 1/5$, то отклонение $\sigma^{(t+1)}$ увеличивается (мы идём более крупными шагами);
\item если коэффициент успеха $\varphi(k) < 1/5$, то отклонение $\sigma^{(t+1)}$ уменьшается (шаг поиска уменьшается).
\end{itemize}
Таким образом, алгоритм автоматически подстраивает шаг поиска под текущий рельеф функции.
\subsection{Многократная эволюционная стратегия}
По сравнению с двукратной многократная эволюция отличается не только размером популяции ($N > 2$), но и имеет некоторые дополнительные отличия:
\begin{itemize}
\item все особи в поколении имеют одинаковую вероятность выбора для мутации;
\item имеется возможность введения оператора рекомбинации, где два случайно выбранных родителя производят потомка по следующей схеме:
$$x_i^{\text{потомок}} = x_i^{q_i}, \quad i = 1, \ldots, n,$$
где $q_i = 1$ или $q_i = 2$ (т.е. каждая компонента потомка копируется из первого или второго родителя).
\end{itemize}
В современной литературе используются следующие обозначения:
\begin{itemize}
\item $(1+1)$-ЭС --- двукратная стратегия (1 родитель производит 1 потомка);
\item $(\mu+1)$-ЭС --- многократная стратегия ($\mu$ родителей производят 1 потомка);
\item $(\mu+\lambda)$-ЭС --- $\mu$ родителей производят $\lambda$ потомков и отбор $\mu$ лучших представителей производится среди объединённого множества ($\mu + \lambda$ особей) родителей и потомков;
\item $(\mu, \lambda)$-ЭС --- $\mu$ особей родителей порождает $\lambda$ потомков, причём $\lambda > \mu$ и процесс выбора $\mu$ лучших производится только на множестве потомков.
\end{itemize}
Следует подчеркнуть, что в обоих последних видах ЭС обычно число потомков существенно больше числа родителей $\lambda > \mu$ (иногда полагают $\lambda/\mu = 7$).
Многочисленные исследования доказывают, что ЭС не менее эффективно, а часто гораздо лучше справляются с задачами оптимизации в многомерных пространствах, при этом более просты в реализации из-за отсутствия процедур кодирования и декодирования хромосом.
\newpage
\section{Особенности реализации}
\subsection{Структура модулей}
\begin{itemize}
\item \textbf{Модуль \texttt{functions.py}}: содержит реализацию тестовой функции axis parallel hyper-ellipsoid и вспомогательные генераторы диапазонов.
\item \textbf{Модуль \texttt{es.py}}: ядро эволюционной стратегии. Определены структуры конфигурации, представление особей и популяции, операторы рекомбинации и мутации.
\item \textbf{Модуль \texttt{experiments.py}}: сценарии серийных экспериментов с переборами параметров и сохранением метрик.
\item \textbf{Модуль \texttt{main.py}}: точка входа для интерактивных запусков с визуализацией.
\end{itemize}
\subsection{Модуль functions.py}
Модуль содержит реализацию тестовой функции axis parallel hyper-ellipsoid:
\begin{lstlisting}[language=Python]
def axis_parallel_hyperellipsoid(x: Array) -> float:
"""Axis-parallel hyper-ellipsoid benchmark function.
Parameters:
x: Point in R^n
Returns:
The value of the hyper-ellipsoid function
"""
indices = np.arange(1, x.shape[0] + 1, dtype=np.float64)
return float(np.sum(indices * np.square(x)))
\end{lstlisting}
Функция принимает вектор NumPy произвольной размерности и возвращает скалярное значение фитнеса. Для двумерного случая формула принимает вид $f(x_1, x_2) = x_1^2 + 2x_2^2$, для трёхмерного $f(x_1, x_2, x_3) = x_1^2 + 2x_2^2 + 3x_3^2$.
Также определена вспомогательная функция для генерации симметричных границ:
\begin{lstlisting}[language=Python]
def default_bounds(dimension: int,
lower: float = -5.12,
upper: float = 5.12) -> tuple[Array, Array]:
"""Construct symmetric bounds for each dimension."""
x_min = np.full(dimension, lower, dtype=np.float64)
x_max = np.full(dimension, upper, dtype=np.float64)
return x_min, x_max
\end{lstlisting}
\subsection{Модуль es.py}
\subsubsection{Структуры данных}
Особь представлена классом \texttt{Individual}, содержащим координаты решения, стратегические параметры и фитнес:
\begin{lstlisting}[language=Python]
@dataclass
class Individual:
"""Single individual of the evolution strategy population."""
x: Array # Coordinates in solution space
sigma: Array # Standard deviations for mutation
fitness: float # Fitness value
def copy(self) -> "Individual":
return Individual(self.x.copy(),
self.sigma.copy(),
float(self.fitness))
\end{lstlisting}
Конфигурация эволюционной стратегии задаётся через \texttt{EvolutionStrategyConfig}:
\begin{lstlisting}[language=Python]
@dataclass
class EvolutionStrategyConfig:
fitness_func: FitnessFn
dimension: int
x_min: Array
x_max: Array
mu: int # Number of parents
lambda_: int # Number of offspring
mutation_probability: float
initial_sigma: Array | float
max_generations: int
selection: Literal["plus", "comma"] = "comma"
recombination: Literal["intermediate", "discrete",
"none"] = "intermediate"
success_rule_window: int = 10
success_rule_target: float = 0.2
sigma_increase: float = 1.22
sigma_decrease: float = 0.82
# ... other parameters
\end{lstlisting}
\subsubsection{Рекомбинация}
Функция \texttt{recombine} реализует выбор родителей и создание базового вектора для потомка:
\begin{lstlisting}[language=Python]
def recombine(parents: Sequence[Individual],
config: EvolutionStrategyConfig) -> tuple[Array, Array, float]:
"""Recombine parent individuals before mutation.
Returns:
Base vector, sigma and the best parent fitness
"""
if config.recombination == "none":
parent = random.choice(parents)
return parent.x.copy(), parent.sigma.copy(), parent.fitness
selected = random.choices(parents,
k=config.parents_per_offspring)
if config.recombination == "intermediate":
x = np.mean([p.x for p in selected], axis=0)
sigma = np.mean([p.sigma for p in selected], axis=0)
elif config.recombination == "discrete":
mask = np.random.randint(0, len(selected),
size=config.dimension)
x = np.array([selected[mask[i]].x[i]
for i in range(config.dimension)])
sigma = np.array([selected[mask[i]].sigma[i]
for i in range(config.dimension)])
parent_fitness = min(p.fitness for p in selected)
return x, sigma, parent_fitness
\end{lstlisting}
Промежуточная рекомбинация усредняет координаты родителей, дискретная копирует каждую координату из случайно выбранного родителя.
\subsubsection{Мутация}
Оператор мутации использует логнормальное распределение для адаптации стратегических параметров:
\begin{lstlisting}[language=Python]
def mutate(x: Array, sigma: Array,
config: EvolutionStrategyConfig,
sigma_scale: float) -> tuple[Array, Array]:
"""Apply log-normal mutation with optional
per-coordinate masking."""
global_noise = np.random.normal()
coordinate_noise = np.random.normal(size=config.dimension)
# Adapt sigma using log-normal distribution
sigma_new = sigma * np.exp(config.tau_prime * global_noise +
config.tau * coordinate_noise)
sigma_new = np.clip(sigma_new * sigma_scale,
config.sigma_min, config.sigma_max)
# Apply mutation steps
steps = np.random.normal(size=config.dimension) * sigma_new
# Optional per-coordinate mutation probability
if config.mutation_probability < 1.0:
mask = np.random.random(config.dimension) < \
config.mutation_probability
if not np.any(mask):
mask[np.random.randint(0, config.dimension)] = True
steps = steps * mask
sigma_new = np.where(mask, sigma_new, sigma)
x_new = np.clip(x + steps, config.x_min, config.x_max)
return x_new, sigma_new
\end{lstlisting}
Параметры $\tau$ и $\tau'$ вычисляются как $\tau = 1/\sqrt{2\sqrt{n}}$ и $\tau' = 1/\sqrt{2n}$, где $n$ --- размерность задачи.
\subsubsection{Создание потомков}
Функция \texttt{create\_offspring} генерирует $\lambda$ потомков и отслеживает успешные мутации:
\begin{lstlisting}[language=Python]
def create_offspring(parents: Sequence[Individual],
config: EvolutionStrategyConfig,
sigma_scale: float) -> tuple[list[Individual],
list[bool]]:
"""Create offspring and track successful mutations."""
offspring: list[Individual] = []
successes: list[bool] = []
for _ in range(config.lambda_):
base_x, base_sigma, best_parent_fitness = \
recombine(parents, config)
mutated_x, mutated_sigma = \
mutate(base_x, base_sigma, config, sigma_scale)
fitness = float(config.fitness_func(mutated_x))
child = Individual(mutated_x, mutated_sigma, fitness)
offspring.append(child)
successes.append(fitness < best_parent_fitness)
return offspring, successes
\end{lstlisting}
\subsubsection{Селекция}
Отбор следующего поколения производится согласно выбранной стратегии:
\begin{lstlisting}[language=Python]
def select_next_generation(parents: list[Individual],
offspring: list[Individual],
config: EvolutionStrategyConfig) -> list[Individual]:
"""Select next generation according to the strategy."""
if config.selection == "plus":
pool = parents + offspring # (mu + lambda)-strategy
else:
pool = offspring # (mu, lambda)-strategy
pool.sort(key=lambda ind: ind.fitness)
next_generation = [ind.copy() for ind in pool[:config.mu]]
return next_generation
\end{lstlisting}
\subsection{Главная функция алгоритма}
Функция \texttt{run\_evolution\_strategy} реализует основной цикл эволюционной стратегии с адаптацией по правилу успеха $1/5$:
\begin{lstlisting}[language=Python]
def run_evolution_strategy(config: EvolutionStrategyConfig) -> EvolutionStrategyResult:
"""Main evolution strategy loop with 1/5 success rule."""
# Initialize random seed
if config.seed is not None:
random.seed(config.seed)
np.random.seed(config.seed)
# Initialize population
parents = [Individual(
np.random.uniform(config.x_min, config.x_max),
config.make_initial_sigma(),
0.0
) for _ in range(config.mu)]
evaluate_population(parents, config.fitness_func)
sigma_scale = 1.0
success_window: deque[float] = deque()
for generation_number in range(1, config.max_generations + 1):
# Create offspring and track successes
offspring, successes = create_offspring(parents, config,
sigma_scale)
success_ratio = sum(successes) / len(successes)
success_window.append(success_ratio)
# Apply 1/5 success rule
if len(success_window) == config.success_rule_window:
average_success = sum(success_window) / \
len(success_window)
if average_success > config.success_rule_target:
sigma_scale = min(sigma_scale * config.sigma_increase,
config.sigma_scale_max)
elif average_success < config.success_rule_target:
sigma_scale = max(sigma_scale * config.sigma_decrease,
config.sigma_scale_min)
success_window.clear()
# Select next generation
parents = select_next_generation(parents, offspring, config)
# Check stopping criteria
# ...
return EvolutionStrategyResult(...)
\end{lstlisting}
Правило успеха $1/5$ применяется каждые $k$ поколений (по умолчанию $k=5$): если доля успешных мутаций выше $1/5$, масштаб $\sigma$ увеличивается в $1.22$ раза, если ниже --- уменьшается в $0.82$ раза.
\newpage
\section{Результаты работы}
Для демонстрации работы алгоритма была выполнена визуализация процесса оптимизации двумерной функции ($n=2$) со следующими параметрами:
\begin{itemize}
\item $\mu = 20$ -- размер популяции родителей.
\item $\lambda = 80$ -- число потомков ($\lambda = 4\mu$).
\item $p_{mut} = 0.7$ -- вероятность мутации каждой координаты.
\item Промежуточная рекомбинация двух родителей.
\item $(\mu, \lambda)$-селекция: родители полностью заменяются.
\item Адаптивное масштабирование шага мутации по правилу успеха $1/5$.
\item Начальное стандартное отклонение $\sigma_0 = 0.15 \cdot (x_{max} - x_{min})$.
\end{itemize}
Визуализация воспроизводит поверхность целевой функции и положение популяции на каждом шаге. Пошаговый режим позволяет наблюдать влияние изменения дисперсий: при успешных мутациях облако точек расширяется, при неудачах сжимается вокруг текущего минимума. Популяция постепенно консолидируется вокруг глобального минимума в точке $(0, 0)$.
\begin{figure}[H]
\centering
\includegraphics[width=1\linewidth]{img/results/generation_001.png}
\caption{Поколение 1: начальная популяция и рельеф функции}
\end{figure}
\begin{figure}[H]
\centering
\includegraphics[width=1\linewidth]{img/results/generation_002.png}
\caption{Поколение 2: адаптация стратегических параметров}
\end{figure}
\begin{figure}[H]
\centering
\includegraphics[width=1\linewidth]{img/results/generation_003.png}
\caption{Поколение 3: фокусировка поиска около минимума}
\end{figure}
\begin{figure}[H]
\centering
\includegraphics[width=1\linewidth]{img/results/generation_005.png}
\caption{Поколение 5: сжатие облака решений}
\end{figure}
\begin{figure}[H]
\centering
\includegraphics[width=1\linewidth]{img/results/generation_008.png}
\caption{Поколение 8: стабилизация шага мутации}
\end{figure}
\begin{figure}[H]
\centering
\includegraphics[width=1\linewidth]{img/results/generation_010.png}
\caption{Поколение 10: движение вдоль долины уровня}
\end{figure}
% \begin{figure}[H]
% \centering
% \includegraphics[width=1\linewidth]{img/results/generation_015.png}
% \caption{Поколение 15: уточнение положения минимума}
% \end{figure}
\begin{figure}[H]
\centering
\includegraphics[width=1\linewidth]{img/results/generation_017.png}
\caption{Поколение 17: окончательная популяция}
\end{figure}
\newpage
\section{Исследование параметров}
В рамках лабораторной работы было проведено исследование влияния размера популяции $\mu$ и вероятности мутации $p_{mut}$ на эффективность алгоритма. Для экспериментов использовалась $(\mu, \lambda)$-стратегия с $\lambda = 5\mu$, промежуточной рекомбинацией и адаптивным масштабированием шага мутации по правилу успеха $1/5$.
\subsection{Проведение измерений}
Для исследования были выбраны следующие значения параметров:
\begin{itemize}
\item $\mu = 5, 10, 20, 40$ -- размер популяции родителей.
\item $p_{mut} = 0.3, 0.5, 0.7, 0.9, 1.0$ -- вероятность мутации каждой координаты.
\item Количество независимых запусков для усреднения результатов: 5.
\item Критерий остановки: достижение порога $f(\mathbf{x}) < 10^{-6}$ или исчерпание лимита 300 поколений.
\end{itemize}
Результаты измерений представлены в таблицах~\ref{tab:es_results_2} и~\ref{tab:es_results_3}. В ячейках указано среднее время выполнения в миллисекундах и среднее число поколений до достижения критерия остановки. Лучшие результаты по времени выполнения и по числу поколений выделены жирным цветом.
\newcolumntype{Y}{>{\centering\arraybackslash}X}
\begin{table}[h!]
\centering
\small
\caption{Результаты для $n = 2$. Формат: время в мс (число поколений)}
\begin{tabularx}{0.95\linewidth}{l *{5}{Y}}
\toprule
$\mathbf{\mu \;\backslash\; p_{mut}}$ & \textbf{0.30} & \textbf{0.50} & \textbf{0.70} & \textbf{0.90} & \textbf{1.00} \\
\midrule
\textbf{5} & 60.6 (37) & 35.1 (23) & 37.9 (25) & 29.2 (20) & \textcolor{magenta}{\textbf{20.4}} (17) \\
\textbf{10} & 69.5 (22) & 84.1 (28) & 61.1 (21) & 48.2 (17) & 38.1 (16) \\
\textbf{20} & 109.6 (18) & 120.4 (20) & 107.0 (18) & 100.2 (17) & 69.4 (15) \\
\textbf{40} & 239.8 (19) & 225.9 (19) & 199.9 (17) & 180.6 (16) & 121.4 (\textcolor{magenta}{\textbf{13}}) \\
\bottomrule
\end{tabularx}
\label{tab:es_results_2}
\end{table}
\begin{table}[h!]
\centering
\small
\caption{Результаты для $n = 3$. Формат: время в мс (число поколений)}
\begin{tabularx}{0.95\linewidth}{l *{5}{Y}}
\toprule
$\mathbf{\mu \;\backslash\; p_{mut}}$ & \textbf{0.30} & \textbf{0.50} & \textbf{0.70} & \textbf{0.90} & \textbf{1.00} \\
\midrule
\textbf{5} & 146.0 (88) & 212.2 (126) & 93.7 (60) & 44.8 (29) & \textcolor{magenta}{\textbf{30.3}} (25) \\
\textbf{10} & 155.9 (49) & 149.3 (48) & 88.7 (30) & 69.8 (24) & 55.7 (23) \\
\textbf{20} & 235.5 (38) & 199.0 (32) & 157.7 (26) & 125.8 (21) & 105.9 (21) \\
\textbf{40} & 670.3 (53) & 374.2 (31) & 311.8 (26) & 258.2 (22) & 194.0 (\textcolor{magenta}{\textbf{20}}) \\
\bottomrule
\end{tabularx}
\label{tab:es_results_3}
\end{table}
\subsection{Анализ результатов}
Анализ экспериментальных данных выявляет следующие закономерности:
\begin{itemize}
\item \textbf{Влияние вероятности мутации:} Увеличение $p_{mut}$ от 0.3 до 1.0 последовательно улучшает результаты как по времени, так и по числу поколений. Это объясняется тем, что более частая мутация всех координат ускоряет исследование пространства и адаптацию популяции. Лучшие результаты достигаются при $p_{mut} = 1.0$ (мутация всех координат на каждом шаге).
\item \textbf{Влияние размера популяции:} При малых $\mu$ (5-10) алгоритм демонстрирует наименьшее время выполнения и умеренное число поколений. С ростом $\mu$ до 40 время увеличивается пропорционально размеру популяции, но число поколений снижается благодаря более широкому охвату пространства поиска. Для двумерной задачи оптимальным является $\mu=5$, $p_{mut}=1.0$ (20.4 мс, 17 поколений).
\item \textbf{Масштабирование на размерность:} При переходе от $n=2$ к $n=3$ время выполнения изменяется незначительно (30.3 мс против 20.4 мс для лучшей конфигурации), однако требуется больше поколений (25 против 17). Это связано с усложнением ландшафта целевой функции и необходимостью большего числа итераций для достижения порога $10^{-6}$.
\item \textbf{Эффективность адаптации:} Правило успеха $1/5$ обеспечивает автоматическую подстройку масштаба мутации, что позволяет алгоритму быстро сходиться без ручной настройки начального $\sigma$. Минимальное число поколений (13 и 20 для $n=2$ и $n=3$ соответственно) достигается при больших популяциях ($\mu=40$) и высокой вероятности мутации ($p_{mut}=1.0$).
\end{itemize}
\newpage
\section{Ответ на контрольный вопрос}
\textbf{Вопрос}: Что такое направленная мутация?
\textbf{Ответ}: Направленная мутация --- это тип мутации, при котором изменения вносятся не случайным образом, а с учётом информации о ландшафте фитнес-функции или направлении улучшения решения. В отличие от обычной (ненаправленной) мутации, которая добавляет случайный шум к параметрам, направленная мутация использует информацию о градиенте функции приспособленности, историю успешных мутаций или другие эвристики, чтобы изменять особь в направлении, с большей вероятностью ведущем к улучшению. Это позволяет ускорить сходимость алгоритма, особенно вблизи оптимума, комбинируя преимущества эволюционного поиска и методов локальной оптимизации.
\newpage
\section*{Заключение}
\addcontentsline{toc}{section}{Заключение}
В ходе пятой лабораторной работы реализована программа оптимизации многомерных функций методом эволюционных стратегий. Получены следующие результаты:
\begin{enumerate}
\item Изучены теоретические основы $(1+1)$ и популяционных ЭС, включая самонастраивающуюся мутацию и правило успеха $1/5$;
\item Разработана модульная Python-реализация с поддержкой визуализации поиска и гибкой конфигурацией стратегических параметров;
\item Проведены вычислительные эксперименты для измерения влияния размера популяции, интенсивности мутации и схемы адаптации на скорость сходимости при $n=2$ и $n=3$;
\item Подготовлена инфраструктура для дальнейшего расширения: сохранение историй поколений, экспорт результатов и интерактивный просмотр шагов оптимизации.
\end{enumerate}
\newpage
% \section*{Список литратуры}
\addcontentsline{toc}{section}{Список литературы}
\vspace{-1.5cm}
\begin{thebibliography}{0}
\bibitem{vostrov}
Методические указания по выполнению лабораторных работ к курсу «Генетические алгоритмы», 119 стр.
\end{thebibliography}
\end{document}

3
lab6/README.md Normal file
View File

@@ -0,0 +1,3 @@
# Attention!
lab6 is fully AI generated slop.

160
lab6/aco.py Normal file
View File

@@ -0,0 +1,160 @@
import math
import random
from dataclasses import dataclass
from typing import List, Sequence, Tuple
import matplotlib.pyplot as plt
City = Tuple[float, float]
Tour = List[int]
def euclidean_distance(c1: City, c2: City) -> float:
return math.hypot(c1[0] - c2[0], c1[1] - c2[1])
def build_distance_matrix(cities: Sequence[City]) -> list[list[float]]:
size = len(cities)
matrix = [[0.0 for _ in range(size)] for _ in range(size)]
for i in range(size):
for j in range(i + 1, size):
dist = euclidean_distance(cities[i], cities[j])
matrix[i][j] = matrix[j][i] = dist
return matrix
def plot_tour(cities: Sequence[City], tour: Sequence[int], save_path: str) -> None:
x = [cities[i][0] for i in tour]
y = [cities[i][1] for i in tour]
fig, ax = plt.subplots(figsize=(7, 7))
ax.plot(x + [x[0]], y + [y[0]], "k-", linewidth=1)
ax.plot(x, y, "ro", markersize=4)
ax.axis("equal")
fig.tight_layout()
fig.savefig(save_path, dpi=220)
plt.close(fig)
def plot_history(best_lengths: Sequence[float], save_path: str) -> None:
if not best_lengths:
return
iterations = list(range(len(best_lengths)))
fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(iterations, best_lengths, linewidth=2, color="blue")
ax.set_xlabel("Итерация", fontsize=12)
ax.set_ylabel("Длина лучшего тура", fontsize=12)
ax.grid(True, alpha=0.3)
fig.savefig(save_path, dpi=150, bbox_inches="tight")
plt.close(fig)
@dataclass
class ACOConfig:
cities: Sequence[City]
n_ants: int
n_iterations: int
alpha: float = 1.0
beta: float = 5.0
rho: float = 0.5
q: float = 1.0
seed: int | None = None
@dataclass
class ACOResult:
best_tour: Tour
best_length: float
history: List[float]
class AntColonyOptimizer:
def __init__(self, config: ACOConfig):
self.config = config
if config.seed is not None:
random.seed(config.seed)
self.cities = config.cities
self.dist_matrix = build_distance_matrix(config.cities)
n = len(config.cities)
self.pheromone = [[1.0 if i != j else 0.0 for j in range(n)] for i in range(n)]
def _choose_next_city(self, current: int, unvisited: set[int]) -> int:
candidates = list(unvisited)
weights = []
for nxt in candidates:
tau = self.pheromone[current][nxt] ** self.config.alpha
eta = (1.0 / (self.dist_matrix[current][nxt] + 1e-12)) ** self.config.beta
weights.append(tau * eta)
total = sum(weights)
probs = [w / total for w in weights]
return random.choices(candidates, weights=probs, k=1)[0]
def _build_tour(self, start: int) -> Tour:
n = len(self.cities)
tour = [start]
unvisited = set(range(n))
unvisited.remove(start)
current = start
while unvisited:
nxt = self._choose_next_city(current, unvisited)
tour.append(nxt)
unvisited.remove(nxt)
current = nxt
return tour
def _tour_length(self, tour: Sequence[int]) -> float:
return sum(
self.dist_matrix[tour[i]][tour[(i + 1) % len(tour)]]
for i in range(len(tour))
)
def run(self) -> ACOResult:
best_tour: Tour = []
best_length = float("inf")
best_history: list[float] = []
for _ in range(self.config.n_iterations):
tours: list[Tour] = []
lengths: list[float] = []
for _ in range(self.config.n_ants):
start_city = random.randrange(len(self.cities))
tour = self._build_tour(start_city)
length = self._tour_length(tour)
tours.append(tour)
lengths.append(length)
if length < best_length:
best_length = length
best_tour = tour
for i in range(len(self.pheromone)):
for j in range(len(self.pheromone)):
self.pheromone[i][j] *= 1 - self.config.rho
for tour, length in zip(tours, lengths):
deposit = self.config.q / length
for i in range(len(tour)):
a, b = tour[i], tour[(i + 1) % len(tour)]
self.pheromone[a][b] += deposit
self.pheromone[b][a] += deposit
best_history.append(best_length)
return ACOResult(
best_tour=best_tour, best_length=best_length, history=best_history
)
def run_aco(config: ACOConfig) -> ACOResult:
optimizer = AntColonyOptimizer(config)
return optimizer.run()

38
lab6/main.py Normal file
View File

@@ -0,0 +1,38 @@
import os
from aco import ACOConfig, plot_history, plot_tour, run_aco
# В списке из 89 городов только 38 уникальных
cities = set()
with open(os.path.join(os.path.dirname(__file__), "../lab3/data.txt"), "r") as file:
for line in file:
# x и y поменяны местами в визуализациях в методичке
_, y, x = line.split()
cities.add((float(x), float(y)))
cities = list(cities)
config = ACOConfig(
cities=cities,
n_ants=50,
n_iterations=50,
alpha=1.2,
beta=5.0,
rho=0.5,
q=1.0,
seed=7,
)
result = run_aco(config)
print(f"Лучшая длина: {result.best_length:.2f}")
print(f"Лучший тур: {result.best_tour}")
results_dir = os.path.join(os.path.dirname(__file__), "report", "img")
os.makedirs(results_dir, exist_ok=True)
plot_tour(
config.cities, result.best_tour, os.path.join(results_dir, "aco_best_tour.png")
)
plot_history(result.history, os.path.join(results_dir, "aco_history.png"))
with open(os.path.join(results_dir, "aco_best_tour.txt"), "w", encoding="utf-8") as f:
f.write(" ".join(map(str, result.best_tour)))

5
lab6/report/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
*
!**/
!.gitignore
!report.tex
!img/**/*.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

455
lab6/report/report.tex Normal file
View File

@@ -0,0 +1,455 @@
\documentclass[a4paper, final]{article}
%\usepackage{literat} % Нормальные шрифты
\usepackage[14pt]{extsizes} % для того чтобы задать нестандартный 14-ый размер шрифта
\usepackage{tabularx}
\usepackage{booktabs}
\usepackage[T2A]{fontenc}
\usepackage[utf8]{inputenc}
\usepackage[russian]{babel}
\usepackage{amsmath}
\usepackage[left=25mm, top=20mm, right=20mm, bottom=20mm, footskip=10mm]{geometry}
\usepackage{ragged2e} %для растягивания по ширине
\usepackage{setspace} %для межстрочно го интервала
\usepackage{moreverb} %для работы с листингами
\usepackage{indentfirst} % для абзацного отступа
\usepackage{moreverb} %для печати в листинге исходного кода программ
\usepackage{pdfpages} %для вставки других pdf файлов
\usepackage{tikz}
\usepackage{graphicx}
\usepackage{afterpage}
\usepackage{longtable}
\usepackage{float}
\usepackage{xcolor}
% \usepackage[paper=A4,DIV=12]{typearea}
\usepackage{pdflscape}
% \usepackage{lscape}
\usepackage{array}
\usepackage{multirow}
\renewcommand\verbatimtabsize{4\relax}
\renewcommand\listingoffset{0.2em} %отступ от номеров строк в листинге
\renewcommand{\arraystretch}{1.4} % изменяю высоту строки в таблице
\usepackage[font=small, singlelinecheck=false, justification=centering, format=plain, labelsep=period]{caption} %для настройки заголовка таблицы
\usepackage{listings} %листинги
\usepackage{xcolor} % цвета
\usepackage{hyperref}% для гиперссылок
\usepackage{enumitem} %для перечислений
\newcommand{\specialcell}[2][l]{\begin{tabular}[#1]{@{}l@{}}#2\end{tabular}}
\setlist[enumerate,itemize]{leftmargin=1.2cm} %отступ в перечислениях
\hypersetup{colorlinks,
allcolors=[RGB]{010 090 200}} %красивые гиперссылки (не красные)
% подгружаемые языки — подробнее в документации listings (это всё для листингов)
\lstloadlanguages{ SQL}
% включаем кириллицу и добавляем кое−какие опции
\lstset{tabsize=2,
breaklines,
basicstyle=\footnotesize,
columns=fullflexible,
flexiblecolumns,
numbers=left,
numberstyle={\footnotesize},
keywordstyle=\color{blue},
inputencoding=cp1251,
extendedchars=true
}
\lstdefinelanguage{MyC}{
language=SQL,
% ndkeywordstyle=\color{darkgray}\bfseries,
% identifierstyle=\color{black},
% morecomment=[n]{/**}{*/},
% commentstyle=\color{blue}\ttfamily,
% stringstyle=\color{red}\ttfamily,
% morestring=[b]",
% showstringspaces=false,
% morecomment=[l][\color{gray}]{//},
keepspaces=true,
escapechar=\%,
texcl=true
}
\textheight=24cm % высота текста
\textwidth=16cm % ширина текста
\oddsidemargin=0pt % отступ от левого края
\topmargin=-1.5cm % отступ от верхнего края
\parindent=24pt % абзацный отступ
\parskip=5pt % интервал между абзацами
\tolerance=2000 % терпимость к "жидким" строкам
\flushbottom % выравнивание высоты страниц
% Настройка листингов
\lstset{
language=python,
extendedchars=\true,
inputencoding=utf8,
keepspaces=true,
% captionpos=b, % подписи листингов снизу
}
\begin{document} % начало документа
% НАЧАЛО ТИТУЛЬНОГО ЛИСТА
\begin{center}
\hfill \break
\hfill \break
\normalsize{МИНИСТЕРСТВО НАУКИ И ВЫСШЕГО ОБРАЗОВАНИЯ РОССИЙСКОЙ ФЕДЕРАЦИИ\\
федеральное государственное автономное образовательное учреждение высшего образования «Санкт-Петербургский политехнический университет Петра Великого»\\[10pt]}
\normalsize{Институт компьютерных наук и кибербезопасности}\\[10pt]
\normalsize{Высшая школа технологий искусственного интеллекта}\\[10pt]
\normalsize{Направление: 02.03.01 <<Математика и компьютерные науки>>}\\
\hfill \break
\hfill \break
\hfill \break
\hfill \break
\large{Лабораторная работа №6}\\
\large{по дисциплине}\\
\large{<<Генетические алгоритмы>>}\\
\large{Вариант 18}\\
% \hfill \break
\hfill \break
\end{center}
\small{
\begin{tabular}{lrrl}
\!\!\!Студент, & \hspace{2cm} & & \\
\!\!\!группы 5130201/20101 & \hspace{2cm} & \underline{\hspace{3cm}} &Тищенко А. А. \\\\
\!\!\!Преподаватель & \hspace{2cm} & \underline{\hspace{3cm}} & Большаков А. А. \\\\
&&\hspace{4cm}
\end{tabular}
\begin{flushright}
<<\underline{\hspace{1cm}}>>\underline{\hspace{2.5cm}} 2025г.
\end{flushright}
}
\hfill \break
% \hfill \break
\begin{center} \small{Санкт-Петербург, 2025} \end{center}
\thispagestyle{empty} % выключаем отображение номера для этой страницы
% КОНЕЦ ТИТУЛЬНОГО ЛИСТА
\newpage
\tableofcontents
\newpage
\section {Постановка задачи}
В данной работе были поставлены следующие задачи:
\begin{itemize}
\item Реализовать с использованием муравьиных алгоритмов решение задачи коммивояжера по индивидуальному заданию согласно номеру варианта.
\item Представить графически найденное решение
\item Сравнить найденное решение с представленным в условии задачи оптимальным решением и результатами, полученными в лабораторной работе №3.
\end{itemize}
\textbf{Индивидуальное задание вариант 18:}
\textbf{Дано:} Эвклидовы координаты городов 38 городов в Джибути (см.~Приложение~А). Оптимальный тур представлен на Рис.~\ref{fig:optimal_tour}, его длина равна 6659.
\begin{figure}[h!]
\centering
\includegraphics[width=0.5\linewidth]{img/optimal_tour.png}
\caption{Оптимальный тур для заданного набора данных}
\label{fig:optimal_tour}
\end{figure}
\newpage
\section{Теоретические сведения}
\subsection{Общие сведения о муравьиных алгоритмах}
Муравьиные алгоритмы (МА) относятся к метаэвристическим методам оптимизации и предназначены преимущественно для решения задач комбинаторной оптимизации, в частности задачи поиска оптимальных путей на графах. Основная идея таких алгоритмов основана на моделировании коллективного поведения реальных муравьёв, использующих феромонные следы для обмена информацией.
Каждый агент, называемый \textit{искусственным муравьём}, поэтапно строит решение задачи, перемещаясь по графу и выбирая следующую вершину на основе вероятностного правила, учитывающего концентрацию феромона на дугах графа. Феромон отражает привлекательность соответствующих маршрутов: чем выше его концентрация на дуге, тем вероятнее выбор этой дуги муравьём.
\subsection{Простой муравьиный алгоритм (SACO)}
Для иллюстрации рассмотрим простой муравьиный алгоритм SACO (Simple Ant Colony Optimization). Пусть задан граф
\[
G = (V, E),
\]
где $V$ — множество вершин, $E$ — множество рёбер. Каждой дуге $(i,j)$ сопоставлена величина феромона $\tau_{ij}$.
В начальный момент концентрация феромона обычно принимается нулевой, однако для предотвращения зацикливания каждому ребру присваивается малое случайное начальное значение $\tau_{ij}^{(0)}$.
Каждый муравей $k=1,\ldots,n_k$ помещается в стартовую вершину и начинает построение пути. Если муравей находится в вершине $i$, он выбирает следующую вершину $j \in N_i^k$ на основе вероятностного правила
\[
p_{ij}^k(t) = \frac{\tau_{ij}^\alpha(t)}{\sum\limits_{l \in N_i^k} \tau_{il}^\alpha(t)},
\]
где $\alpha$ — параметр, определяющий степень влияния феромона.
При отсутствии допустимых переходов допускается возврат в предыдущую вершину, что приводит к появлению петель, которые впоследствии удаляются.
После завершения построения полного пути $x_k(t)$ выполняется его оценка. Длина пути обозначается как $L_k(t)$ и равна числу пройденных дуг.
\subsection{Обновление феромона}
Каждый муравей откладывает феромон на рёбрах своего пути согласно правилу
\[
\Delta \tau_{ij}^k(t) =
\begin{cases}
\frac{1}{L_k(t)}, &\text{если дуга } (i,j) \in x_k(t), \\
0, &\text{иначе}.
\end{cases}
\]
Общее обновление феромона на дуге $(i,j)$:
\[
\tau_{ij}(t+1) = \tau_{ij}(t) + \sum_{k=1}^{n_k} \Delta\tau_{ij}^k(t).
\]
Чем короче путь, тем больше феромона откладывается на его рёбрах, что повышает вероятность выбора коротких маршрутов в последующих итерациях.
\subsection{Испарение феромона}
Чтобы предотвратить преждевременную сходимость алгоритма к локальным минимумам, применяется механизм \textit{искусственного испарения феромона}. На каждом шаге выполняется:
\[
\tau_{ij}(t) = (1 - \rho)\,\tau_{ij}(t),
\]
где $\rho \in [0,1]$ — коэффициент испарения. Большие значения $\rho$ усиливают случайность поиска, малые — повышают устойчивость к изменениям.
\subsection{Критерии остановки алгоритма}
Муравьиные алгоритмы могут завершаться при выполнении одного из условий:
\begin{itemize}
\item достигнуто максимальное число итераций;
\item найдено решение приемлемого качества $f(x_k(t)) \leq \varepsilon$;
\item все муравьи начинают строить одинаковые маршруты, что говорит о стабилизации процесса.
\end{itemize}
\subsection{Описание общего алгоритма}
Алгоритм SACO можно представить в следующем виде:
\begin{enumerate}
\item Инициализация феромона малыми случайными значениями $\tau_{ij}^{(0)}$.
\item Размещение всех муравьёв в начальной вершине.
\item Для каждой итерации:
\begin{enumerate}
\item Каждый муравей строит путь согласно вероятностному правилу выбора вершины.
\item Выполняется удаление петель.
\item Вычисляется длина пути $L_k(t)$.
\end{enumerate}
\item Выполняется испарение феромона.
\item Каждый муравей откладывает феромон на рёбрах своего пути.
\item Итерация продолжается до выполнения критерия остановки.
\end{enumerate}
Муравьиные алгоритмы позволяют эффективно находить приближённые решения задач комбинаторной оптимизации, таких как задача коммивояжёра, что и является целью данной лабораторной работы.
\newpage
\section{Особенности реализации}
Код решения собран в модуле \texttt{lab6/aco.py}. Реализация использует объектно-ориентированный подход с явной типизацией через современные аннотации типов Python (PEP 604). Ниже приведены ключевые элементы реализации с сигнатурами функций и пояснениями.
\subsection{Структуры данных конфигурации и результата}
Конфигурация алгоритма оформлена через \texttt{@dataclass} и включает все параметры, влияющие на поведение ACO:
\begin{lstlisting}[language=Python]
@dataclass
class ACOConfig:
cities: Sequence[City] # список координат городов
n_ants: int # число муравьев
n_iterations: int # число итераций
alpha: float = 1.0 # влияние феромона
beta: float = 5.0 # влияние эвристики (1/расстояние)
rho: float = 0.5 # коэффициент испарения
q: float = 1.0 # константа для отложения феромона
seed: int | None = None # зерно ГСЧ (воспроизводимость)
\end{lstlisting}
Результат работы алгоритма представлен структурой:
\begin{lstlisting}[language=Python]
@dataclass
class ACOResult:
best_tour: Tour # индексы городов в порядке обхода
best_length: float # длина лучшего маршрута
history: List[float] # история длин по итерациям
\end{lstlisting}
\subsection{Класс AntColonyOptimizer и инициализация}
Основная логика инкапсулирована в классе \texttt{AntColonyOptimizer}, который принимает конфигурацию при создании:
\begin{lstlisting}[language=Python]
class AntColonyOptimizer:
def __init__(self, config: ACOConfig)
\end{lstlisting}
В конструкторе выполняются следующие действия:
\begin{itemize}
\item инициализация генератора случайных чисел через \texttt{random.seed(config.seed)} для обеспечения воспроизводимости экспериментов;
\item вычисление матрицы расстояний между всеми городами с помощью \texttt{build\_distance\_matrix};
\item создание матрицы феромона размером $n \times n$, где все недиагональные элементы инициализируются единицами, а диагональные — нулями (для предотвращения самопереходов).
\end{itemize}
\subsection{Построение тура муравьём}
Каждый муравей строит полный гамильтонов цикл, начиная со случайно выбранного стартового города. Ключевой метод выбора следующего города:
\begin{lstlisting}[language=Python]
def _choose_next_city(self, current: int,
unvisited: set[int]) -> int
\end{lstlisting}
Метод реализует вероятностный выбор на основе формулы:
\[
p_{ij} = \frac{[\tau_{ij}]^\alpha \cdot [\eta_{ij}]^\beta}{\sum_{k \in \text{unvisited}} [\tau_{ik}]^\alpha \cdot [\eta_{ik}]^\beta}
\]
где $\tau_{ij}$ — уровень феромона на ребре $(i,j)$, а $\eta_{ij} = 1/d_{ij}$ — эвристическая привлекательность (обратная величина расстояния). К расстоянию добавляется малая константа $10^{-12}$ для численной стабильности при делении. Финальный выбор осуществляется через \texttt{random.choices} с вычисленными вероятностями.
Построение полного тура выполняет метод:
\begin{lstlisting}[language=Python]
def _build_tour(self, start: int) -> Tour
\end{lstlisting}
Начиная со стартового города, муравей последовательно выбирает следующие непосещённые города до тех пор, пока множество \texttt{unvisited} не станет пустым.
Вычисление длины построенного тура:
\begin{lstlisting}[language=Python]
def _tour_length(self, tour: Sequence[int]) -> float
\end{lstlisting}
Метод суммирует расстояния между последовательными городами в туре, включая замыкающее ребро от последнего города к первому, используя предвычисленную матрицу расстояний.
\subsection{Основной цикл алгоритма}
Главный метод запуска оптимизации:
\begin{lstlisting}[language=Python]
def run(self) -> ACOResult
\end{lstlisting}
На каждой из \texttt{n\_iterations} итераций выполняются следующие шаги:
\begin{enumerate}
\item \textbf{Построение туров}: каждый из \texttt{n\_ants} муравьёв создаёт свой маршрут, начиная со случайного города. Вычисляется длина каждого маршрута, и глобально лучший тур обновляется при обнаружении более короткого.
\item \textbf{Испарение феромона}: все элементы матрицы феромона умножаются на $(1 - \rho)$, моделируя естественное испарение. Это предотвращает неограниченный рост концентрации феромона и позволяет алгоритму «забывать» плохие решения.
\item \textbf{Отложение феромона}: для каждого муравья вычисляется вклад $\Delta\tau = q/L$, где $L$ — длина его маршрута. Этот вклад добавляется симметрично на оба направления каждого ребра в туре. Таким образом, короткие маршруты откладывают больше феромона.
\item \textbf{Запись истории}: лучшая на данный момент длина добавляется в список \texttt{history} для последующего анализа сходимости.
\end{enumerate}
По завершении всех итераций метод возвращает \texttt{ACOResult} с лучшим найденным туром, его длиной и историей оптимизации.
\subsection{Точка входа}
Для удобства использования предоставлена функция верхнего уровня:
\begin{lstlisting}[language=Python]
def run_aco(config: ACOConfig) -> ACOResult
\end{lstlisting}
Она создаёт экземпляр оптимизатора и запускает алгоритм, возвращая результат.
\subsection{Визуализация}
Модуль включает две функции для визуализации результатов средствами \texttt{matplotlib}:
Функция построения графика маршрута:
\begin{lstlisting}[language=Python]
def plot_tour(cities: Sequence[City], tour: Sequence[int],
save_path: str) -> None
\end{lstlisting}
Отображает города в виде точек и соединяет их ломаной линией в порядке обхода, включая возврат к начальной точке. Используется соотношение сторон \texttt{aspect="equal"} для сохранения геометрии, сетка для лучшей читаемости координат. Результат сохраняется в PNG с разрешением 220 DPI.
Функция построения графика сходимости:
\begin{lstlisting}[language=Python]
def plot_history(best_lengths: Sequence[float],
save_path: str) -> None
\end{lstlisting}
Строит линейный график изменения длины лучшего найденного тура по итерациям. Позволяет визуально оценить скорость сходимости и стабильность алгоритма.
\newpage
\section{Результаты работы}
Алгоритм был запущен со следующими параметрами: 50 муравьёв, 50 итераций, $\alpha = 1{,}2$, $\beta = 5$, $\rho = 0{,}5$, $q = 1$. Лучший найденный тур имеет длину $6662{,}35$, что на $0{,}05\%$ отличается от оптимального значения 6659.
\begin{figure}[h!]
\centering
\begin{minipage}{0.48\linewidth}
\centering
\includegraphics[width=0.95\linewidth]{img/optimal_tour.png}
\caption{Оптимальный маршрут длиной 6659}
\label{fig:optimal_result}
\end{minipage}\hfill
\begin{minipage}{0.48\linewidth}
\centering
\includegraphics[width=0.95\linewidth]{img/aco_best_tour.png}
\caption{Лучший маршрут, найденный муравьиным алгоритмом (6662{,}35)}
\label{fig:aco_tour}
\end{minipage}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=0.9\linewidth]{img/aco_history.png}
\caption{Сходимость длины лучшего тура по итерациям}
\label{fig:aco_history}
\end{figure}
\subsection{Сравнение с результатами лабораторной работы~№3}
Для лабораторной работы №3 с генетическим алгоритмом лучший результат составил \textbf{6667{,}03} при популяции $N=500$, вероятностях $P_c=0{,}9$ и $P_m=0{,}5$. Муравьиный алгоритм показал более точное решение: длина тура \textbf{6662{,}35} против оптимального 6659. Разница с оптимумом составила 3{,}35 единицы (0{,}05\%), тогда как в лабораторной работе №3 отклонение было 8{,}03 (0{,}12\%).
\begin{figure}[h!]
\centering
\begin{minipage}{0.48\linewidth}
\centering
\includegraphics[width=0.95\linewidth]{img/best_lab3.png}
\caption{Лучший маршрут из лабораторной работы №3 (ГА): длина 6667{,}03}
\label{fig:lab3_best}
\end{minipage}\hfill
\begin{minipage}{0.48\linewidth}
\centering
\includegraphics[width=0.95\linewidth]{img/aco_best_tour.png}
\caption{Лучший маршрут лабораторной работы №6 (МА): длина 6662{,}35}
\label{fig:lab6_best}
\end{minipage}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=0.43\linewidth]{img/optimal_tour.png}
\caption{Оптимальный маршрут длиной 6659}
\label{fig:optimal_comparison}
\end{figure}
\newpage
\section{Ответ на контрольный вопрос}
\textbf{Вопрос}: Какие критерии окончания могут быть использованы в простом МА?
\textbf{Ответ}: В простом муравьином алгоритме могут использоваться следующие критерии завершения работы:
\begin{itemize}
\item окончание при превышении заданного числа итераций;
\item окончание по достижению приемлемого решения;
\item окончание в случае, когда все муравьи начинают следовать одним и тем же путём.
\end{itemize}
\newpage
\section*{Заключение}
\addcontentsline{toc}{section}{Заключение}
В ходе шестой лабораторной работы выполнена реализация простого муравьиного алгоритма для задачи коммивояжёра:
\begin{enumerate}
\item Разработан модуль \texttt{aco.py} с конфигурацией алгоритма, построением туров, обновлением феромона и визуализацией результатов с помощью \texttt{matplotlib}.
\item Проведён численный эксперимент на данных из варианта 18 (38 городов Джибути); подобраны параметры $\alpha=1{,}2$, $\beta=5$, $\rho=0{,}5$, 50 муравьёв, 400 итераций.
\item Получено приближённое решение длиной 6662{,}35, что всего на 0{,}05\% хуже известного оптимума 6659 и лучше результата, достигнутого генетическим алгоритмом из лабораторной работы №3.
\end{enumerate}
\newpage
\section*{Список литературы}
\addcontentsline{toc}{section}{Список литературы}
\vspace{-1.5cm}
\begin{thebibliography}{0}
\bibitem{vostrov}
Методические указания по выполнению лабораторных работ к курсу «Генетические алгоритмы», 119 стр.
\end{thebibliography}
\end{document}