11 Commits
lab4 ... lab5

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
22 changed files with 2498 additions and 2 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

View File

@@ -26,9 +26,9 @@ from gp.mutations import (
ShrinkMutation,
)
from gp.ops import ADD, COS, DIV, EXP, MUL, POW, SIN, SQUARE, SUB
from gp.population import ramped_initialization
from gp.primitive import Var
from gp.selection import tournament_selection
from gp.utils import ramped_initialization
NUM_VARS = 8
TEST_POINTS = 10000

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}