38 Commits
lab1 ... main

Author SHA1 Message Date
672b1c33d5 lab6 2025-12-03 18:16:28 +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
74e02df205 i think i've done this shit RMSE: 0.64 !!!! 2025-11-07 01:44:59 +03:00
cfae423f11 best for now RMSE: 30.937 2025-11-07 00:11:02 +03:00
cb2b031e9c safe operations 2025-11-06 23:12:48 +03:00
cc180dc700 fitnesses 2025-11-06 22:50:10 +03:00
e6765c9254 vectorized 2025-11-05 20:32:09 +03:00
26bd6da1b4 another save 2025-11-05 20:07:35 +03:00
8e8e0abd0d save 2025-11-04 15:02:02 +03:00
83be98e923 Вынес методы инициализации из хромосомы 2025-10-21 18:14:20 +03:00
afd7a700ca Хромосомы для лаб4 2025-10-21 12:26:43 +03:00
268c4cf4a1 Лаба 3 2025-10-16 11:22:02 +03:00
740a7be984 lab3 2025-10-15 16:43:11 +03:00
2cf0693070 Опечатка в типах в lab2 2025-10-15 16:43:05 +03:00
3436f94b61 Заголовок на нужной странице 2025-10-10 16:11:32 +03:00
f79a6abf1b Заменил данные + анализ от клода 2025-10-09 13:19:05 +03:00
ee79d6ad41 Цвета в табличках, остановка по лучшему решению 2025-10-09 13:15:09 +03:00
745cfea282 Остановка по повтореням лучшего значения 2025-10-09 12:25:23 +03:00
b7f2234bff Контрольный вопрос и заключение 2025-10-09 11:40:51 +03:00
1e9e52341a Анализ результатов от claude 2025-10-08 17:39:32 +03:00
7c54d08b71 Скрипт для генерации tex таблиц по результатам экспериментов 2025-10-08 17:33:14 +03:00
c15867f027 Lab2 половина отчёта 2025-10-08 17:30:39 +03:00
12276dc54c Эксперименты lab2 2025-10-08 15:12:34 +03:00
42387c70cb lab2 предварительная версия 2025-10-08 15:04:38 +03:00
27f4a8f2d8 Реферат по ГА 2025-10-03 10:52:23 +03:00
9c2e13e90b Подписи таблиц сверху 2025-09-19 11:03:53 +03:00
115 changed files with 9099 additions and 5 deletions

6
.gitignore vendored
View File

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

View File

@@ -448,6 +448,7 @@
\begin{table}[h!]
\centering
\small
\caption{Результаты для $N = 10$}
\begin{tabularx}{\linewidth}{l *{5}{Y}}
\toprule
$\mathbf{P_c \;\backslash\; P_m}$ & \textbf{0.001} & \textbf{0.010} & \textbf{0.050} & \textbf{0.100} & \textbf{0.200} \\
@@ -460,13 +461,13 @@
\textbf{0.8} & 8.7 (126) & 8.3 (119) & 3.9 (57) & 7.9 (113)& 4.4 (61) \\
\bottomrule
\end{tabularx}
\caption{Результаты для $N = 10$}
\label{tab:pc_pm_results}
\end{table}
\begin{table}[h!]
\centering
\small
\caption{Результаты для $N = 25$}
\begin{tabularx}{\linewidth}{l *{5}{Y}}
\toprule
$\mathbf{P_c \;\backslash\; P_m}$ & \textbf{0.001} & \textbf{0.010} & \textbf{0.050} & \textbf{0.100} & \textbf{0.200} \\
@@ -479,13 +480,13 @@
\textbf{0.8} & 9.2 (64) & 5.8 (41) & 2.5 (18) & 3.0 (22) & 11.2 (78) \\
\bottomrule
\end{tabularx}
\caption{Результаты для $N = 25$}
\label{tab:pc_pm_results2}
\end{table}
\begin{table}[h!]
\centering
\small
\caption{Результаты для $N = 50$}
\begin{tabularx}{\linewidth}{l *{5}{Y}}
\toprule
$\mathbf{P_c \;\backslash\; P_m}$ & \textbf{0.001} & \textbf{0.010} & \textbf{0.050} & \textbf{0.100} & \textbf{0.200} \\
@@ -498,12 +499,12 @@
\textbf{0.8} & 10.8 (44) & 5.0 (21) & 6.1 (24) & 13.9 (56) & 36.5 (145) \\
\bottomrule
\end{tabularx}
\caption{Результаты для $N = 50$}
\label{tab:pc_pm_results3}
\end{table}
\begin{table}[h!]
\centering
\small
\caption{Результаты для $N = 100$}
\begin{tabularx}{\linewidth}{l *{5}{Y}}
\toprule
$\mathbf{P_c \;\backslash\; P_m}$ & \textbf{0.001} & \textbf{0.010} & \textbf{0.050} & \textbf{0.100} & \textbf{0.200} \\
@@ -516,7 +517,6 @@
\textbf{0.8} & 16.4 (34) & 12.1 (25) & 31.4 (64) & 54.9 (114) & -- \\
\bottomrule
\end{tabularx}
\caption{Результаты для $N = 100$}
\label{tab:pc_pm_results4}
\end{table}

339
lab2/csv_to_tex.py Normal file
View File

@@ -0,0 +1,339 @@
"""
Скрипт для конвертации результатов экспериментов из CSV в LaTeX таблицы.
Этот скрипт автоматически сканирует папку experiments/, находит все подпапки
с файлами results.csv, парсит данные экспериментов и генерирует LaTeX код
таблиц в формате, готовом для вставки в отчёт.
Структура входных данных:
- experiments/N/results.csv, где N - размер популяции
- CSV содержит результаты экспериментов с различными параметрами Pc и Pm
- Значения в формате "X.Y (Z)" где X.Y - время выполнения, Z - количество итераций
- "" для отсутствующих данных
Выходной файл: tables.tex с готовым LaTeX кодом всех таблиц.
Лучшие результаты по времени и фитнесу выделяются жирным (и цветом, если задан HIGHLIGHT_COLOR).
"""
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) >= 6: # Pc + 5 значений Pm
data_rows.append(parts)
return header, data_rows
def extract_time_value(value: str) -> float | None:
"""
Извлекает значение времени из строки формата "X.Y (Z)" или "X.Y (Z) W.V".
Args:
value: Строка с результатом
Returns:
Время выполнения как float или None если значение пустое
"""
value = value.strip()
if value == "" or value == "" or value == "":
return None
# Ищем паттерн "число.число (число)"
match = re.match(r"(\d+\.?\d*)\s*\(", value)
if match:
return float(match.group(1))
return None
def extract_fitness_value(value: str) -> float | None:
"""
Извлекает значение фитнеса из строки формата "X.Y (Z) W.V".
Args:
value: Строка с результатом
Returns:
Значение фитнеса как float или None если значение пустое
"""
value = value.strip()
if value == "" or value == "" or value == "":
return None
# Ищем паттерн "число.число (число) число.число"
# Фитнес - это последнее число в строке
match = re.search(r"\)\s+(\d+\.?\d*)\s*$", 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, min(6, len(row))): # Пропускаем первую колонку (Pc)
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_fitness(data_rows: list[list[str]]) -> float | None:
"""
Находит минимальное значение фитнеса среди всех значений в таблице.
Args:
data_rows: Строки данных таблицы
Returns:
Минимальное значение фитнеса или None если нет валидных значений
"""
min_fitness = None
for row in data_rows:
for i in range(1, min(6, len(row))): # Пропускаем первую колонку (Pc)
fitness_value = extract_fitness_value(row[i])
if fitness_value is not None:
if min_fitness is None or fitness_value < min_fitness:
min_fitness = fitness_value
return min_fitness
def format_value(
value: str, best_time: float | None = None, best_fitness: float | None = None
) -> str:
"""
Форматирует значение для LaTeX таблицы, выделяя лучшие результаты жирным.
Args:
value: Строковое значение из CSV
best_time: Лучшее время в таблице для сравнения
best_fitness: Лучший фитнес в таблице для сравнения
Returns:
Отформатированное значение для LaTeX
"""
value = value.strip()
if value == "" or value == "" or value == "":
return ""
# Проверяем есть ли фитнес в строке
fitness_match = re.search(r"(\d+\.?\d*)\s*\((\d+)\)\s+(\d+\.?\d*)\s*$", value)
if fitness_match:
# Есть фитнес: "время (поколения) фитнес"
time_str = fitness_match.group(1)
generations_str = fitness_match.group(2)
fitness_str = fitness_match.group(3)
current_time = float(time_str)
current_fitness = float(fitness_str)
# Проверяем, является ли время лучшим
time_part = f"{time_str} ({generations_str})"
if best_time is not None and abs(current_time - best_time) < 0.001:
if HIGHLIGHT_COLOR is not None:
time_part = (
f"\\textcolor{{{HIGHLIGHT_COLOR}}}{{\\textbf{{{time_part}}}}}"
)
else:
time_part = f"\\textbf{{{time_part}}}"
# Проверяем, является ли фитнес лучшим
fitness_part = fitness_str
if best_fitness is not None and abs(current_fitness - best_fitness) < 0.00001:
if HIGHLIGHT_COLOR is not None:
fitness_part = (
f"\\textcolor{{{HIGHLIGHT_COLOR}}}{{\\textbf{{{fitness_part}}}}}"
)
else:
fitness_part = f"\\textbf{{{fitness_part}}}"
return f"{time_part} {fitness_part}"
else:
# Нет фитнеса: только "время (поколения)"
time_match = re.match(r"(\d+\.?\d*)\s*\((\d+)\)", value)
if time_match:
current_time = float(time_match.group(1))
if best_time is not None and abs(current_time - best_time) < 0.001:
if HIGHLIGHT_COLOR is not None:
return f"\\textcolor{{{HIGHLIGHT_COLOR}}}{{\\textbf{{{value}}}}}"
else:
return f"\\textbf{{{value}}}"
return value
def generate_latex_table(n: str, header: str, data_rows: list[list[str]]) -> str:
"""
Генерирует LaTeX код таблицы.
Args:
n: Размер популяции
header: Заголовок таблицы
data_rows: Строки данных
Returns:
LaTeX код таблицы
"""
# Находим лучшее время и лучший фитнес в таблице
best_time = find_best_time(data_rows)
best_fitness = find_best_fitness(data_rows)
# Извлекаем заголовки колонок из header
header_parts = header.split(",")
pm_values = header_parts[1:] # Пропускаем "Pc \ Pm"
latex_code = f""" \\begin{{table}}[h!]
\\centering
\\small
\\caption{{Результаты для $N = {n}$}}
\\begin{{tabularx}}{{\\linewidth}}{{l *{{5}}{{Y}}}}
\\toprule
$\\mathbf{{P_c \\;\\backslash\\; P_m}}$"""
# Добавляем заголовки Pm
for pm in pm_values:
latex_code += f" & \\textbf{{{pm.strip()}}}"
latex_code += " \\\\\n \\midrule\n"
# Добавляем строки данных
for row in data_rows:
pc_value = row[0].strip()
latex_code += f" \\textbf{{{pc_value}}}"
# Добавляем значения для каждого Pm
for i in range(1, min(6, len(row))): # Максимум 5 колонок Pm
value = format_value(row[i], best_time, best_fitness)
latex_code += f" & {value}"
# Заполняем недостающие колонки если их меньше 5
for i in range(len(row) - 1, 5):
latex_code += " & —"
latex_code += " \\\\\n"
latex_code += f""" \\bottomrule
\\end{{tabularx}}
\\label{{tab:pc_pm_results_{n}}}
\\end{{table}}"""
return latex_code
def main():
"""Основная функция скрипта."""
experiments_path = Path("experiments")
if not experiments_path.exists():
print("Папка experiments не найдена!")
return
tables = []
# Сканируем все подпапки в experiments, сортируем по числовому значению N
subdirs = [
subdir
for subdir in experiments_path.iterdir()
if subdir.is_dir() and subdir.name.isdigit()
]
subdirs.sort(key=lambda x: int(x.name))
for subdir in subdirs:
n = subdir.name
csv_file = subdir / "results.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_fitness = find_best_fitness(data_rows)
latex_table = generate_latex_table(n, header, data_rows)
tables.append(latex_table)
print(
f"✓ Таблица для N={n} готова (лучшее время: {best_time}, лучший фитнес: {best_fitness})"
)
except Exception as e:
print(f"✗ Ошибка при обработке {csv_file}: {e}")
else:
print(f"✗ Файл {csv_file} не найден")
# Сохраняем все таблицы в файл
if tables:
with open("tables.tex", "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Все таблицы сохранены в файл 'tables.tex'")
print(f"Сгенерировано таблиц: {len(tables)}")
else:
print("Не найдено данных для генерации таблиц!")
if __name__ == "__main__":
main()

179
lab2/expirements.py Normal file
View File

@@ -0,0 +1,179 @@
import math
import os
import shutil
import statistics
import numpy as np
from gen import GARunConfig, genetic_algorithm
from prettytable import PrettyTable
def fitness_function(chromosome: np.ndarray) -> np.ndarray:
return chromosome[0] ** 2 + 2 * chromosome[1] ** 2
# Базовая папка для экспериментов
BASE_DIR = "experiments"
# Параметры для экспериментов
POPULATION_SIZES = [10, 25, 50, 100]
PC_VALUES = [0.3, 0.4, 0.5, 0.6, 0.7, 0.8] # вероятности кроссинговера
PM_VALUES = [0.001, 0.01, 0.05, 0.1, 0.2] # вероятности мутации
SAVE_AVG_BEST_FITNESS = True
# Количество запусков для усреднения результатов
NUM_RUNS = 1
# Базовые параметры (как в main.py)
BASE_CONFIG = {
"x_min": np.array([-5.12, -5.12]),
"x_max": np.array([5.12, 5.12]),
"fitness_func": fitness_function,
"max_generations": 200,
"seed": None, # None для случайности, т. к. всё усредняем
"minimize": True,
# "fitness_avg_threshold": 0.05, # критерий остановки
# "max_best_repetitions": 10,
"best_value_threshold": 0.005,
# при включенном сохранении графиков на время смотреть бессмысленно
# "save_generations": [1, 50, 199],
}
def run_single_experiment(
pop_size: int, pc: float, pm: float
) -> tuple[float, float, float, float, float, float]:
"""
Запускает несколько экспериментов с заданными параметрами и усредняет результаты.
Возвращает (среднееремя_в_мс, стд_отклонениеремени, среднее_поколений,
стд_отклонение_поколений, среднееучшее_значение_фитнеса, стд_отклонениеучшего_значения_фитнеса).
"""
times = []
generations = []
best_fitnesses = []
for run_num in range(NUM_RUNS):
config = GARunConfig(
**BASE_CONFIG,
pop_size=pop_size,
pc=pc,
pm=pm,
results_dir=os.path.join(
BASE_DIR,
str(pop_size),
f"pc_{pc:.3f}",
f"pm_{pm:.3f}",
f"run_{run_num}",
),
)
result = genetic_algorithm(config)
times.append(result.time_ms)
generations.append(result.generations_count)
best_fitnesses.append(result.best_generation.best_fitness)
# Вычисляем средние значения и стандартные отклонения
avg_time = statistics.mean(times)
std_time = statistics.stdev(times) if len(times) > 1 else 0.0
avg_generations = statistics.mean(generations)
std_generations = statistics.stdev(generations) if len(generations) > 1 else 0.0
avg_best_fitness = statistics.mean(best_fitnesses)
std_best_fitness = (
statistics.stdev(best_fitnesses) if len(best_fitnesses) > 1 else 0.0
)
return (
avg_time,
std_time,
avg_generations,
std_generations,
avg_best_fitness,
std_best_fitness,
)
def run_experiments_for_population(pop_size: int) -> PrettyTable:
"""
Запускает эксперименты для одного размера популяции.
Возвращает таблицу результатов.
"""
print(f"\nЗапуск экспериментов для популяции размером {pop_size}...")
print(f"Количество запусков для усреднения: {NUM_RUNS}")
# Создаем таблицу
table = PrettyTable()
table.field_names = ["Pc \\ Pm"] + [f"{pm:.3f}" for pm in PM_VALUES]
# Запускаем эксперименты для всех комбинаций Pc и Pm
for pc in PC_VALUES:
row = [f"{pc:.1f}"]
for pm in PM_VALUES:
print(f" Эксперимент: pop_size={pop_size}, Pc={pc:.1f}, Pm={pm:.3f}")
(
avg_time,
std_time,
avg_generations,
std_generations,
avg_best_fitness,
std_best_fitness,
) = run_single_experiment(pop_size, pc, pm)
# Форматируем результат: среднееремя±стд_отклонение (среднее_поколения±стд_отклонение)
# cell_value = f"{avg_time:.1f}±{std_time:.1f} ({avg_generations:.1f}±{std_generations:.1f})"
cell_value = f"{avg_time:.1f} ({avg_generations:.0f})"
if SAVE_AVG_BEST_FITNESS:
cell_value += f" {avg_best_fitness:.5f}"
if avg_generations == BASE_CONFIG["max_generations"]:
cell_value = ""
row.append(cell_value)
table.add_row(row)
return table
def main():
"""Основная функция для запуска всех экспериментов."""
print("=" * 60)
print("ЗАПУСК ЭКСПЕРИМЕНТОВ ПО ПАРАМЕТРАМ ГЕНЕТИЧЕСКОГО АЛГОРИТМА")
print("=" * 60)
print(f"Размеры популяции: {POPULATION_SIZES}")
print(f"Значения Pc: {PC_VALUES}")
print(f"Значения Pm: {PM_VALUES}")
print(f"Количество запусков для усреднения: {NUM_RUNS}")
print("=" * 60)
# Создаем базовую папку
if os.path.exists(BASE_DIR):
shutil.rmtree(BASE_DIR)
os.makedirs(BASE_DIR)
# Запускаем эксперименты для каждого размера популяции
for pop_size in POPULATION_SIZES:
table = run_experiments_for_population(pop_size)
print(f"\n{'='*60}")
print(f"РЕЗУЛЬТАТЫ ДЛЯ ПОПУЛЯЦИИ РАЗМЕРОМ {pop_size}")
print(f"{'='*60}")
print(
f"Формат: среднееремя±стд_отклонениес (среднее_поколения±стд_отклонение)"
)
print(f"Усреднено по {NUM_RUNS} запускам")
print(table)
pop_exp_dir = os.path.join(BASE_DIR, str(pop_size))
os.makedirs(pop_exp_dir, exist_ok=True)
with open(os.path.join(pop_exp_dir, "results.csv"), "w", encoding="utf-8") as f:
f.write(table.get_csv_string())
print(f"Результаты сохранены в папке: {pop_exp_dir}")
print(f"\n{'='*60}")
print("ВСЕ ЭКСПЕРИМЕНТЫ ЗАВЕРШЕНЫ!")
print(f"Результаты сохранены в {BASE_DIR}")
print(f"{'='*60}")
if __name__ == "__main__":
main()

454
lab2/gen.py Normal file
View File

@@ -0,0 +1,454 @@
import os
import random
import shutil
import time
from copy import deepcopy
from dataclasses import dataclass
from typing import Callable
import numpy as np
import plotly.graph_objects as go
from matplotlib import pyplot as plt
from matplotlib.axes import Axes
from mpl_toolkits.mplot3d import Axes3D
from numpy.typing import NDArray
type Chromosome = NDArray[np.float64]
type Population = list[Chromosome]
type Fitnesses = NDArray[np.float64]
type FitnessFn = Callable[[Chromosome], np.float64]
type CrossoverFn = Callable[[Chromosome, Chromosome], tuple[Chromosome, Chromosome]]
type MutationFn = Callable[[Chromosome], Chromosome]
@dataclass
class GARunConfig:
x_min: Chromosome
x_max: Chromosome
fitness_func: FitnessFn
pop_size: int # размер популяции
pc: float # вероятность кроссинговера
pm: float # вероятность мутации
max_generations: int # максимальное количество поколений
max_best_repetitions: int | None = (
None # остановка при повторении лучшего результата
)
seed: int | None = None # seed для генератора случайных чисел
minimize: bool = False # если True, ищем минимум вместо максимума
save_generations: list[int] | None = (
None # индексы поколений для сохранения графиков
)
results_dir: str = "results" # папка для сохранения графиков
fitness_avg_threshold: float | None = (
None # порог среднего значения фитнес функции для остановки
)
best_value_threshold: float | None = (
None # остановка при достижении значения фитнеса лучше заданного
)
log_every_generation: bool = False # логировать каждое поколение
@dataclass(frozen=True)
class Generation:
number: int
best: Chromosome
best_fitness: float
population: Population
fitnesses: Fitnesses
@dataclass(frozen=True)
class GARunResult:
generations_count: int
best_generation: Generation
history: list[Generation]
time_ms: float
def initialize_population(
pop_size: int, x_min: Chromosome, x_max: Chromosome
) -> Population:
"""Инициализирует популяцию случайными векторами из заданного диапазона."""
return [np.random.uniform(x_min, x_max, x_min.shape) for _ in range(pop_size)]
def reproduction(population: Population, fitnesses: Fitnesses) -> Population:
"""Репродукция (селекция) методом рулетки.
Чем больше значение фитнеса, тем больше вероятность выбора особи. Для минимизации
значения фитнеса нужно предварительно инвертировать.
"""
# Чтобы работать с отрицательными f, сдвигаем значения фитнес функции на минимальное
# значение в популяции. Вычитаем min_fit, т. к. min_fit может быть отрицательным.
min_fit = np.min(fitnesses)
shifted_fitnesses = fitnesses - min_fit + 1e-12
# Получаем вероятности для каждой особи
probs = shifted_fitnesses / np.sum(shifted_fitnesses)
cum = np.cumsum(probs)
# Выбираем особей методом рулетки
selected = []
for _ in population:
r = np.random.random()
idx = int(np.searchsorted(cum, r, side="left"))
selected.append(population[idx])
return selected
def arithmetical_crossover_fn(
p1: Chromosome, p2: Chromosome, w: float = 0.5
) -> tuple[Chromosome, Chromosome]:
"""Арифметический кроссинговер."""
h1 = w * p1 + (1 - w) * p2
h2 = (1 - w) * p1 + w * p2
return h1, h2
def geometrical_crossover_fn(
p1: Chromosome, p2: Chromosome, w: float = 0.5
) -> tuple[Chromosome, Chromosome]:
"""Геометрический кроссинговер."""
h1 = np.power(p1, w) * np.power(p2, 1 - w)
h2 = np.power(p2, w) * np.power(p1, 1 - w)
return h1, h2
def crossover(
population: Population,
pc: float,
crossover_fn: CrossoverFn,
) -> Population:
"""Оператор кроссинговера (скрещивания) выполняется с заданной вероятностью pc.
Две хромосомы (родители) выбираются случайно из промежуточной популяции.
Если популяция нечетного размера, то последняя хромосома скрещивается со случайной
другой хромосомой из популяции. В таком случае одна из хромосом может поучаствовать
в кроссовере дважды.
"""
# Создаем копию популяции и перемешиваем её для случайного выбора пар
shuffled_population = population.copy()
np.random.shuffle(shuffled_population)
next_population = []
pop_size = len(shuffled_population)
for i in range(0, pop_size, 2):
p1 = shuffled_population[i]
p2 = shuffled_population[(i + 1) % pop_size]
if np.random.random() <= pc:
p1, p2 = crossover_fn(p1, p2)
next_population.append(p1)
next_population.append(p2)
return next_population[:pop_size]
def build_random_mutation_fn(x_min: Chromosome, x_max: Chromosome) -> MutationFn:
"""Создаёт функцию случайной мутации."""
def mutation_fn(chrom: Chromosome) -> Chromosome:
chrom_new = chrom.copy()
k = np.random.randint(0, chrom_new.shape[0])
chrom_new[k] = np.random.uniform(x_min[k], x_max[k])
return chrom_new
return mutation_fn
def mutation(population: Population, pm: float, mutation_fn: MutationFn) -> Population:
"""Мутация происходит с вероятностью pm."""
next_population = []
for chrom in population:
next_population.append(
mutation_fn(chrom) if np.random.random() <= pm else chrom
)
return next_population
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 eval_population(population: Population, fitness_func: FitnessFn) -> Fitnesses:
return np.array([fitness_func(chrom) for chrom in population])
def plot_fitness_surface(
fitness_func: FitnessFn,
x_min: Chromosome,
x_max: Chromosome,
ax: Axes3D,
num_points: int = 100,
):
"""Рисует поверхность функции фитнеса в 3D."""
assert (
x_min.shape == x_max.shape == (2,)
), "Рисовать графики можно только для функции от двух переменных"
X = np.linspace(x_min[0], x_max[0], num_points)
Y = np.linspace(x_min[1], x_max[1], num_points)
X, Y = np.meshgrid(X, Y)
vectorized_fitness = np.vectorize(lambda x, y: fitness_func(np.array([x, y])))
Z = vectorized_fitness(X, Y)
return ax.plot_surface(
X, Y, Z, cmap="viridis", edgecolor="none", alpha=0.7, shade=False
)
def plot_fitness_contour(
fitness_func: FitnessFn,
x_min: Chromosome,
x_max: Chromosome,
ax: Axes,
num_points: int = 100,
) -> None:
"""Рисует контурный график функции фитнеса в 2D."""
X = np.linspace(x_min[0], x_max[0], num_points)
Y = np.linspace(x_min[1], x_max[1], num_points)
X, Y = np.meshgrid(X, Y)
vectorized_fitness = np.vectorize(lambda x, y: fitness_func(np.array([x, y])))
Z = vectorized_fitness(X, Y)
# Рисуем контуры
# X и Y поменяны местами для единообразия с 3D графиками, там ось Y изображена
# горизонтально из-за особенностей функции в моём варианте
contourf = ax.contourf(Y, X, Z, levels=20, cmap="viridis", alpha=0.7)
ax.set_xlabel("Y")
ax.set_ylabel("X")
# Добавляем цветовую шкалу
plt.colorbar(contourf, ax=ax, shrink=0.5)
# По умолчанию matplotlib пытается растянуть график по оси Y, тут мы это отключаем
ax.set_aspect("equal")
def save_generation(
generation: Generation, history: list[Generation], config: GARunConfig
) -> None:
"""Сохраняем графики поколения.
Функция не самая универсальная, тут есть хардкод, однако для большинства вариантов
должна работать и так.
"""
assert (
config.x_min.shape == config.x_max.shape == (2,)
), "Рисовать графики можно только для функции от двух переменных"
os.makedirs(config.results_dir, exist_ok=True)
fig = plt.figure(figsize=(21, 7))
fig.suptitle(
f"Поколение #{generation.number}. "
f"Лучшая особь: {generation.best_fitness:.4f}. "
f"Среднее значение: {np.mean(generation.fitnesses):.4f}",
fontsize=14,
y=0.85,
)
# Контурный график (как вид сверху)
ax1 = fig.add_subplot(1, 3, 1)
plot_fitness_contour(config.fitness_func, config.x_min, config.x_max, ax1)
# Популяция на контурном графике
arr = np.array(generation.population)
# Координаты специально поменяны местами (см. plot_fitness_contour)
ax1.scatter(
arr[:, 1],
arr[:, 0],
c="red",
marker="o",
alpha=0.9,
s=20,
)
# Подпись под первым графиком
ax1.text(
0.5,
-0.3,
"(a)",
transform=ax1.transAxes,
ha="center",
fontsize=16,
)
# 3D графики с разных ракурсов
views_3d = [
# (elev, azim)
(50, 0),
(50, 15),
]
for i, (elev, azim) in enumerate(views_3d):
ax = fig.add_subplot(1, 3, i + 2, projection="3d", computed_zorder=False)
plot_fitness_surface(config.fitness_func, config.x_min, config.x_max, ax)
ax.set_xlabel("X")
ax.set_ylabel("Y")
ax.set_zlabel("f(X, Y)")
ax.scatter(
arr[:, 0],
arr[:, 1],
generation.fitnesses + 1, # type: ignore
c="red",
s=10,
marker="o",
alpha=0.9,
)
# Устанавливаем угол обзора
ax.view_init(elev=elev, azim=azim)
# Подпись под 3D графиками
label = chr(ord("b") + i) # 'b' для i=0, 'c' для i=1
ax.text2D(
0.5,
-0.15,
f"({label})",
transform=ax.transAxes,
ha="center",
fontsize=16,
)
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")
# Можно раскомментировать, чтобы подобрать более удачные ракурсы
# в интерактивном режиме
# fig.show()
# plt.pause(1000)
plt.close(fig)
def genetic_algorithm(config: GARunConfig) -> GARunResult:
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)
population = initialize_population(config.pop_size, config.x_min, config.x_max)
start = time.perf_counter()
history: list[Generation] = []
best: Generation | None = None
generation_number = 1
best_repetitions = 0
while True:
# Вычисляем фитнес для всех особей в популяции
fitnesses = eval_population(population, config.fitness_func)
# Находим лучшую особь в поколении
best_index = (
int(np.argmin(fitnesses)) if config.minimize else int(np.argmax(fitnesses))
)
# Добавляем эпоху в историю
current = Generation(
number=generation_number,
best=population[best_index],
best_fitness=fitnesses[best_index],
population=deepcopy(population),
fitnesses=deepcopy(fitnesses),
)
history.append(current)
if config.log_every_generation:
print(
f"Generation #{generation_number} best: {current.best_fitness},"
f" avg: {np.mean(current.fitnesses)}"
)
# Обновляем лучшую эпоху
if (
best is None
or (config.minimize and current.best_fitness < best.best_fitness)
or (not config.minimize and current.best_fitness > best.best_fitness)
):
best = current
# Проверка критериев остановки
stop_algorithm = False
if generation_number >= config.max_generations:
stop_algorithm = True
if config.max_best_repetitions is not None and generation_number > 1:
if history[-2].best_fitness == current.best_fitness:
best_repetitions += 1
if best_repetitions == config.max_best_repetitions:
stop_algorithm = True
else:
best_repetitions = 0
# if config.variance_threshold is not None:
# fitness_variance = np.var(fitnesses)
# if fitness_variance < config.variance_threshold:
# stop_algorithm = True
if config.best_value_threshold is not None:
if (
config.minimize and current.best_fitness < config.best_value_threshold
) or (
not config.minimize
and current.best_fitness > config.best_value_threshold
):
stop_algorithm = True
if config.fitness_avg_threshold is not None:
mean_fitness = np.mean(fitnesses)
if (config.minimize and mean_fitness < config.fitness_avg_threshold) or (
not config.minimize and mean_fitness > config.fitness_avg_threshold
):
stop_algorithm = True
# Сохраняем указанные поколения и последнее поколение
if config.save_generations and (
stop_algorithm or generation_number in config.save_generations
):
# save_generation(current, history, config)
save_generation(current, history, config)
if stop_algorithm:
break
# селекция (для минимума инвертируем знак)
parents = reproduction(
population, fitnesses if not config.minimize else -fitnesses
)
# кроссинговер попарно
next_population = crossover(parents, config.pc, arithmetical_crossover_fn)
# мутация
next_population = mutation(
next_population,
config.pm,
build_random_mutation_fn(config.x_min, config.x_max),
)
population = next_population[: config.pop_size]
generation_number += 1
end = time.perf_counter()
assert best is not None, "Best was never set"
return GARunResult(
len(history),
best,
history,
(end - start) * 1000.0,
)

31
lab2/main.py Normal file
View File

@@ -0,0 +1,31 @@
import matplotlib.pyplot as plt
import numpy as np
from gen import GARunConfig, genetic_algorithm
def fitness_function(chromosome: np.ndarray) -> np.float64:
return chromosome[0] ** 2 + 2 * chromosome[1] ** 2
config = GARunConfig(
x_min=np.array([-5.12, -5.12]),
x_max=np.array([5.12, 5.12]),
fitness_func=fitness_function,
pop_size=25,
pc=0.5,
pm=0.01,
max_generations=200,
max_best_repetitions=10,
minimize=True,
seed=17,
save_generations=[1, 2, 3, 5, 7, 9, 10, 15, 19],
log_every_generation=True,
)
result = genetic_algorithm(config)
# Выводим результаты
print(f"Лучшая особь: {result.best_generation.best}")
print(f"Лучшее значение фитнеса: {result.best_generation.best_fitness:.6f}")
print(f"Количество поколений: {result.generations_count}")
print(f"Время выполнения: {result.time_ms:.2f} мс")

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

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

BIN
lab2/report/img/alg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

747
lab2/report/report.tex Normal file
View File

@@ -0,0 +1,747 @@
\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{Лабораторная работа №2}\\
\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 Создать программу, использующую генетический алгоритм для нахождения минимума данной функции;
\item Для n=2 вывести на экран график функции с указанием найденного экстремума и точек популяции. Предусмотреть возможность пошагового просмотра процесса поиска решения;
\item Исследовать зависимость времени поиска, числа поколений (генераций), точности нахождения решения от основных параметров генетического алгоритма: числа особей в популяции, вероятности кроссинговера и мутации;
\item Повторить процесс поиска решения для n=3, сравнить результаты и скорость работы программы.
\end{enumerate}
\newpage
\section{Теоретические сведения}
Генетические алгоритмы (ГА) используют принципы и терминологию, заимствованные у биологической науки генетики. В ГА каждая особь представляет потенциальное решение некоторой
проблемы. В классическом ГА особь кодируется строкой двоичных символов хромосомой. Однако при работе с оптимизационными задачами в непрерывных пространствах вполне естественно представлять гены напрямую вещественными числами. В этом случае хромосома есть вектор вещественных чисел (real-coded алгоритмы). Их точность определяется исключительно разрядной сеткой ЭВМ. Длина хромосомы совпадает с длиной вектора-решения оптимизационной задачи, каждый ген отвечает за одну переменную. Генотип объекта становится идентичным его фенотипу.
Множество особей потенциальных решений составляет популяцию. Поиск (суб)оптимального решения проблемы выполняется в процессе эволюции популяции - последовательного преобразования одного конечного множества решений в другое с помощью генетических операторов репродукции, кроссинговера и мутации.
Предварительно простой ГА случайным образом генерирует начальную популяцию стрингов
(хромосом). Затем алгоритм генерирует следующее поколение (популяцию), с помощью трех основных генетических операторов:
\begin{enumerate}
\item Оператор репродукции (ОР);
\item Оператор скрещивания (кроссинговера, ОК);
\item Оператор мутации (ОМ).
\end{enumerate}
ГА работает до тех пор, пока не будет выполнено заданное количество поколений (итераций)
процесса эволюции или на некоторой генерации будет получено заданное качество или вследствие
преждевременной сходимости при попадании в некоторый локальный оптимум. На Рис.~\ref{fig:alg} представлен простой генетический алгоритм.
\begin{figure}[h!]
\centering
\includegraphics[width=0.9\linewidth]{img/alg.png}
\caption{Простой генетический алгоритм}
\label{fig:alg}
\end{figure}
\newpage
\subsection{Основная терминология в генетических алгоритмах}
\textbf{Ген} -- элементарный код в хромосоме $s_i$, называемый также знаком или детектором
(в классическом ГА $s_i = 0, 1$).
\textbf{Хромосома} -- упорядоченная последовательность генов в виде закодированной структуры
данных $S = (s_1, s_2, \ldots, s_n)$, определяющая решение. Может быть представлена как двоичная
последовательность (где $s_i = 0, 1$) или как вектор вещественных чисел (real-coded представление).
\textbf{Локус} -- местоположение (позиция, номер бита) данного гена в хромосоме.
\textbf{Аллель} -- значение, которое принимает данный ген (например, 0 или 1).
\textbf{Особь} -- одно потенциальное решение задачи (представляемое хромосомой).
\textbf{Популяция} -- множество особей (хромосом), представляющих потенциальные решения.
\textbf{Поколение} -- текущая популяция ГА на данной итерации алгоритма.
\textbf{Генотип} -- набор хромосом данной особи. В популяции могут использоваться как отдельные
хромосомы, так и целые генотипы.
\textbf{Генофонд} -- множество всех возможных генотипов.
\textbf{Фенотип} -- набор значений, соответствующий данному генотипу. Это декодированное множество
параметров задачи (например, десятичное значение $x$, соответствующее двоичному коду).
\textbf{Размер популяции $N$} -- число особей в популяции.
\textbf{Число поколений} -- количество итераций, в течение которых производится поиск.
\textbf{Селекция} -- совокупность правил, определяющих выживание особей на основе значений целевой функции.
\textbf{Эволюция популяции} -- чередование поколений, в которых хромосомы изменяют свои признаки,
чтобы каждая новая популяция лучше приспосабливалась к среде.
\textbf{Фитнесс-функция} -- функция полезности, определяющая меру приспособленности особи.
В задачах оптимизации она совпадает с целевой функцией или описывает близость к оптимальному решению.
\subsection{Генетические операторы}
\subsubsection{Оператор репродукции}
Репродукция -- процесс копирования хромосом в промежуточную популяцию для дальнейшего
``размножения'' в соответствии со значениями фитнесс-функции. В данной работе рассматривается метод колеса рулетки. Каждой хромосоме соответствует сектор, пропорциональный значению фитнесс-функции.
Хромосомы с большим значением имеют больше шансов попасть в следующее поколение.
\subsubsection{Операторы кроссинговера для real-coded алгоритмов}
Оператор скрещивания непрерывного ГА (кроссовер) порождает одного или нескольких потомков от двух хромосом. Требуется из двух векторов вещественных чисел получить новые векторы по определённым законам. Большинство real-coded алгоритмов генерируют новые векторы в окрестности родительских пар.
Пусть $C_1=(c_{11},c_{21},\ldots,c_{n1})$ и $C_2=(c_{12},c_{22},\ldots,c_{n2})$ -- две хромосомы, выбранные оператором селекции для скрещивания.
\textbf{Арифметический кроссовер (arithmetical crossover):} создаются два потомка $H_1=(h_{11},\ldots,h_{n1})$, $H_2=(h_{12},\ldots,h_{n2})$, где:
$$h_{k1}=w \cdot c_{k1}+(1-w) \cdot c_{k2}$$
$$h_{k2}=w \cdot c_{k2}+(1-w) \cdot c_{k1}$$
где $k=1,\ldots,n$, $w$ -- весовой коэффициент из интервала $[0;1]$.
\begin{figure}[h!]
\centering
\includegraphics[width=0.5\linewidth]{img/arithmetic_crossover.png}
\caption{Арифметический кроссовер}
\label{fig:arithmetic_crossover}
\end{figure}
\textbf{Геометрический кроссовер (geometrical crossover):} создаются два потомка $H_1=(h_{11},\ldots,h_{n1})$, $H_2=(h_{12},\ldots,h_{n2})$, где:
$$h_{k1}=(c_{k1})^w \cdot (c_{k2})^{(1-w)}$$
$$h_{k2}=(c_{k2})^w \cdot (c_{k1})^{(1-w)}$$
где $w$ -- случайное число из интервала $[0;1]$.
\begin{figure}[h!]
\centering
\includegraphics[width=0.5\linewidth]{img/geometric_crossover.png}
\caption{Геометрический кроссовер}
\label{fig:geometric_crossover}
\end{figure}
\textbf{Смешанный кроссовер (BLX-alpha crossover):} генерируется один потомок $H=(h_1,\ldots,h_k,\ldots,h_n)$, где $h_k$ -- случайное число из интервала $[c_{min}-I \cdot \alpha, c_{max}+I \cdot \alpha]$, $c_{min}=\min(c_{k1},c_{k2})$, $c_{max}=\max(c_{k1},c_{k2})$, $I=c_{max}-c_{min}$.
\begin{figure}[h!]
\centering
\includegraphics[width=0.5\linewidth]{img/blx_crossover.png}
\caption{Смешанный кроссовер}
\label{fig:blx_crossover}
\end{figure}
\textbf{SBX кроссовер (Simulated Binary Crossover):} кроссовер, имитирующий двоичный, разработанный в 1995 году исследовательской группой под руководством K. Deb'а. Моделирует принципы работы двоичного оператора скрещивания, сохраняя важное свойство -- среднее значение функции приспособленности остаётся неизменным у родителей и их потомков.
Создаются два потомка $H_k=(h_{1k}, \ldots, h_{jk}, \ldots, h_{nk})$, $k=1,2$, где:
$$h_{j1} = 0.5[(1+\beta_k)c_{j1} + (1-\beta_k)c_{j2}]$$
$$h_{j2} = 0.5[(1-\beta_k)c_{j1} + (1+\beta_k)c_{j2}]$$
где $\beta_k \geq 0$ -- число, полученное по формуле:
$$\beta_k = \begin{cases}
(2u)^{\frac{1}{n+1}}, & \text{при } u \leq 0.5 \\
\left(\frac{1}{2(1-u)}\right)^{\frac{1}{n+1}}, & \text{при } u > 0.5
\end{cases}$$
где $u \in (0,1)$ -- случайное число, распределённое по равномерному закону, $n \in [2,5]$ -- параметр кроссовера. Увеличение $n$ повышает вероятность появления потомка в окрестности родителей.
\begin{figure}[h!]
\centering
\includegraphics[width=0.7\linewidth]{img/sbx_crossover.png}
\caption{SBX кроссовер}
\label{fig:sbx_crossover}
\end{figure}
\subsubsection{Операторы мутации для real-coded алгоритмов}
В качестве оператора мутации наибольшее распространение получили: случайная и неравномерная мутация.
\textbf{Случайная мутация (random mutation):} ген, подлежащий изменению, принимает случайное значение из интервала своего изменения.
\textbf{Неравномерная мутация (non-uniform mutation):} из особи случайно выбирается точка $c_k$ с разрешёнными пределами изменения $[c_{kl}, c_{kr}]$. Точка меняется на:
$$c_k' = \begin{cases}
c_k + \Delta(t, c_{kr} - c_k), & \text{при } a = 1 \\
c_k - \Delta(t, c_k - c_{kl}), & \text{при } a = 0
\end{cases}$$
где $a$ -- случайно выбранное направление изменения, $\Delta(t, y)$ -- функция, возвращающая случайную величину в пределах $[0, y]$ таким образом, что при увеличении $t$ среднее возвращаемое значение уменьшается:
$$\Delta(t, y) = y \cdot r \cdot \left(1 - \frac{t}{T}\right)^b$$
где $r$ -- случайная величина на интервале $[0, 1]$, $t$ -- текущая эпоха работы генетического алгоритма, $T$ -- общее разрешённое число эпох алгоритма, $b$ -- задаваемый пользователем параметр, определяющий степень зависимости от числа эпох.
\newpage
\section{Особенности реализации}
В рамках работы создана мини-библиотека \texttt{gen.py} для экспериментов с real-coded
генетическим алгоритмом для многомерных функций. Второй модуль
\texttt{expirements.py} организует серийные эксперименты (перебор параметров,
форматирование и сохранение результатов).
\begin{itemize}
\item \textbf{Кодирование особей}: каждая хромосома представлена как \texttt{np.ndarray} вещественных чисел (\texttt{Chromosome = NDArray[np.float64]}). Длина хромосомы соответствует размерности задачи оптимизации. Популяция -- список хромосом (\texttt{Population = list[Chromosome]}). Инициализация случайными векторами в заданном диапазоне:
\begin{itemize}
\item \texttt{initialize\_population(pop\_size: int, x\_min: Chromosome, x\_max:}\\ \texttt{Chromosome) -> Population}
\end{itemize}
\item \textbf{Фитнесс и минимум/максимум}: целевая функция принимает хромосому (вектор) и возвращает скалярное значение фитнесса. Для режима минимизации используется внутреннее преобразование при селекции (сдвиг на минимальное значение), что позволяет применять рулетку при отрицательных значениях:
\begin{itemize}
\item \texttt{eval\_population(population: Population, fitness\_func: FitnessFn) -> Fitnesses}
\item Логика режима минимизации в \texttt{genetic\_algorithm(config: GARunConfig) -> GARunResult}
\end{itemize}
\item \textbf{Селекция (рулетка)}: вероятности нормируются после сдвига на минимальное значение в поколении (устойчиво к отрицательным фитнессам). Функция:
\texttt{reproduction(population: Population, fitnesses: Fitnesses) -> Population}.
\item \textbf{Кроссинговер}: реализованы арифметический и геометрический кроссоверы для real-coded алгоритмов. Кроссинговер выполняется попарно по перемешанной популяции с вероятностью $p_c$. Функции:
\begin{itemize}
\item \texttt{arithmetical\_crossover\_fn(p1: Chromosome, p2: Chromosome, w: float) -> tuple[Chromosome, Chromosome]}
\item \texttt{geometrical\_crossover\_fn(p1: Chromosome, p2: Chromosome, w: float) -> tuple[Chromosome, Chromosome]}
\item \texttt{crossover(population: Population, pc: float, crossover\_fn: CrossoverFn) -> Population}
\end{itemize}
\item \textbf{Мутация}: случайная мутация -- с вероятностью $p_m$ на хромосому изменяется один случайно выбранный ген на случайное значение из допустимого диапазона. Функции:
\begin{itemize}
\item \texttt{build\_random\_mutation\_fn(x\_min: Chromosome, x\_max: Chromosome) -> MutationFn}
\item \texttt{mutation(population: Population, pm: float, mutation\_fn: MutationFn) -> Population}
\end{itemize}
\item \textbf{Критерий остановки}: поддерживается критерий по среднему значению фитнесс-функции в популяции и максимальному количеству поколений. Хранится история всех поколений. Проверка выполняется в функции:
\texttt{genetic\_algorithm(config: GARunConfig) -> GARunResult}.
\item \textbf{Визуализация}: для двумерных функций реализованы 3D-графики поверхности и 2D-контурные графики с отображением популяций. Функции:
\begin{itemize}
\item \texttt{plot\_fitness\_surface(fitness\_func: FitnessFn, x\_min: Chromosome, x\_max: Chromosome, ax: Axes3D)}
\item \texttt{plot\_fitness\_contour(fitness\_func: FitnessFn, x\_min: Chromosome, x\_max: Chromosome, ax: Axes)}
\item \texttt{save\_generation(generation: Generation, history: list[Generation], config: GARunConfig)}
\end{itemize}
\item \textbf{Измерение времени}: длительность вычислений возвращается в миллисекундах как часть \texttt{GARunResult.time\_ms}.
\item \textbf{Файловая организация}: результаты экспериментов сохраняются иерархически в структуре \texttt{experiments/N/} с таблицами результатов в формате CSV. Задействованные функции:
\begin{itemize}
\item \texttt{clear\_results\_directory(results\_dir: str) -> None}
\item \texttt{run\_single\_experiment(pop\_size: int, pc: float, pm: float) -> tuple[float, float, float, float]}
\item \texttt{run\_experiments\_for\_population(pop\_size: int) -> PrettyTable}
\end{itemize}
\end{itemize}
В модуле \texttt{expirements.py} задаётся целевая функция axis parallel hyper-ellipsoid: $f(x, y) = x^2 + 2y^2$ и параметры экспериментов.
Серийные запуски и сохранение результатов реализованы в функциях \texttt{run\_single\_experiment}, \texttt{run\_experiments\_for\_population} и \texttt{main}.
\newpage
\section{Результаты работы}
На Рис.~\ref{fig:gen1}--\ref{fig:lastgen} представлены результаты работы генетического алгоритма со следующими параметрами:
\begin{itemize}
\item $N = 25$ -- размер популяции.
\item $p_c = 0.5$ -- вероятность кроссинговера.
\item $p_m = 0.01$ -- вероятность мутации.
\item Алгоритм останавливался, если лучшее значение фитнеса не изменялось $10$ поколений подряд.
\item Использован арифметический кроссовер для real-coded хромосом.
\end{itemize}
Популяция постепенно консолидируется вокруг глобального минимума в точке $(0, 0)$. Лучшая особь была найдена на поколнении №9 (см. Рис.~\ref{fig:gen9}), но судя по всему она подверглась мутации или кроссинговеру, поэтому алгоритм не остановился. На поколении №19 (см. Рис.~\ref{fig:lastgen}) было получено значение фитнеса $0.0201$, которое затем повторялось в следующих 10 поколениях. Алгоритм остановился на поколлении №29. На графиках показаны 2D-контурный график (a) и 3D-поверхность целевой функции с точками популяции текущего поколения (b) и (c).
\begin{figure}[h!]
\centering
\includegraphics[width=1\linewidth]{img/results/generation_001.png}
\caption{График целевой функции и популяции поколения №1}
\label{fig:gen1}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=1\linewidth]{img/results/generation_002.png}
\caption{График целевой функции и популяции поколения №2}
\label{fig:gen2}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=1\linewidth]{img/results/generation_003.png}
\caption{График целевой функции и популяции поколения №3}
\label{fig:gen3}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=1\linewidth]{img/results/generation_005.png}
\caption{График целевой функции и популяции поколения №5}
\label{fig:gen5}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=1\linewidth]{img/results/generation_007.png}
\caption{График целевой функции и популяции поколения №7}
\label{fig:gen7}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=1\linewidth]{img/results/generation_009.png}
\caption{График целевой функции и популяции поколения №9}
\label{fig:gen9}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=1\linewidth]{img/results/generation_010.png}
\caption{График целевой функции и популяции поколения №10}
\label{fig:gen10}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=1\linewidth]{img/results/generation_015.png}
\caption{График целевой функции и популяции поколения №15}
\label{fig:gen15}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=1\linewidth]{img/results/generation_019.png}
\caption{График целевой функции и популяции поколения №19}
\label{fig:lastgen}
\end{figure}
\newpage
\phantom{text}
\newpage
\phantom{text}
\newpage
\phantom{text}
\newpage
\section{Исследование реализации}
\subsection{Проведение измерений}
В рамках лабораторной работы необходимо было исследовать зависимость времени выполнения задачи и количества поколений от популяции и вероятностей кроссинговера и мутации хромосомы
Для исследования были выбраны следующие значения параметров:
\begin{itemize}
\item $N = 10, 25, 50, 100$ -- размер популяции.
\item $p_c = 0.3, 0.4, 0.5, 0.6, 0.7, 0.8$ -- вероятность кроссинговера.
\item $p_m = 0.001, 0.01, 0.05, 0.1, 0.2$ -- вероятность мутации.
\end{itemize}
Измерения были проведены для двух критериев остановки:
\begin{itemize}
\item Лучшее значение фитнеса не изменялось 10 поколений.
\item Лучшее значение фитнеса достигло заданного значения $0.005$.
\end{itemize}
\subsubsection*{Результаты для первого критерия остановки}
Результаты измерений представлены в таблицах \ref{tab:pc_pm_results_10}--\ref{tab:pc_pm_results_100}. В ячейках указано время в миллисекундах нахождения минимума функции. В скобках указано количество поколений, за которое было найдено решение. Во второй строке указано усреднённое по всем запускам лучшее значение фитнеса. Если в ячейке стоит прочерк, то это означает, что решение не было найдено за 200 поколений. Лучшее значение по времени выполнения и по значению фитнеса для каждого размера популяции выделено цветом и жирным шрифтом.
\newcolumntype{Y}{>{\centering\arraybackslash}X}
% Автоматически сгенерированные LaTeX таблицы
% Лучший результат по времени и по фитнесу выделены жирным отдельно
% Убедитесь, что подключен \usepackage{tabularx}
% ВНИМАНИЕ: Убедитесь, что подключен \usepackage{xcolor} для цветового выделения
% Используйте \newcolumntype{Y}{>{\centering\arraybackslash}X} перед таблицами
\begin{table}[h!]
\centering
\small
\caption{Результаты для $N = 10$}
\begin{tabularx}{\linewidth}{l *{5}{Y}}
\toprule
$\mathbf{P_c \;\backslash\; P_m}$ & \textbf{0.001} & \textbf{0.010} & \textbf{0.050} & \textbf{0.100} & \textbf{0.200} \\
\midrule
\textbf{0.3} & 1.3 (13) 0.36281 & 1.7 (18) 7.55685 & 1.2 (13) 1.55537 & \textcolor{magenta}{\textbf{1.0 (11)}} 1.78411 & 9.4 (87) 0.04271 \\
\textbf{0.4} & 1.3 (14) 0.03913 & 1.6 (17) 0.02868 & 1.3 (13) 0.36232 & 2.1 (20) 0.10641 &\\
\textbf{0.5} & 1.4 (15) 0.87081 & 1.7 (18) 1.71634 & 2.3 (21) 0.10401 & 3.4 (25) 0.00461 &\\
\textbf{0.6} & 2.8 (19) 0.06375 & 1.8 (13) 0.72202 & 2.9 (22) 0.01473 & 3.4 (25) 0.01162 & 29.4 (184) \textcolor{magenta}{\textbf{0.00033}} \\
\textbf{0.7} & 1.5 (15) 1.25409 & 2.3 (22) 8.67464 & 1.9 (18) 0.13319 & 8.6 (66) 0.00078 & 8.9 (48) 0.11136 \\
\textbf{0.8} & 1.9 (15) 3.10415 & 1.4 (13) 1.09275 & 2.1 (19) 0.43094 & 6.4 (54) 0.00191 &\\
\bottomrule
\end{tabularx}
\label{tab:pc_pm_results_10}
\end{table}
\begin{table}[h!]
\centering
\small
\caption{Результаты для $N = 25$}
\begin{tabularx}{\linewidth}{l *{5}{Y}}
\toprule
$\mathbf{P_c \;\backslash\; P_m}$ & \textbf{0.001} & \textbf{0.010} & \textbf{0.050} & \textbf{0.100} & \textbf{0.200} \\
\midrule
\textbf{0.3} & 3.0 (18) 0.16836 & \textcolor{magenta}{\textbf{2.2 (13)}} 0.04190 & 4.7 (27) 0.00544 &&\\
\textbf{0.4} & 4.1 (24) 0.00808 & 4.6 (26) 0.01101 & 5.8 (31) 0.02330 & 3.8 (19) 0.05414 &\\
\textbf{0.5} & 3.1 (17) 0.05259 & 5.0 (26) 0.47018 & 27.8 (138) \textcolor{magenta}{\textbf{0.00024}} & 14.5 (67) 0.00312 &\\
\textbf{0.6} & 6.1 (31) 0.01033 & 6.8 (34) 0.00148 &&&\\
\textbf{0.7} & 4.1 (21) 0.00107 & 3.2 (16) 0.32522 &&&\\
\textbf{0.8} & 23.9 (109) 0.00352 & 15.8 (72) 0.11662 & 28.3 (123) 0.00038 &&\\
\bottomrule
\end{tabularx}
\label{tab:pc_pm_results_25}
\end{table}
\begin{table}[h!]
\centering
\small
\caption{Результаты для $N = 50$}
\begin{tabularx}{\linewidth}{l *{5}{Y}}
\toprule
$\mathbf{P_c \;\backslash\; P_m}$ & \textbf{0.001} & \textbf{0.010} & \textbf{0.050} & \textbf{0.100} & \textbf{0.200} \\
\midrule
\textbf{0.3} & 14.9 (51) 0.05874 & 19.3 (59) \textcolor{magenta}{\textbf{0.00003}} & 36.7 (113) 0.00190 &&\\
\textbf{0.4} & 12.5 (40) 0.01955 & \textcolor{magenta}{\textbf{5.6 (18)}} 0.00022 &&&\\
\textbf{0.5} & 65.0 (195) 0.04790 & 26.4 (78) 0.01673 &&&\\
\textbf{0.6} & 16.4 (47) 0.00329 & 18.5 (50) 0.00065 &&&\\
\textbf{0.7} & 51.0 (137) 0.00120 & 59.3 (158) 0.00010 &&&\\
\textbf{0.8} & 48.8 (126) 0.01393 & 67.6 (172) 0.00650 &&&\\
\bottomrule
\end{tabularx}
\label{tab:pc_pm_results_50}
\end{table}
\begin{table}[h!]
\centering
\small
\caption{Результаты для $N = 100$}
\begin{tabularx}{\linewidth}{l *{5}{Y}}
\toprule
$\mathbf{P_c \;\backslash\; P_m}$ & \textbf{0.001} & \textbf{0.010} & \textbf{0.050} & \textbf{0.100} & \textbf{0.200} \\
\midrule
\textbf{0.3} & 24.2 (44) 0.00110 & 17.9 (32) 0.00113 & \textcolor{magenta}{\textbf{17.6 (29)}} 0.00193 &&\\
\textbf{0.4} & 30.7 (51) 0.00173 &&&&\\
\textbf{0.5} & 27.4 (43) 0.00016 &&&&\\
\textbf{0.6} & 20.4 (31) 0.00115 & 129.8 (186) 0.00025 &&&\\
\textbf{0.7} & 115.4 (162) 0.00002 &&&&\\
\textbf{0.8} & 106.5 (143) \textcolor{magenta}{\textbf{0.00001}} &&&&\\
\bottomrule
\end{tabularx}
\label{tab:pc_pm_results_100}
\end{table}
\newpage
\phantom{text}
\newpage
\subsubsection*{Результаты для второго критерия остановки}
Результаты измерений представлены в таблицах \ref{tab:1_pc_pm_results_10}--\ref{tab:1_pc_pm_results_100}.
% Автоматически сгенерированные LaTeX таблицы
% Лучший результат по времени и по фитнесу выделены жирным отдельно
% Убедитесь, что подключен \usepackage{tabularx}
% ВНИМАНИЕ: Убедитесь, что подключен \usepackage{xcolor} для цветового выделения
% Используйте \newcolumntype{Y}{>{\centering\arraybackslash}X} перед таблицами
\begin{table}[h!]
\centering
\small
\caption{Результаты для $N = 10$}
\begin{tabularx}{\linewidth}{l *{5}{Y}}
\toprule
$\mathbf{P_c \;\backslash\; P_m}$ & \textbf{0.001} & \textbf{0.010} & \textbf{0.050} & \textbf{0.100} & \textbf{0.200} \\
\midrule
\textbf{0.3} &&&& 15.6 (155) 0.00063 & 7.8 (69) 0.00409 \\
\textbf{0.4} &&&& 8.9 (81) \textcolor{magenta}{\textbf{0.00038}} & \textcolor{magenta}{\textbf{4.6 (40)}} 0.00317 \\
\textbf{0.5} &&& 8.7 (85) 0.00199 && 16.5 (140) 0.00453 \\
\textbf{0.6} &&&& 8.9 (77) 0.00310 & 14.3 (117) 0.00082 \\
\textbf{0.7} &&& 8.2 (70) 0.00089 & 5.6 (49) 0.00431 & 7.1 (58) 0.00047 \\
\textbf{0.8} && 19.7 (180) 0.00397 && 5.0 (42) 0.00494 & 5.5 (44) 0.00357 \\
\bottomrule
\end{tabularx}
\label{tab:1_pc_pm_results_10}
\end{table}
\begin{table}[h!]
\centering
\small
\caption{Результаты для $N = 25$}
\begin{tabularx}{\linewidth}{l *{5}{Y}}
\toprule
$\mathbf{P_c \;\backslash\; P_m}$ & \textbf{0.001} & \textbf{0.010} & \textbf{0.050} & \textbf{0.100} & \textbf{0.200} \\
\midrule
\textbf{0.3} & 1.1 (7) 0.00277 & 30.0 (173) \textcolor{magenta}{\textbf{0.00059}} && 2.2 (12) 0.00191 & 30.2 (139) 0.00200 \\
\textbf{0.4} & 1.8 (10) 0.00384 && 12.2 (63) 0.00164 & 6.6 (33) 0.00354 & 18.5 (82) 0.00224 \\
\textbf{0.5} &&& 12.5 (58) 0.00233 & 2.3 (11) 0.00196 & 17.1 (73) 0.00116 \\
\textbf{0.6} && 30.9 (151) 0.00265 & 36.7 (175) 0.00146 & 10.0 (46) 0.00449 & 5.7 (23) 0.00281 \\
\textbf{0.7} & 1.1 (6) 0.00472 && 0.8 (4) 0.00233 & 3.9 (17) 0.00112 & \textcolor{magenta}{\textbf{0.3 (2)}} 0.00371 \\
\textbf{0.8} &&& 10.3 (43) 0.00137 & 7.7 (32) 0.00379 & 10.5 (41) 0.00155 \\
\bottomrule
\end{tabularx}
\label{tab:1_pc_pm_results_25}
\end{table}
\begin{table}[h!]
\centering
\small
\caption{Результаты для $N = 50$}
\begin{tabularx}{\linewidth}{l *{5}{Y}}
\toprule
$\mathbf{P_c \;\backslash\; P_m}$ & \textbf{0.001} & \textbf{0.010} & \textbf{0.050} & \textbf{0.100} & \textbf{0.200} \\
\midrule
\textbf{0.3} & 3.7 (12) 0.00354 & 3.4 (9) 0.00075 & 23.7 (73) 0.00467 & 4.9 (14) 0.00043 & 2.1 (6) 0.00029 \\
\textbf{0.4} & 3.6 (12) 0.00270 & 4.2 (13) 0.00061 & 9.2 (25) 0.00251 & 18.2 (51) 0.00490 & 6.6 (16) 0.00063 \\
\textbf{0.5} & 4.0 (10) 0.00099 & 48.8 (141) 0.00324 & 3.8 (11) 0.00087 & 14.7 (39) \textcolor{magenta}{\textbf{0.00017}} & 1.2 (3) 0.00115 \\
\textbf{0.6} & 1.6 (5) 0.00070 & 51.6 (139) 0.00217 & 4.7 (13) 0.00294 & 2.6 (7) 0.00397 & 11.5 (27) 0.00053 \\
\textbf{0.7} &&& 2.6 (7) 0.00144 & 3.5 (9) 0.00182 & \textcolor{magenta}{\textbf{1.1 (3)}} 0.00072 \\
\textbf{0.8} & 4.1 (11) 0.00240 & 3.5 (8) 0.00380 & 2.5 (6) 0.00422 & 2.7 (7) 0.00126 & 4.3 (10) 0.00060 \\
\bottomrule
\end{tabularx}
\label{tab:1_pc_pm_results_50}
\end{table}
\begin{table}[h!]
\centering
\small
\caption{Результаты для $N = 100$}
\begin{tabularx}{\linewidth}{l *{5}{Y}}
\toprule
$\mathbf{P_c \;\backslash\; P_m}$ & \textbf{0.001} & \textbf{0.010} & \textbf{0.050} & \textbf{0.100} & \textbf{0.200} \\
\midrule
\textbf{0.3} & 9.3 (17) 0.00451 & 6.0 (11) 0.00344 & 10.0 (17) 0.00343 & 5.3 (8) 0.00046 & 9.8 (14) 0.00412 \\
\textbf{0.4} & 5.7 (9) \textcolor{magenta}{\textbf{0.00005}} & 8.4 (14) 0.00108 & 3.5 (6) 0.00254 & 4.0 (6) 0.00186 & 6.5 (9) 0.00283 \\
\textbf{0.5} & 3.8 (6) 0.00019 & 4.9 (8) 0.00103 & 3.6 (6) 0.00260 & 11.1 (16) 0.00204 & 7.5 (10) 0.00374 \\
\textbf{0.6} && 6.5 (10) 0.00107 & 3.6 (5) 0.00079 & \textcolor{magenta}{\textbf{0.9 (2)}} 0.00324 & 10.1 (13) 0.00044 \\
\textbf{0.7} & 1.7 (3) 0.00106 & 6.6 (10) 0.00489 & 4.1 (6) 0.00031 & 12.4 (16) 0.00240 & 4.8 (6) 0.00276 \\
\textbf{0.8} & 5.0 (7) 0.00387 & 58.4 (77) 0.00453 & 7.8 (10) 0.00259 & 11.2 (13) 0.00210 & 6.1 (7) 0.00493 \\
\bottomrule
\end{tabularx}
\label{tab:1_pc_pm_results_100}
\end{table}
\newpage
\phantom{text}
\newpage
\phantom{text}
\newpage
\phantom{text}
\subsection{Анализ результатов}
\subsubsection*{Обоснование применения двух критериев остановки}
В исследовании использовались два различных критерия остановки алгоритма, поскольку критерий по количеству поколений (отсутствие улучшения в течение 10 поколений) не всегда обеспечивал достижение достаточно хороших значений фитнеса, особенно для малых популяций. Это делало некорректным сравнение эффективности различных комбинаций параметров только по времени выполнения. Введение второго критерия (достижение фитнеса 0.005) позволило получить более объективную оценку скорости нахождении качественных решений.
\subsubsection*{Первый критерий остановки (отсутствие улучшения в течение 10 поколений)}
При использовании первого критерия остановки наблюдаются следующие закономерности:
\begin{itemize}
\item \textbf{Малые популяции ($N=10$):} Оптимальный баланс достигается при умеренных значениях параметров. Лучший результат по времени показывает комбинация $p_c=0.3$, $p_m=0.1$ (1.0 мс, 11 поколений), однако лучшее значение фитнеса достигается при $p_c=0.6$, $p_m=0.2$ (0.00033). Качество решений существенно варьируется.
\item \textbf{Средние популяции ($N=25$):} Демонстрируют высокую эффективность при низких значениях мутации. Минимальное время выполнения достигается при $p_c=0.3$, $p_m=0.01$ (2.2 мс, 13 поколений), а наилучший фитнес — при $p_c=0.5$, $p_m=0.05$ (0.00024).
\item \textbf{Большие популяции ($N=50, 100$):} Характеризуются критической чувствительностью к высоким значениям мутации и демонстрируют заметное улучшение качества фитнеса. Для $N=50$ лучшие результаты при $p_c=0.4$, $p_m=0.01$ (5.6 мс по времени) и $p_c=0.3$, $p_m=0.01$ (фитнес 0.00003). Для $N=100$ работают только комбинации с очень низкой мутацией, но обеспечивают отличное качество (фитнес до 0.00001).
\item \textbf{Проблема сходимости:} С увеличением размера популяции значительно возрастает количество комбинаций параметров, не обеспечивающих сходимость за 200 поколений, особенно при $p_m \geq 0.05$.
\end{itemize}
\subsubsection*{Второй критерий остановки (достижение фитнеса 0.005)}
Использование фиксированного порога фитнеса демонстрирует принципиально иную картину и подтверждает правильность введения альтернативного критерия:
\begin{itemize}
\item \textbf{Инверсия требований к мутации:} В отличие от первого критерия, здесь малые популяции требуют более высоких значений мутации для достижения целевого фитнеса. Для $N=10$ большинство комбинаций с $p_m \leq 0.01$ вообще не достигают порога, что подтверждает проблему качества при первом критерии.
\item \textbf{Лучшие результаты больших популяций:} Популяции $N=50$ и $N=100$ показывают отличные результаты — достижение высокого качества за минимальное время: $N=50$ при $p_c=0.7$, $p_m=0.2$ (1.1 мс, 3 поколения) и $N=100$ при $p_c=0.6$, $p_m=0.1$ (0.9 мс, 2 поколения).
\end{itemize}
\newpage
\section{Ответ на контрольный вопрос}
\textbf{Вопрос}: Опишите понятие «оптимизационная задача».
\textbf{Ответ}: Оптимизационная задача — это математическая задача, в которой требуется найти такие значения переменных, при которых некоторая функция, называемая целевой, принимает наибольшее или наименьшее значение. При этом искомые значения должны удовлетворять определённым условиям или ограничениям, задающим допустимую область решений. Цель оптимизации заключается в выборе наилучшего варианта среди множества возможных с точки зрения заданного критерия эффективности.
Такие задачи широко применяются в науке, технике, экономике и управлении для рационального распределения ресурсов, минимизации затрат или максимизации прибыли. В зависимости от формы целевой функции и ограничений оптимизационные задачи могут быть линейными, нелинейными, дискретными или непрерывными. Их решение позволяет принимать обоснованные решения и повышать эффективность различных процессов и систем.
\newpage
\section*{Заключение}
\addcontentsline{toc}{section}{Заключение}
В ходе второй лабораторной работы была успешно решена задача оптимизации функции Axis parallel hyper-ellipsoid function с использованием генетических алгоритмов:
\begin{enumerate}
\item Изучен теоретический материал о real-coded генетических алгоритмах и различных операторах кроссинговера и мутации;
\item Создана программная библиотека на языке Python с реализацией арифметического и геометрического кроссоверов, случайной мутации и селекции методом рулетки;
\item Проведено исследование влияния параметров ГА на эффективность поиска для популяций размером 10, 25, 50 и 100 особей;
\end{enumerate}
\newpage
\section*{Список литературы}
\addcontentsline{toc}{section}{Список литературы}
\vspace{-1.5cm}
\begin{thebibliography}{0}
\bibitem{vostrov}
Методические указания по выполнению лабораторных работ к курсу «Генетические алгоритмы», 119 стр.
\end{thebibliography}
\end{document}

340
lab3/csv_to_tex.py Normal file
View File

@@ -0,0 +1,340 @@
"""
Скрипт для конвертации результатов экспериментов из CSV в LaTeX таблицы.
Этот скрипт автоматически сканирует папку experiments/, находит все подпапки
с файлами results.csv, парсит данные экспериментов и генерирует LaTeX код
таблиц в формате, готовом для вставки в отчёт.
Структура входных данных:
- experiments/N/results.csv, где N - размер популяции
- CSV содержит результаты экспериментов с различными параметрами Pc и Pm
- Значения в формате "X.Y (Z)" где X.Y - время выполнения, Z - количество итераций
- "" для отсутствующих данных
Выходной файл: tables.tex с готовым LaTeX кодом всех таблиц.
Лучшие результаты по времени и фитнесу выделяются жирным (и цветом, если задан HIGHLIGHT_COLOR).
"""
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: # Pc + как минимум одно значение Pm
data_rows.append(parts)
return header, data_rows
def extract_time_value(value: str) -> float | None:
"""
Извлекает значение времени из строки формата "X.Y (Z)" или "X.Y (Z) W.V".
Args:
value: Строка с результатом
Returns:
Время выполнения как float или None если значение пустое
"""
value = value.strip()
if value == "" or value == "" or value == "":
return None
# Ищем паттерн "число.число (число)"
match = re.match(r"(\d+\.?\d*)\s*\(", value)
if match:
return float(match.group(1))
return None
def extract_fitness_value(value: str) -> float | None:
"""
Извлекает значение фитнеса из строки формата "X.Y (Z) W.V".
Args:
value: Строка с результатом
Returns:
Значение фитнеса как float или None если значение пустое
"""
value = value.strip()
if value == "" or value == "" or value == "":
return None
# Ищем паттерн "число.число (число) число.число"
# Фитнес - это последнее число в строке
match = re.search(r"\)\s+(\d+\.?\d*)\s*$", 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)): # Пропускаем первую колонку (Pc)
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_fitness(data_rows: list[list[str]]) -> float | None:
"""
Находит минимальное значение фитнеса среди всех значений в таблице.
Args:
data_rows: Строки данных таблицы
Returns:
Минимальное значение фитнеса или None если нет валидных значений
"""
min_fitness = None
for row in data_rows:
for i in range(1, len(row)): # Пропускаем первую колонку (Pc)
fitness_value = extract_fitness_value(row[i])
if fitness_value is not None:
if min_fitness is None or fitness_value < min_fitness:
min_fitness = fitness_value
return min_fitness
def format_value(
value: str, best_time: float | None = None, best_fitness: float | None = None
) -> str:
"""
Форматирует значение для LaTeX таблицы, выделяя лучшие результаты жирным.
Args:
value: Строковое значение из CSV
best_time: Лучшее время в таблице для сравнения
best_fitness: Лучший фитнес в таблице для сравнения
Returns:
Отформатированное значение для LaTeX
"""
value = value.strip()
if value == "" or value == "" or value == "":
return ""
# Проверяем есть ли фитнес в строке
fitness_match = re.search(r"(\d+\.?\d*)\s*\((\d+)\)\s+(\d+\.?\d*)\s*$", value)
if fitness_match:
# Есть фитнес: "время (поколения) фитнес"
time_str = fitness_match.group(1)
generations_str = fitness_match.group(2)
fitness_str = fitness_match.group(3)
current_time = float(time_str)
current_fitness = float(fitness_str)
# Проверяем, является ли время лучшим
time_part = f"{time_str} ({generations_str})"
if best_time is not None and abs(current_time - best_time) < 0.001:
if HIGHLIGHT_COLOR is not None:
time_part = (
f"\\textcolor{{{HIGHLIGHT_COLOR}}}{{\\textbf{{{time_part}}}}}"
)
else:
time_part = f"\\textbf{{{time_part}}}"
# Проверяем, является ли фитнес лучшим
fitness_part = fitness_str
if best_fitness is not None and abs(current_fitness - best_fitness) < 0.00001:
if HIGHLIGHT_COLOR is not None:
fitness_part = (
f"\\textcolor{{{HIGHLIGHT_COLOR}}}{{\\textbf{{{fitness_part}}}}}"
)
else:
fitness_part = f"\\textbf{{{fitness_part}}}"
return f"{time_part} {fitness_part}"
else:
# Нет фитнеса: только "время (поколения)"
time_match = re.match(r"(\d+\.?\d*)\s*\((\d+)\)", value)
if time_match:
current_time = float(time_match.group(1))
if best_time is not None and abs(current_time - best_time) < 0.001:
if HIGHLIGHT_COLOR is not None:
return f"\\textcolor{{{HIGHLIGHT_COLOR}}}{{\\textbf{{{value}}}}}"
else:
return f"\\textbf{{{value}}}"
return value
def generate_latex_table(n: str, header: str, data_rows: list[list[str]]) -> str:
"""
Генерирует LaTeX код таблицы.
Args:
n: Размер популяции
header: Заголовок таблицы
data_rows: Строки данных
Returns:
LaTeX код таблицы
"""
# Находим лучшее время и лучший фитнес в таблице
best_time = find_best_time(data_rows)
best_fitness = find_best_fitness(data_rows)
# Извлекаем заголовки колонок из header
header_parts = header.split(",")
pm_values = header_parts[1:] # Пропускаем "Pc \ Pm"
num_pm_columns = len(pm_values) # Динамически определяем количество колонок
latex_code = f""" \\begin{{table}}[h!]
\\centering
\\small
\\caption{{Результаты для $N = {n}$}}
\\begin{{tabularx}}{{\\linewidth}}{{l *{{{num_pm_columns}}}{{Y}}}}
\\toprule
$\\mathbf{{P_c \\;\\backslash\\; P_m}}$"""
# Добавляем заголовки Pm
for pm in pm_values:
latex_code += f" & \\textbf{{{pm.strip()}}}"
latex_code += " \\\\\n \\midrule\n"
# Добавляем строки данных
for row in data_rows:
pc_value = row[0].strip()
latex_code += f" \\textbf{{{pc_value}}}"
# Добавляем значения для каждого Pm
for i in range(1, min(num_pm_columns + 1, len(row))):
value = format_value(row[i], best_time, best_fitness)
latex_code += f" & {value}"
# Заполняем недостающие колонки если их меньше чем num_pm_columns
for i in range(len(row) - 1, num_pm_columns):
latex_code += " & —"
latex_code += " \\\\\n"
latex_code += f""" \\bottomrule
\\end{{tabularx}}
\\label{{tab:pc_pm_results_{n}}}
\\end{{table}}"""
return latex_code
def main():
"""Основная функция скрипта."""
experiments_path = Path("experiments")
if not experiments_path.exists():
print("Папка experiments не найдена!")
return
tables = []
# Сканируем все подпапки в experiments, сортируем по числовому значению N
subdirs = [
subdir
for subdir in experiments_path.iterdir()
if subdir.is_dir() and subdir.name.isdigit()
]
subdirs.sort(key=lambda x: int(x.name))
for subdir in subdirs:
n = subdir.name
csv_file = subdir / "results.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_fitness = find_best_fitness(data_rows)
latex_table = generate_latex_table(n, header, data_rows)
tables.append(latex_table)
print(
f"✓ Таблица для N={n} готова (лучшее время: {best_time}, лучший фитнес: {best_fitness})"
)
except Exception as e:
print(f"✗ Ошибка при обработке {csv_file}: {e}")
else:
print(f"✗ Файл {csv_file} не найден")
# Сохраняем все таблицы в файл
if tables:
with open("tables.tex", "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Все таблицы сохранены в файл 'tables.tex'")
print(f"Сгенерировано таблиц: {len(tables)}")
else:
print("Не найдено данных для генерации таблиц!")
if __name__ == "__main__":
main()

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

208
lab3/expirements.py Normal file
View File

@@ -0,0 +1,208 @@
import math
import os
import shutil
import statistics
import numpy as np
from gen import (
Chromosome,
GARunConfig,
genetic_algorithm,
initialize_random_population,
inversion_mutation_fn,
partially_mapped_crossover_fn,
)
from prettytable import PrettyTable
# В списке из 89 городов только 38 уникальных
cities = set()
with open("data.txt", "r") as file:
for line in file:
# x и y поменяны местами в визуализациях в методичке
_, y, x = line.split()
cities.add((float(x), float(y)))
cities = list(cities)
def euclidean_distance(city1, city2):
return math.sqrt((city1[0] - city2[0]) ** 2 + (city1[1] - city2[1]) ** 2)
def build_fitness_function(cities):
def fitness_function(chromosome: Chromosome) -> float:
return sum(
euclidean_distance(cities[chromosome[i]], cities[chromosome[i + 1]])
for i in range(len(chromosome) - 1)
) + euclidean_distance(cities[chromosome[0]], cities[chromosome[-1]])
return fitness_function
# Базовая папка для экспериментов
BASE_DIR = "experiments"
# Параметры для экспериментов
POPULATION_SIZES = [10, 25, 50, 100]
PC_VALUES = [0.5, 0.6, 0.7, 0.8, 0.9] # вероятности кроссинговера
PM_VALUES = [0.2, 0.3, 0.4, 0.5, 0.8] # вероятности мутации
SAVE_AVG_BEST_FITNESS = True
# Количество запусков для усреднения результатов
NUM_RUNS = 3
# Базовые параметры (как в main.py)
BASE_CONFIG = {
"fitness_func": build_fitness_function(cities),
"max_generations": 2500,
"elitism": 2,
"cities": cities,
"initialize_population_fn": initialize_random_population,
"crossover_fn": partially_mapped_crossover_fn,
"mutation_fn": inversion_mutation_fn,
"seed": None, # None для случайности, т. к. всё усредняем
"minimize": True,
# "fitness_avg_threshold": 0.05, # критерий остановки
# "max_best_repetitions": 10,
"best_value_threshold": 7000,
# при включенном сохранении графиков на время смотреть бессмысленно
# "save_generations": [1, 50, 199],
}
def run_single_experiment(
pop_size: int, pc: float, pm: float
) -> tuple[float, float, float, float, float, float]:
"""
Запускает несколько экспериментов с заданными параметрами и усредняет результаты.
Возвращает (среднееремя_в_мс, стд_отклонениеремени, среднее_поколений,
стд_отклонение_поколений, среднееучшее_значение_фитнеса, стд_отклонениеучшего_значения_фитнеса).
"""
times = []
generations = []
best_fitnesses = []
for run_num in range(NUM_RUNS):
config = GARunConfig(
**BASE_CONFIG,
pop_size=pop_size,
pc=pc,
pm=pm,
results_dir=os.path.join(
BASE_DIR,
str(pop_size),
f"pc_{pc:.3f}",
f"pm_{pm:.3f}",
f"run_{run_num}",
),
)
result = genetic_algorithm(config)
times.append(result.time_ms)
generations.append(result.generations_count)
best_fitnesses.append(result.best_generation.best_fitness)
# Вычисляем средние значения и стандартные отклонения
avg_time = statistics.mean(times)
std_time = statistics.stdev(times) if len(times) > 1 else 0.0
avg_generations = statistics.mean(generations)
std_generations = statistics.stdev(generations) if len(generations) > 1 else 0.0
avg_best_fitness = statistics.mean(best_fitnesses)
std_best_fitness = (
statistics.stdev(best_fitnesses) if len(best_fitnesses) > 1 else 0.0
)
return (
avg_time,
std_time,
avg_generations,
std_generations,
avg_best_fitness,
std_best_fitness,
)
def run_experiments_for_population(pop_size: int) -> PrettyTable:
"""
Запускает эксперименты для одного размера популяции.
Возвращает таблицу результатов.
"""
print(f"\nЗапуск экспериментов для популяции размером {pop_size}...")
print(f"Количество запусков для усреднения: {NUM_RUNS}")
# Создаем таблицу
table = PrettyTable()
table.field_names = ["Pc \\ Pm"] + [f"{pm:.3f}" for pm in PM_VALUES]
# Запускаем эксперименты для всех комбинаций Pc и Pm
for pc in PC_VALUES:
row = [f"{pc:.1f}"]
for pm in PM_VALUES:
print(f" Эксперимент: pop_size={pop_size}, Pc={pc:.1f}, Pm={pm:.3f}")
(
avg_time,
std_time,
avg_generations,
std_generations,
avg_best_fitness,
std_best_fitness,
) = run_single_experiment(pop_size, pc, pm)
# Форматируем результат: среднееремя±стд_отклонение (среднее_поколения±стд_отклонение)
# cell_value = f"{avg_time:.1f}±{std_time:.1f} ({avg_generations:.1f}±{std_generations:.1f})"
cell_value = f"{avg_time:.0f} ({avg_generations:.0f})"
if SAVE_AVG_BEST_FITNESS:
cell_value += f" {avg_best_fitness:.0f}"
if avg_generations == BASE_CONFIG["max_generations"]:
cell_value = ""
row.append(cell_value)
table.add_row(row)
return table
def main():
"""Основная функция для запуска всех экспериментов."""
print("=" * 60)
print("ЗАПУСК ЭКСПЕРИМЕНТОВ ПО ПАРАМЕТРАМ ГЕНЕТИЧЕСКОГО АЛГОРИТМА")
print("=" * 60)
print(f"Размеры популяции: {POPULATION_SIZES}")
print(f"Значения Pc: {PC_VALUES}")
print(f"Значения Pm: {PM_VALUES}")
print(f"Количество запусков для усреднения: {NUM_RUNS}")
print("=" * 60)
# Создаем базовую папку
if os.path.exists(BASE_DIR):
shutil.rmtree(BASE_DIR)
os.makedirs(BASE_DIR)
# Запускаем эксперименты для каждого размера популяции
for pop_size in POPULATION_SIZES:
table = run_experiments_for_population(pop_size)
print(f"\n{'='*60}")
print(f"РЕЗУЛЬТАТЫ ДЛЯ ПОПУЛЯЦИИ РАЗМЕРОМ {pop_size}")
print(f"{'='*60}")
print(
f"Формат: среднееремя±стд_отклонениес (среднее_поколения±стд_отклонение)"
)
print(f"Усреднено по {NUM_RUNS} запускам")
print(table)
pop_exp_dir = os.path.join(BASE_DIR, str(pop_size))
os.makedirs(pop_exp_dir, exist_ok=True)
with open(os.path.join(pop_exp_dir, "results.csv"), "w", encoding="utf-8") as f:
f.write(table.get_csv_string())
print(f"Результаты сохранены в папке: {pop_exp_dir}")
print(f"\n{'='*60}")
print("ВСЕ ЭКСПЕРИМЕНТЫ ЗАВЕРШЕНЫ!")
print(f"Результаты сохранены в {BASE_DIR}")
print(f"{'='*60}")
if __name__ == "__main__":
main()

531
lab3/gen.py Normal file
View File

@@ -0,0 +1,531 @@
import os
import random
import shutil
import time
from copy import deepcopy
from dataclasses import asdict, dataclass
from typing import Callable
import numpy as np
import plotly.graph_objects as go
from matplotlib import pyplot as plt
from matplotlib.axes import Axes
from mpl_toolkits.mplot3d import Axes3D
from numpy.typing import NDArray
type Cites = list[tuple[float, float]]
type InitializePopulationFn = Callable[[int, Cites], Population]
type Chromosome = list[int]
type Population = list[Chromosome]
type Fitnesses = NDArray[np.float64]
type FitnessFn = Callable[[Chromosome], float]
type CrossoverFn = Callable[[Chromosome, Chromosome], tuple[Chromosome, Chromosome]]
type MutationFn = Callable[[Chromosome], Chromosome]
@dataclass
class GARunConfig:
fitness_func: FitnessFn
cities: Cites
initialize_population_fn: InitializePopulationFn
crossover_fn: CrossoverFn
mutation_fn: MutationFn
pop_size: int # размер популяции
pc: float # вероятность кроссинговера
pm: float # вероятность мутации
max_generations: int # максимальное количество поколений
elitism: int = (
0 # сколько лучших особей перенести без изменения в следующее поколение
)
max_best_repetitions: int | None = (
None # остановка при повторении лучшего результата
)
seed: int | None = None # seed для генератора случайных чисел
minimize: bool = False # если True, ищем минимум вместо максимума
save_generations: list[int] | None = (
None # индексы поколений для сохранения графиков
)
results_dir: str = "results" # папка для сохранения графиков
fitness_avg_threshold: float | None = (
None # порог среднего значения фитнес функции для остановки
)
best_value_threshold: float | None = (
None # остановка при достижении значения фитнеса лучше заданного
)
log_every_generation: bool = False # логировать каждое поколение
def save(self, filename: str = "GARunConfig.txt"):
"""Сохраняет конфиг в results_dir."""
os.makedirs(self.results_dir, exist_ok=True)
path = os.path.join(self.results_dir, filename)
with open(path, "w", encoding="utf-8") as f:
for k, v in asdict(self).items():
f.write(f"{k}: {v}\n")
@dataclass(frozen=True)
class Generation:
number: int
best: Chromosome
best_fitness: float
population: Population
fitnesses: Fitnesses
@dataclass(frozen=True)
class GARunResult:
generations_count: int
best_generation: Generation
history: list[Generation]
time_ms: float
def save(self, path: str, filename: str = "GARunResult.txt"):
"""Сохраняет конфиг в results_dir."""
os.makedirs(path, exist_ok=True)
path = os.path.join(path, filename)
with open(path, "w", encoding="utf-8") as f:
for k, v in asdict(self).items():
if k == "history":
continue
if k == "best_generation":
f.write(
f"{k}: Number: {v['number']}, Best Fitness: {v['best_fitness']}, Best: {v['best']}\n"
)
else:
f.write(f"{k}: {v}\n")
def initialize_random_population(pop_size: int, cities: Cites) -> Population:
"""Инициализирует популяцию случайными маршрутами без повторений городов."""
return [random.sample(range(len(cities)), len(cities)) for _ in range(pop_size)]
def reproduction(population: Population, fitnesses: Fitnesses) -> Population:
"""Репродукция (селекция) методом рулетки.
Чем больше значение фитнеса, тем больше вероятность выбора особи. Для минимизации
значения фитнеса нужно предварительно инвертировать.
"""
# Чтобы работать с отрицательными f, сдвигаем значения фитнес функции на минимальное
# значение в популяции. Вычитаем min_fit, т. к. min_fit может быть отрицательным.
min_fit = np.min(fitnesses)
shifted_fitnesses = fitnesses - min_fit + 1e-12
# Получаем вероятности для каждой особи
probs = shifted_fitnesses / np.sum(shifted_fitnesses)
cum = np.cumsum(probs)
# Выбираем особей методом рулетки
selected = []
for _ in population:
r = np.random.random()
idx = int(np.searchsorted(cum, r, side="left"))
selected.append(population[idx])
return selected
def partially_mapped_crossover_fn(
p1: Chromosome,
p2: Chromosome,
cut1: int | None = None,
cut2: int | None = None,
) -> tuple[Chromosome, Chromosome]:
n = len(p1)
# если разрезы не заданы — выберем случайные
if cut1 is None or cut2 is None:
cut1 = random.randint(1, n - 2) # [1, n-2]
cut2 = random.randint(cut1 + 1, n - 1) # (cut1, n-1]
# отображения внутри среднего сегмента
mapping12 = {p1[i]: p2[i] for i in range(cut1, cut2)}
mapping21 = {p2[i]: p1[i] for i in range(cut1, cut2)}
# будущие потомки
o1 = p2[:cut1] + p1[cut1:cut2] + p2[cut2:]
o2 = p1[:cut1] + p2[cut1:cut2] + p1[cut2:]
# разрешаем конфликты по цепочке
def resolve(x: int, mapping: dict[int, int]) -> int:
while x in mapping:
x = mapping[x]
return x
# исправляем только вне среднего сегмента
for i in (*range(0, cut1), *range(cut2, n)):
o1[i] = resolve(o1[i], mapping12)
o2[i] = resolve(o2[i], mapping21)
return o1, o2
def ordered_crossover_fn(
p1: Chromosome,
p2: Chromosome,
cut1: int | None = None,
cut2: int | None = None,
) -> tuple[Chromosome, Chromosome]:
n = len(p1)
# если разрезы не заданы — выберем случайные корректно
if cut1 is None or cut2 is None:
cut1 = random.randint(1, n - 2) # [1, n-2]
cut2 = random.randint(cut1 + 1, n - 1) # [cut1+1, n-1]
# --- o1: сегмент от p1, остальное — порядок из p2
o1: Chromosome = [None] * n # type: ignore
o1[cut1:cut2] = p1[cut1:cut2]
segment1 = set(p1[cut1:cut2])
fill_idx = cut2 % n
for x in (p2[i % n] for i in range(cut2, cut2 + n)):
if x not in segment1:
# прокручиваем fill_idx до ближайшей пустой ячейки
while o1[fill_idx] is not None:
fill_idx = (fill_idx + 1) % n
o1[fill_idx] = x
fill_idx = (fill_idx + 1) % n
# --- o2: сегмент от p2, остальное — порядок из p1
o2: Chromosome = [None] * n # type: ignore
o2[cut1:cut2] = p2[cut1:cut2]
segment2 = set(p2[cut1:cut2])
fill_idx = cut2 % n
for x in (p1[i % n] for i in range(cut2, cut2 + n)):
if x not in segment2:
while o2[fill_idx] is not None:
fill_idx = (fill_idx + 1) % n
o2[fill_idx] = x
fill_idx = (fill_idx + 1) % n
return o1, o2
def cycle_crossover_fn(p1: Chromosome, p2: Chromosome) -> tuple[Chromosome, Chromosome]:
n = len(p1)
o1 = [None] * n
o2 = [None] * n
# быстрый поиск позиций элементов p1
pos_in_p1 = {val: i for i, val in enumerate(p1)}
used = [False] * n
cycle_index = 0
for start in range(n):
if used[start]:
continue
# строим цикл индексов
idx = start
cycle = []
while not used[idx]:
used[idx] = True
cycle.append(idx)
# переход: idx -> элемент p2[idx] -> его позиция в p1
val = p2[idx]
idx = pos_in_p1[val]
# нечётные циклы: из p1 в o1, из p2 в o2
# чётные циклы: наоборот
if cycle_index % 2 == 0:
for i in cycle:
o1[i] = p1[i]
o2[i] = p2[i]
else:
for i in cycle:
o1[i] = p2[i]
o2[i] = p1[i]
cycle_index += 1
return o1, o2 # type: ignore
def crossover(
population: Population,
pc: float,
crossover_fn: CrossoverFn,
) -> Population:
"""Оператор кроссинговера (скрещивания) выполняется с заданной вероятностью pc.
Две хромосомы (родители) выбираются случайно из промежуточной популяции.
Если популяция нечетного размера, то последняя хромосома скрещивается со случайной
другой хромосомой из популяции. В таком случае одна из хромосом может поучаствовать
в кроссовере дважды.
"""
# Создаем копию популяции и перемешиваем её для случайного выбора пар
shuffled_population = population.copy()
np.random.shuffle(shuffled_population)
next_population = []
pop_size = len(shuffled_population)
for i in range(0, pop_size, 2):
p1 = shuffled_population[i]
p2 = shuffled_population[(i + 1) % pop_size]
if np.random.random() <= pc:
p1, p2 = crossover_fn(p1, p2)
next_population.append(p1)
next_population.append(p2)
return next_population[:pop_size]
def swap_mutation_fn(chrom: Chromosome) -> Chromosome:
"""Меняем два случайных города в маршруте местами."""
chrom = chrom.copy()
a, b = random.sample(range(len(chrom)), 2)
chrom[a], chrom[b] = chrom[b], chrom[a]
return chrom
def inversion_mutation_fn(chrom: Chromosome) -> Chromosome:
"""Инвертируем случайный сегмент маршрута."""
chrom = chrom.copy()
a, b = sorted(random.sample(range(len(chrom)), 2))
chrom[a:b] = reversed(chrom[a:b])
return chrom
def insertion_mutation_fn(chrom: Chromosome) -> Chromosome:
"""Вырезаем случайный город и вставляем его в случайное место маршрута."""
chrom = chrom.copy()
a, b = random.sample(range(len(chrom)), 2)
city = chrom.pop(a)
chrom.insert(b, city)
return chrom
def mutation(population: Population, pm: float, mutation_fn: MutationFn) -> Population:
"""Мутация происходит с вероятностью pm."""
next_population = []
for chrom in population:
next_population.append(
mutation_fn(chrom) if np.random.random() <= pm else chrom
)
return next_population
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 eval_population(population: Population, fitness_func: FitnessFn) -> Fitnesses:
return np.array([fitness_func(chrom) for chrom in population])
def plot_tour(cities: list[tuple[float, float]], tour: list[int], ax: Axes):
"""Рисует маршрут обхода городов."""
x = [cities[i][0] for i in tour]
y = [cities[i][1] for i in tour]
ax.plot(x + [x[0]], y + [y[0]], "k-", linewidth=1)
ax.plot(x, y, "ro", markersize=4)
# for i, (cx, cy) in enumerate(cities):
# plt.text(cx, cy, str(i), fontsize=7, ha="right", va="bottom")
ax.axis("equal")
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,
)
# Рисуем лучший маршрут в поколении
ax = fig.add_subplot(1, 1, 1)
plot_tour(config.cities, generation.best, ax)
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)
def genetic_algorithm(config: GARunConfig) -> GARunResult:
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)
population = config.initialize_population_fn(config.pop_size, config.cities)
start = time.perf_counter()
history: list[Generation] = []
best: Generation | None = None
generation_number = 1
best_repetitions = 0
while True:
# Вычисляем фитнес для всех особей в популяции
fitnesses = eval_population(population, config.fitness_func)
# Сохраняем лучших особей для переноса в следующее поколение
elites: list[Chromosome] = []
if config.elitism:
elites = deepcopy(
[
population[i]
for i in sorted(
range(len(fitnesses)),
key=lambda i: fitnesses[i],
reverse=not config.minimize,
)
][: config.elitism]
)
# Находим лучшую особь в поколении
best_index = (
int(np.argmin(fitnesses)) if config.minimize else int(np.argmax(fitnesses))
)
# Добавляем эпоху в историю
current = Generation(
number=generation_number,
best=population[best_index],
best_fitness=fitnesses[best_index],
population=deepcopy(population),
fitnesses=deepcopy(fitnesses),
)
history.append(current)
if config.log_every_generation:
print(
f"Generation #{generation_number} best: {current.best_fitness},"
f" avg: {np.mean(current.fitnesses)}"
)
# Обновляем лучшую эпоху
if (
best is None
or (config.minimize and current.best_fitness < best.best_fitness)
or (not config.minimize and current.best_fitness > best.best_fitness)
):
best = current
# Проверка критериев остановки
stop_algorithm = False
if generation_number >= config.max_generations:
stop_algorithm = True
if config.max_best_repetitions is not None and generation_number > 1:
if history[-2].best_fitness == current.best_fitness:
best_repetitions += 1
if best_repetitions == config.max_best_repetitions:
stop_algorithm = True
else:
best_repetitions = 0
# if config.variance_threshold is not None:
# fitness_variance = np.var(fitnesses)
# if fitness_variance < config.variance_threshold:
# stop_algorithm = True
if config.best_value_threshold is not None:
if (
config.minimize and current.best_fitness < config.best_value_threshold
) or (
not config.minimize
and current.best_fitness > config.best_value_threshold
):
stop_algorithm = True
if config.fitness_avg_threshold is not None:
mean_fitness = np.mean(fitnesses)
if (config.minimize and mean_fitness < config.fitness_avg_threshold) or (
not config.minimize and mean_fitness > config.fitness_avg_threshold
):
stop_algorithm = True
# Сохраняем указанные поколения и последнее поколение
if config.save_generations and (
stop_algorithm or generation_number in config.save_generations
):
save_generation(current, history, config)
if stop_algorithm:
break
# селекция (для минимума инвертируем знак)
parents = reproduction(
population, fitnesses if not config.minimize else -fitnesses
)
# кроссинговер попарно
next_population = crossover(parents, config.pc, config.crossover_fn)
# мутация
next_population = mutation(
next_population,
config.pm,
config.mutation_fn,
)
# Вставляем элиту в новую популяцию
population = next_population[: config.pop_size - config.elitism] + elites
generation_number += 1
end = time.perf_counter()
assert best is not None, "Best was never set"
return GARunResult(
len(history),
best,
history,
(end - start) * 1000.0,
)
def plot_fitness_history(result: GARunResult, save_path: str | None = None) -> None:
"""Рисует график изменения лучших и средних значений фитнеса по поколениям."""
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]
fig, ax = plt.subplots(figsize=(10, 6))
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}")
else:
plt.show()
plt.close(fig)

111
lab3/main.py Normal file
View File

@@ -0,0 +1,111 @@
import math
import os
import matplotlib.pyplot as plt
import numpy as np
from gen import (
Chromosome,
GARunConfig,
genetic_algorithm,
initialize_random_population,
inversion_mutation_fn,
partially_mapped_crossover_fn,
plot_fitness_history,
plot_tour,
swap_mutation_fn,
)
# В списке из 89 городов только 38 уникальных
cities = set()
with open("data.txt", "r") as file:
for line in file:
# x и y поменяны местами в визуализациях в методичке
_, y, x = line.split()
cities.add((float(x), float(y)))
cities = list(cities)
def euclidean_distance(city1, city2):
return math.sqrt((city1[0] - city2[0]) ** 2 + (city1[1] - city2[1]) ** 2)
def build_fitness_function(cities):
def fitness_function(chromosome: Chromosome) -> float:
return sum(
euclidean_distance(cities[chromosome[i]], cities[chromosome[i + 1]])
for i in range(len(chromosome) - 1)
) + euclidean_distance(cities[chromosome[0]], cities[chromosome[-1]])
return fitness_function
config = GARunConfig(
fitness_func=build_fitness_function(cities),
initialize_population_fn=initialize_random_population,
cities=cities,
crossover_fn=partially_mapped_crossover_fn,
# mutation_fn=swap_mutation_fn,
mutation_fn=inversion_mutation_fn,
pop_size=500,
elitism=3,
pc=0.9,
pm=0.3,
max_generations=2500,
# max_best_repetitions=10,
minimize=False,
seed=17,
save_generations=[
1,
5,
20,
50,
100,
300,
500,
700,
900,
1500,
2000,
2500,
3000,
3500,
4000,
4500,
],
log_every_generation=True,
)
result = genetic_algorithm(config)
# Сохраняем конфиг и результаты в файлы
config.save()
result.save(config.results_dir)
# Выводим результаты
print(f"Лучшая особь: {result.best_generation.best}")
print(f"Лучшее значение фитнеса: {result.best_generation.best_fitness:.6f}")
print(f"Количество поколений: {result.generations_count}")
print(f"Время выполнения: {result.time_ms:.2f} мс")
# Сохраняем лучшую особь за всё время
fig = plt.figure(figsize=(7, 7))
fig.suptitle(
f"Поколение #{result.best_generation.number}. "
f"Лучшая особь: {result.best_generation.best_fitness:.4f}. "
f"Среднее значение: {np.mean(result.best_generation.fitnesses):.4f}",
fontsize=14,
y=0.95,
)
# Рисуем лучший маршрут в поколении
ax = fig.add_subplot(1, 1, 1)
plot_tour(config.cities, result.best_generation.best, ax)
filename = f"best_generation_{result.best_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)
# Рисуем график прогресса по поколениям
plot_fitness_history(
result, save_path=os.path.join(config.results_dir, "fitness_history.png")
)

99
lab3/plot_best.py Normal file
View File

@@ -0,0 +1,99 @@
import math
import os
import matplotlib.pyplot as plt
import numpy as np
from gen import (
Chromosome,
GARunConfig,
genetic_algorithm,
initialize_random_population,
inversion_mutation_fn,
partially_mapped_crossover_fn,
plot_fitness_history,
plot_tour,
swap_mutation_fn,
)
best = [
0,
29,
9,
27,
18,
14,
5,
17,
13,
30,
20,
34,
15,
22,
23,
24,
26,
33,
32,
7,
12,
37,
11,
2,
6,
16,
35,
1,
36,
3,
28,
21,
8,
31,
4,
10,
25,
19,
]
cities = set()
with open("data.txt", "r") as file:
for line in file:
# x и y поменяны местами в визуализациях в методичке
_, y, x = line.split()
cities.add((float(x), float(y)))
cities = list(cities)
def euclidean_distance(city1, city2):
return math.sqrt((city1[0] - city2[0]) ** 2 + (city1[1] - city2[1]) ** 2)
def build_fitness_function(cities):
def fitness_function(chromosome: Chromosome) -> float:
return sum(
euclidean_distance(cities[chromosome[i]], cities[chromosome[i + 1]])
for i in range(len(chromosome) - 1)
) + euclidean_distance(cities[chromosome[0]], cities[chromosome[-1]])
return fitness_function
fitness_function = build_fitness_function(cities)
# Сохраняем лучшую особь за всё время
fig = plt.figure(figsize=(7, 7))
fig.suptitle(
f"Лучший возможный маршрут. " f"Длина: {fitness_function(best):.4f}",
fontsize=14,
y=0.95,
)
# Рисуем лучший маршрут в поколении
ax = fig.add_subplot(1, 1, 1)
plot_tour(cities, best, ax)
filename = f"best_possible.png"
path_png = os.path.join("", filename)
fig.savefig(path_png, dpi=150, bbox_inches="tight")
plt.close(fig)

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

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

BIN
lab3/report/img/alg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

678
lab3/report/report.tex Normal file
View File

@@ -0,0 +1,678 @@
\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{Лабораторная работа №3}\\
\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{Дано:} Эвклидовы координаты городов 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}
\vspace{0.3cm}
\textbf{Требуется:}
\begin{enumerate}
\item Реализовать с использованием генетических алгоритмов решение задачи коммивояжера.
\item Для туров использовать путевое представление.
\end{enumerate}
\newpage
\section{Теоретические сведения}
Генетические алгоритмы (ГА) используют принципы и терминологию, заимствованные у биологической науки генетики. В ГА каждая особь представляет потенциальное решение некоторой
проблемы. В классическом ГА особь кодируется строкой двоичных символов хромосомой. Однако представление хромосомы зависит от постановки задачи: для непрерывных задач удобны векторы вещественных чисел (real-coded), тогда как для комбинаторных задач, таких как задача коммивояжера (ЗК), естественно представлять тур как перестановку городов. Длина хромосомы совпадает с числом элементов задачи; двоичное кодирование ЗК, как правило, неэффективно из‑за необходимости «ремонта» решений после применения операторов.
Множество особей потенциальных решений составляет популяцию. Поиск (суб)оптимального решения проблемы выполняется в процессе эволюции популяции - последовательного преобразования одного конечного множества решений в другое с помощью генетических операторов репродукции, кроссинговера и мутации.
Предварительно простой ГА случайным образом генерирует начальную популяцию стрингов
(хромосом). Затем алгоритм генерирует следующее поколение (популяцию), с помощью трех основных генетических операторов:
\begin{enumerate}
\item Оператор репродукции (ОР);
\item Оператор скрещивания (кроссинговера, ОК);
\item Оператор мутации (ОМ).
\end{enumerate}
ГА работает до тех пор, пока не будет выполнено заданное количество поколений (итераций)
процесса эволюции или на некоторой генерации будет получено заданное качество или вследствие
преждевременной сходимости при попадании в некоторый локальный оптимум. На Рис.~\ref{fig:alg} представлен простой генетический алгоритм.
\begin{figure}[h!]
\centering
\includegraphics[width=0.9\linewidth]{img/alg.png}
\caption{Простой генетический алгоритм}
\label{fig:alg}
\end{figure}
\newpage
\subsection{Основная терминология в генетических алгоритмах}
\textbf{Ген} -- элементарный код в хромосоме $s_i$, называемый также знаком или детектором
(в классическом ГА $s_i = 0, 1$).
\textbf{Хромосома} -- упорядоченная последовательность генов в виде закодированной структуры
данных $S = (s_1, s_2, \ldots, s_n)$, определяющая решение. Представление зависит от типа задачи: для непрерывных задач — вектор вещественных чисел; для ЗК — перестановка городов (см. раздел о представлениях: соседское, порядковое и путевое).
\textbf{Локус} -- местоположение (позиция, номер бита) данного гена в хромосоме.
\textbf{Аллель} -- значение, которое принимает данный ген (например, 0 или 1).
\textbf{Особь} -- одно потенциальное решение задачи (представляемое хромосомой).
\textbf{Популяция} -- множество особей (хромосом), представляющих потенциальные решения.
\textbf{Поколение} -- текущая популяция ГА на данной итерации алгоритма.
\textbf{Генотип} -- набор хромосом данной особи. В популяции могут использоваться как отдельные
хромосомы, так и целые генотипы.
\textbf{Генофонд} -- множество всех возможных генотипов.
\textbf{Фенотип} -- набор значений, соответствующий данному генотипу. Это декодированное множество
параметров задачи (например, десятичное значение $x$, соответствующее двоичному коду).
\textbf{Размер популяции $N$} -- число особей в популяции.
\textbf{Число поколений} -- количество итераций, в течение которых производится поиск.
\textbf{Селекция} -- совокупность правил, определяющих выживание особей на основе значений целевой функции.
\textbf{Эволюция популяции} -- чередование поколений, в которых хромосомы изменяют свои признаки,
чтобы каждая новая популяция лучше приспосабливалась к среде.
\textbf{Фитнесс-функция} -- функция полезности, определяющая меру приспособленности особи.
В задачах оптимизации она совпадает с целевой функцией или описывает близость к оптимальному решению.
\subsection{Представления хромосом для задачи коммивояжера}
Задача коммивояжера (ЗК) формулируется так: требуется посетить каждый из $N$ городов ровно один раз и вернуться в исходную точку, минимизируя суммарную стоимость (или длину) тура. Естественным является представление тура как перестановки городов. На практике используются три основных представления, каждое со своими операторами рекомбинации:
\subsubsection{Представление соседства}
Тур задаётся списком из $N$ городов, где в позиции $i$ указан город $j$, означающий переход из города $i$ в город $j$. Например, вектор $(2\;4\;8\;3\;9\;7\;1\;5\;6)$ соответствует туру $1\!\to\!2\!\to\!4\!\to\!3\!\to\!8\!\to\!5\!\to\!9\!\to\!6\!\to\!7$. У каждого корректного тура есть единственное соседское представление, однако не всякая строка в этом представлении корректна (возможны преждевременные циклы, например $1\!\to\!2\!\to\!4\!\to\!1\ldots$).
\subsubsection{Порядковое представление}
Тур представляется списком из $N$ позиций; $i$-й элемент равен индексу города в текущем упорядоченном списке доступных городов. Например, при опорном списке $C=(1\;2\;3\;4\;5\;6\;7\;8\;9)$ тур $1\!\to\!2\!\to\!4\!\to\!3\!\to\!8\!\to\!5\!\to\!9\!\to\!6\!\to\!7$ кодируется как $l=(1\;1\;2\;1\;4\;1\;3\;1\;1)$, последовательно «выбирая» элементы из $C$.
\subsubsection{Путевое представление}
Наиболее интуитивное представление: тур записывается как последовательность городов, например $5\!\to\!1\!\to\!7\!\to\!8\!\to\!9\!\to\!4\!\to\!6\!\to\!2\!\to\!3$ кодируется как $(5\;1\;7\;8\;9\;4\;6\;2\;3)$. Это представление сохраняет относительный порядок городов и широко применяется на практике.
\subsection{Кроссинговеры для представлений ЗК}
Операторы рекомбинации должны сохранять допустимость туров (перестановочную природу решения). Для разных представлений используются различные кроссинговеры.
\subsubsection{Кроссинговеры для представления соседства}
\textbf{Alternating Edges (обмен рёбрами):} потомок строится, поочерёдно выбирая ребра у родителей: одно ребро у первого родителя, следующее — у второго, затем снова у первого и т.д. Если выбранное ребро замыкает цикл преждевременно, выбирается другое ещё не использованное ребро того же родителя, не образующее цикл.
\textbf{Subtour Chunks (обмен подтурами):} потомок формируется конкатенацией кусочков (подтуров), поочерёдно взятых у родителей. При образовании преждевременного цикла производится «ремонт» аналогично предыдущему оператору.
\textbf{Heuristic Crossover (эвристический):} стартуя из случайного города, на каждом шаге сравниваются два инцидентных ребра, предлагаемых родителями, и выбирается более короткое; если возникает цикл или ребро уже использовано, выбирается случайный ещё не посещённый город. Оператор нацелен на сохранение коротких рёбер, но может иметь нестабильную производительность.
\subsubsection{Кроссинговеры для порядкового представления}
Для порядкового представления корректность потомков обеспечивает классический одноточечный кроссовер: любые два родителя, разрезанные в одной позиции и склеенные, порождают допустимых потомков (поскольку выбор «по индексу» в оставшемся списке городов остаётся корректным).
\subsubsection{Кроссинговеры для путевого представления}
Для путевого представления широко применяются три оператора, гарантирующие корректную перестановку у потомков.
\paragraph{PMX (Partially Mapped Crossover).}
Идея: обменять подпоследовательности между родителями и построить отображение соответствий, которым затем разрешать конфликты (дубликаты).
\textit{Пример.} Пусть точки разреза задают сегмент позиций $4\dots7$:
$$
p_1=(1\;2\;3\;|\;4\;5\;6\;7\;|\;8\;9),\quad
p_2=(4\;5\;2\;|\;1\;8\;7\;6\;|\;9\;3).
$$
1) Копируем сегмент второго родителя в потомка $o_1$ и формируем отображение $\{4\leftrightarrow1,\;5\leftrightarrow8,\;6\leftrightarrow7,\;7\leftrightarrow6\}$:
$$o_1=(\_\;\_\;\_\;|\;1\;8\;7\;6\;|\;\_\;\_).$$
2) Заполняем прочие позиции по порядку из $p_1$, применяя отображение при конфликтах: $1\mapsto4$, $8\mapsto5$.
$$o_1=(4\;2\;3\;|\;1\;8\;7\;6\;|\;5\;9).$$
Аналогично для $o_2$ (копируем сегмент из $p_1$, заполняем остальное из $p_2$):
$$o_2=(1\;8\;2\;|\;4\;5\;6\;7\;|\;9\;3).$$
PMX сохраняет как позиции части элементов, так и относительный порядок/соответствия на остальной части хромосомы.
\paragraph{OX (Order Crossover).}
Идея: скопировать сегмент одного родителя и дозаполнить оставшиеся позиции элементами второго родителя в их порядке появления (пропуская уже скопированные).
\textit{Пример.} С теми же родителями и разрезами $4\dots7$:
$$
p_1=(1\;2\;3\;|\;4\;5\;6\;7\;|\;8\;9),\quad
p_2=(4\;5\;2\;|\;1\;8\;7\;6\;|\;9\;3).
$$
1) Копируем сегмент $p_1$ в $o_1$:
$$o_1=(\_\;\_\;\_\;|\;4\;5\;6\;7\;|\;\_\;\_).$$
2) Обходя $p_2$ с позиции после правого разреза, дозаполняем: получаем
$$o_1=(2\;1\;8\;|\;4\;5\;6\;7\;|\;9\;3).$$
Симметрично для $o_2$ (копируем сегмент из $p_2$ и дозаполняем порядком из $p_1$):
$$o_2=(3\;4\;5\;|\;1\;8\;7\;6\;|\;9\;2).$$
Оператор OX сохраняет относительный порядок городов; циклический сдвиг тура несущественен.
\paragraph{CX (Cycle Crossover).}
Идея: находить циклы позиций, индуцированные взаимным расположением значений у родителей, и наследовать циклы по очереди из разных родителей.
\textit{Пример.} Возьмём
$$
p_1=(1\;2\;3\;4\;5\;6\;7\;8\;9),\quad
p_2=(4\;5\;2\;1\;8\;7\;6\;9\;3).
$$
Построив циклы позиций, получим допустимых потомков, например:
$$o_1=(1\;2\;3\;4\;7\;6\;9\;8\;5),\quad o_2=(4\;1\;2\;8\;5\;6\;7\;3\;9).$$
CX сохраняет абсолютные позиции части элементов и способствует передаче «циклами» взаимных расположений.
Отметим, что путевое представление акцентирует порядок городов (а не стартовый город), поэтому туры, отличающиеся циклическим сдвигом, эквивалентны.
\subsection{Мутации для путевого представления}
Операторы мутации в ГА для задачи коммивояжёра должны сохранять допустимость решения (перестановочную структуру). Для путевого представления применяются специализированные операторы, которые модифицируют порядок городов, не нарушая корректности тура.
\paragraph{Swap (обмен двух элементов).}
Идея: выбрать случайным образом две позиции в маршруте и обменять находящиеся на них города местами.
\textit{Пример.} Пусть исходный тур:
$$
t=(1\;2\;3\;4\;5\;6\;7\;8\;9).
$$
Выбираем позиции $i=2$ и $j=6$ (элементы $3$ и $7$). После обмена получаем:
$$
t'=(1\;2\;7\;4\;5\;6\;3\;8\;9).
$$
Оператор swap обеспечивает локальную модификацию тура, изменяя положение только двух городов.
\paragraph{Inversion (инверсия сегмента).}
Идея: выбрать случайный сегмент маршрута и обратить порядок городов внутри него.
\textit{Пример.} Для того же тура выбираем позиции разреза $i=3$ и $j=7$ (сегмент $4\;5\;6\;7$):
$$
t=(1\;2\;3\;|\;4\;5\;6\;7\;|\;8\;9).
$$
Инвертируем выделенный сегмент:
$$
t'=(1\;2\;3\;|\;7\;6\;5\;4\;|\;8\;9).
$$
Инверсия сохраняет связность частей маршрута, меняя направление обхода в подтуре. Этот оператор особенно эффективен при наличии пересечений рёбер, так как инверсия может «распутать» некоторые из них и улучшить длину маршрута.
\paragraph{Insertion (вырезка и вставка).}
Идея: выбрать случайный город, удалить его из текущей позиции и вставить в другую случайную позицию маршрута.
\textit{Пример.} Пусть исходный тур:
$$
t=(1\;2\;3\;4\;5\;6\;7\;8\;9).
$$
Выбираем город на позиции $i=3$ (элемент $4$) и целевую позицию $j=7$. Удаляем элемент $4$:
$$
t_{\text{tmp}}=(1\;2\;3\;5\;6\;7\;8\;9).
$$
Вставляем $4$ на позицию $7$:
$$
t'=(1\;2\;3\;5\;6\;7\;4\;8\;9).
$$
insertion изменяет расположение одного города относительно других, смещая соседей.
Все три оператора гарантируют сохранение корректной перестановки: каждый город остаётся в туре ровно один раз.
\newpage
\section{Особенности реализации}
В рамках работы создана мини-библиотека \texttt{gen.py} для решения задачи коммивояжёра (TSP) генетическим алгоритмом с путевым представлением хромосом. Второй модуль
\texttt{expirements.py} организует серийные эксперименты (перебор параметров,
форматирование и сохранение результатов).
\begin{itemize}
\item \textbf{Кодирование особей}: каждая хромосома представлена как перестановка городов (\texttt{Chromosome = list[int]}), где каждый элемент -- индекс города. Популяция -- список хромосом (\texttt{Population = list[Chromosome]}). Инициализация случайными перестановками без повторений:
\begin{itemize}
\item \texttt{initialize\_random\_population(pop\_size: int, cities: Cites) -> Population}
\end{itemize}
\item \textbf{Фитнесс-функция}: целевая функция принимает хромосому (маршрут) и возвращает скалярное значение фитнесса (длину пути). Для режима минимизации используется внутреннее преобразование при селекции (сдвиг и инверсия знака), что позволяет применять рулетку:
\begin{itemize}
\item \texttt{eval\_population(population: Population, fitness\_func: FitnessFn) -> Fitnesses}
\item Логика режима минимизации в \texttt{genetic\_algorithm(config: GARunConfig) -> GARunResult}
\end{itemize}
\item \textbf{Селекция (рулетка)}: вероятности нормируются после сдвига на минимальное значение в поколении (устойчиво к отрицательным фитнессам). Функция:
\texttt{reproduction(population: Population, fitnesses: Fitnesses) -> Population}.
\item \textbf{Кроссинговер}: реализованы специализированные операторы для перестановок: PMX (Partially Mapped Crossover), OX (Ordered Crossover) и CX (Cycle Crossover). Кроссинговер выполняется попарно по перемешанной популяции с вероятностью $p_c$. Функции:
\begin{itemize}
\item \texttt{partially\_mapped\_crossover\_fn(p1: Chromosome, p2: Chromosome) -> tuple[Chromosome, Chromosome]}
\item \texttt{ordered\_crossover\_fn(p1: Chromosome, p2: Chromosome) -> tuple[Chromosome, Chromosome]}
\item \texttt{cycle\_crossover\_fn(p1: Chromosome, p2: Chromosome) -> tuple[Chromosome, Chromosome]}
\item \texttt{crossover(population: Population, pc: float, crossover\_fn: CrossoverFn) -> Population}
\end{itemize}
\item \textbf{Мутация}: реализованы три типа мутаций для перестановок: обмен двух городов (swap), инверсия сегмента (inversion), вырезка и вставка города (insertion). Мутация применяется с вероятностью $p_m$. Функции:
\begin{itemize}
\item \texttt{swap\_mutation\_fn(chrom: Chromosome) -> Chromosome}
\item \texttt{inversion\_mutation\_fn(chrom: Chromosome) -> Chromosome}
\item \texttt{insertion\_mutation\_fn(chrom: Chromosome) -> Chromosome}
\item \texttt{mutation(population: Population, pm: float, mutation\_fn: MutationFn) -> Population}
\end{itemize}
\item \textbf{Критерий остановки}: поддерживаются критерии по максимальному количеству поколений, повторению лучшего результата, достижению порогового значения фитнесса. Хранится история всех поколений. Проверка выполняется в функции:
\texttt{genetic\_algorithm(config: GARunConfig) -> GARunResult}.
\item \textbf{Визуализация}: реализована отрисовка маршрутов обхода городов на плоскости с отображением лучшей особи поколения. Функции:
\begin{itemize}
\item \texttt{plot\_tour(cities: list[tuple[float, float]], tour: list[int], ax: Axes)}
\item \texttt{save\_generation(generation: Generation, history: list[Generation], config: GARunConfig)}
\item \texttt{plot\_fitness\_history(result: GARunResult, save\_path: str | None) -> None}
\end{itemize}
\item \textbf{Элитизм}: поддерживается перенос лучших особей без изменения в следующее поколение (\texttt{elitism} параметр).
\item \textbf{Измерение времени}: длительность вычислений возвращается в миллисекундах как часть \texttt{GARunResult.time\_ms}.
\item \textbf{Файловая организация}: результаты экспериментов сохраняются в структуре \texttt{experiments/N/} с таблицами результатов. Задействованные функции:
\begin{itemize}
\item \texttt{clear\_results\_directory(results\_dir: str) -> None}
\item Функции для проведения экспериментов в модуле \texttt{expirements.py}
\end{itemize}
\end{itemize}
В модуле \texttt{expirements.py} задаются координаты городов и параметры экспериментов.
Серийные запуски и сохранение результатов реализованы для исследования влияния параметров ГА на качество решения задачи коммивояжёра.
\newpage
\section{Результаты работы}
На Рис.~\ref{fig:gen1}--\ref{fig:lastgen} представлены результаты работы генетического алгоритма со следующими параметрами:
\begin{itemize}
\item $N = 500$ -- размер популяции.
\item $p_c = 0.9$ -- вероятность кроссинговера.
\item $p_m = 0.3$ -- вероятность мутации.
\item $2500$ -- максимальное количество поколений.
\item $3$ -- количество "элитных" особей, переносимых без изменения в следующее поколение.
\item Partially mapped crossover - кроссовер.
\item Inversion mutation - мутация
\end{itemize}
На Рис.~\ref{fig:fitness_history} показан график изменения фитнесса по поколениям. Видно, что алгоритм постепенно сходится к минимально возможному значению фитнеса. Лучший маршрут был найден на поколнении №1896 (см. Рис.~\ref{fig:lastgen}).
\begin{figure}[h!]
\centering
\includegraphics[width=1\linewidth]{img/results/fitness_history.png}
\caption{График изменения фитнесса по поколениям}
\label{fig:fitness_history}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=0.7\linewidth]{img/results/generation_001.png}
\caption{Лучший маршрут поколения №1}
\label{fig:gen1}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=0.7\linewidth]{img/results/generation_005.png}
\caption{Лучший маршрут поколения №5}
\label{fig:gen5}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=0.7\linewidth]{img/results/generation_050.png}
\caption{Лучший маршрут поколения №50}
\label{fig:gen50}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=0.7\linewidth]{img/results/generation_100.png}
\caption{Лучший маршрут поколения №100}
\label{fig:gen100}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=0.7\linewidth]{img/results/generation_300.png}
\caption{Лучший маршрут поколения №300}
\label{fig:gen300}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=0.7\linewidth]{img/results/generation_500.png}
\caption{Лучший маршрут поколения №500}
\label{fig:gen500}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=0.7\linewidth]{img/results/generation_900.png}
\caption{Лучший маршрут поколения №900}
\label{fig:gen900}
\end{figure}
\begin{figure}[h!]
\centering
\includegraphics[width=0.7\linewidth]{img/results/best_generation_1896.png}
\caption{Лучший маршрут поколения №1896}
\label{fig:lastgen}
\end{figure}
\newpage
\phantom{text}
\newpage
\section{Исследование реализации}
\subsection{Проведение измерений}
В рамках лабораторной работы необходимо было исследовать зависимость времени выполнения задачи и количества поколений от популяции и вероятностей кроссинговера и мутации хромосомы
Для исследования были выбраны следующие значения параметров:
\begin{itemize}
\item $N = 10, 50, 100, 500$ -- размер популяции.
\item $p_c = 0.5, 0.6, 0.7, 0.8, 0.9$ -- вероятность кроссинговера.
\item $p_m = 0.05, 0.2, 0.3, 0.4, 0.5, 0.8$ -- вероятность мутации.
\item $3$ -- количество "элитных" особей, переносимых без изменения в следующее поколение.
\item Partially mapped crossover - кроссовер.
\item Inversion mutation - мутация
\item 7000 - пороговое значение фитнеса для остановки алгоритма.
\end{itemize}
Результаты измерений представлены в таблицах \ref{tab:pc_pm_results_10}--\ref{tab:pc_pm_results_500}. В ячейках указано время в миллисекундах нахождения минимума функции. В скобках указано количество поколений, за которое было найдено решение. Во второй строке указано усреднённое по всем запускам лучшее значение фитнеса. Если в ячейке стоит прочерк, то это означает, что решение не было найдено за 2500 поколений. Лучшее значение по времени выполнения и по значению фитнеса для каждого размера популяции выделено цветом и жирным шрифтом.
\newcolumntype{Y}{>{\centering\arraybackslash}X}
% Автоматически сгенерированные LaTeX таблицы
% Лучший результат по времени и по фитнесу выделены жирным отдельно
% Убедитесь, что подключен \usepackage{tabularx}
% ВНИМАНИЕ: Убедитесь, что подключен \usepackage{xcolor} для цветового выделения
% Используйте \newcolumntype{Y}{>{\centering\arraybackslash}X} перед таблицами
\begin{table}[h!]
\centering
\small
\caption{Результаты для $N = 10$}
\begin{tabularx}{\linewidth}{l *{6}{Y}}
\toprule
$\mathbf{P_c \;\backslash\; P_m}$ & \textbf{0.050} & \textbf{0.200} & \textbf{0.300} & \textbf{0.400} & \textbf{0.500} & \textbf{0.800} \\
\midrule
\textbf{0.5} &&& 674.4 (1783) 6943.28027 & 715.1 (1856) 6925.47290 && 225.5 (567) 6984.75016 \\
\textbf{0.6} &&& 550.6 (1427) 6899.82219 & 649.4 (1653) 6897.01699 &&\\
\textbf{0.7} &&& 476.7 (1216) 6796.98342 & 287.4 (724) 6977.43028 & \textcolor{magenta}{\textbf{201.0 (503)}} 6794.32839 &\\
\textbf{0.8} &&&& 767.2 (1852) 6810.96744 & 253.3 (623) 6905.36866 &\\
\textbf{0.9} &&&&&&\\
\textbf{1.0} && 750.9 (1847) 6988.52746 & 415.7 (1016) 6897.99266 & 465.7 (1126) \textcolor{magenta}{\textbf{6762.96572}} & 275.9 (662) 6997.70453 &\\
\bottomrule
\end{tabularx}
\label{tab:pc_pm_results_10}
\end{table}
\begin{table}[h!]
\centering
\small
\caption{Результаты для $N = 50$}
\begin{tabularx}{\linewidth}{l *{6}{Y}}
\toprule
$\mathbf{P_c \;\backslash\; P_m}$ & \textbf{0.050} & \textbf{0.200} & \textbf{0.300} & \textbf{0.400} & \textbf{0.500} & \textbf{0.800} \\
\midrule
\textbf{0.5} & 1711.4 (1083) 6927.73356 && 1642.7 (1015) 6894.10066 & 1355.6 (809) 6938.12550 && 936.3 (544) 6925.57274 \\
\textbf{0.6} & 1338.4 (828) 6952.02461 & 889.1 (552) 6951.40489 & 1142.5 (687) 6963.17379 & 1446.9 (864) 6992.95281 && 2646.2 (1509) 6932.85788 \\
\textbf{0.7} & 1860.8 (1146) 6996.63686 && 2387.8 (1378) 6999.00110 && \textcolor{magenta}{\textbf{809.9 (474)}} 6965.83938 & 1614.7 (918) 6990.50067 \\
\textbf{0.8} &&& 1244.4 (713) \textcolor{magenta}{\textbf{6704.60011}} & 1500.5 (859) 6970.42362 & 1013.5 (581) 6998.68282 &\\
\textbf{0.9} &&&&&&\\
\textbf{1.0} && 891.6 (503) 6952.80522 &&& 1489.6 (824) 6735.40661 & 3685.9 (1978) 6989.21247 \\
\bottomrule
\end{tabularx}
\label{tab:pc_pm_results_50}
\end{table}
\begin{table}[h!]
\centering
\small
\caption{Результаты для $N = 100$}
\begin{tabularx}{\linewidth}{l *{6}{Y}}
\toprule
$\mathbf{P_c \;\backslash\; P_m}$ & \textbf{0.050} & \textbf{0.200} & \textbf{0.300} & \textbf{0.400} & \textbf{0.500} & \textbf{0.800} \\
\midrule
\textbf{0.5} & 1342.3 (441) 6988.26353 & 1467.7 (459) 6958.81642 & 4041.3 (1269) \textcolor{magenta}{\textbf{6839.94363}} &&& 3635.1 (1046) 6966.14098 \\
\textbf{0.6} & 2460.6 (763) 6872.20321 & \textcolor{magenta}{\textbf{1316.5 (409)}} 6861.65860 && 2310.7 (691) 6912.50054 & 2220.9 (663) 6907.57533 &\\
\textbf{0.7} && 1934.1 (591) 6933.87982 && 1966.0 (587) 6943.09435 & 2872.9 (840) 6998.39699 &\\
\textbf{0.8} & 3227.9 (969) 6990.28735 & 1754.4 (523) 6996.67018 && 2152.8 (621) 6988.30495 & 8057.2 (2236) 6899.21400 &\\
\textbf{0.9} &&& 3794.4 (1079) 6963.79199 & 2549.3 (721) 6975.22091 & 4469.6 (1249) 6945.46938 & 8919.4 (2375) 6858.03529 \\
\textbf{1.0} & 4164.4 (1215) 6927.53288 &&&& 3618.7 (1019) 6898.56773 &\\
\bottomrule
\end{tabularx}
\label{tab:pc_pm_results_100}
\end{table}
\begin{table}[h!]
\centering
\small
\caption{Результаты для $N = 500$}
\begin{tabularx}{\linewidth}{l *{6}{Y}}
\toprule
$\mathbf{P_c \;\backslash\; P_m}$ & \textbf{0.050} & \textbf{0.200} & \textbf{0.300} & \textbf{0.400} & \textbf{0.500} & \textbf{0.800} \\
\midrule
\textbf{0.5} & 11709.8 (782) 6994.15844 & \textcolor{magenta}{\textbf{5232.3 (341)}} 6957.38204 & 10676.9 (674) 6980.66167 & 6849.7 (430) 6782.99526 && 13051.6 (775) 6880.72481 \\
\textbf{0.6} & 7193.3 (461) 6960.64487 &&& 14856.5 (866) 6941.77959 & 12944.9 (776) 6958.57319 & 19051.6 (1102) 6951.30787 \\
\textbf{0.7} & 18611.7 (1150) 6810.96744 & 23286.9 (1413) 6895.65139 && 14141.6 (830) 6976.37927 &&\\
\textbf{0.8} & 25456.0 (1556) 6962.40902 && 20592.3 (1223) 6998.71555 &&& 38979.2 (2097) 6842.54074 \\
\textbf{0.9} & 14260.1 (825) 6967.60134 & 26692.6 (1551) 6922.32909 &&& 29235.4 (1644) \textcolor{magenta}{\textbf{6667.02991}} & 41352.0 (2252) 6765.87009 \\
\textbf{1.0} & 34026.1 (1996) 6953.24255 &&&&&\\
\bottomrule
\end{tabularx}
\label{tab:pc_pm_results_500}
\end{table}
\newpage
\phantom{text}
\newpage
\phantom{text}
\newpage
\phantom{text}
\subsection{Анализ результатов}
Наилучшее найденное решение составило \textbf{6667.03} при параметрах $N=500$, $P_c=0.9$, $P_m=0.5$ за 1644 поколения. Это всего на \textbf{0.12\%} хуже оптимального значения 6659, что демонстрирует высокую эффективность алгоритма. Наихудшие результаты показала конфигурация с $N=10$, $P_c=0.7$, $P_m=0.3$ (лучший фитнес 6796.98), что на 2.07\% хуже оптимума. Малый размер популяции в 10 особей оказался недостаточным для стабильного поиска качественных решений — более половины конфигураций при $N=10$ вообще не нашли решение за 2500 поколений.
Наиболее быстрая конфигурация — $N=10$, $P_c=0.7$, $P_m=0.5$ — нашла решение за \textbf{201 мс} (503 поколения). Однако качество решения при таких параметрах нестабильно. Среди конфигураций с большой популяцией лучшее время показала $N=500$, $P_c=0.5$, $P_m=0.2$\textbf{5232 мс} (341 поколение), что является оптимальным балансом скорости и качества для больших популяций.
С ростом размера популяции наблюдается явное улучшение качества решений: при $N=10$ лучший результат 6762.97, при $N=500$ — 6667.03. Одновременно количество необходимых поколений снижается (с 503 до 341), но общее время выполнения растет линейно из-за увеличения числа особей в каждом поколении. Этот эффект объясняется тем, что большая популяция обеспечивает большее генетическое разнообразие, позволяя алгоритму быстрее находить оптимальные решения.
Что касается вероятности кроссовера, средние значения $P_c=0.6$--$0.8$ показывают стабильные результаты для всех размеров популяций. Экстремальные значения ($P_c=0.9$ или $1.0$) работают хорошо только при больших популяциях ($N \geq 100$), при малых — часто приводят к преждевременной сходимости (наблюдается много прочерков в таблицах). Это связано с тем, что высокая вероятность кроссовера при малой популяции быстро приводит к гомогенизации генофонда.
Анализ влияния вероятности мутации показал, что низкие значения $P_m=0.05$ неэффективны для малых популяций — недостаточно разнообразия для выхода из локальных минимумов. Умеренные значения $P_m=0.2$--$0.5$ демонстрируют лучшие результаты, обеспечивая баланс между эксплуатацией найденных решений и исследованием нового пространства поиска. Высокое значение $P_m=0.8$ часто приводит к расхождению алгоритма, так как слишком сильные изменения разрушают хорошие решения быстрее, чем алгоритм успевает их найти (многие конфигурации не нашли решение за отведенное время).
\newpage
\section{Ответ на контрольный вопрос}
\textbf{Вопрос}: Тур в порядковом представлении, используемые кроссинговеры.
\textbf{Ответ}: Тур представляется списком из $N$ позиций; $i$-й элемент равен индексу города в текущем упорядоченном списке доступных городов. Например, при опорном списке $C=(1\;2\;3\;4\;5\;6\;7\;8\;9)$ тур $1\!\to\!2\!\to\!4\!\to\!3\!\to\!8\!\to\!5\!\to\!9\!\to\!6\!\to\!7$ кодируется как $l=(1\;1\;2\;1\;4\;1\;3\;1\;1)$, последовательно «выбирая» элементы из $C$.
Для порядкового представления корректность потомков обеспечивает классический одноточечный кроссовер: любые два родителя, разрезанные в одной позиции и склеенные, порождают допустимых потомков (поскольку выбор «по индексу» в оставшемся списке городов остаётся корректным).
\newpage
\section*{Заключение}
\addcontentsline{toc}{section}{Заключение}
В ходе третьей лабораторной работы была успешно решена задача коммивояжера с использованием генетических алгоритмов для 38 городов Джибути:
\begin{enumerate}
\item Изучен теоретический материал о представлениях туров (соседское, порядковое, путевое) и специализированных операторах кроссинговера и мутации для задачи коммивояжера;
\item Создана программная библиотека на языке Python с реализацией путевого представления хромосом, операторов PMX, OX и CX для кроссинговера, операторов swap, inversion и insertion для мутации, а также селекции методом рулетки с поддержкой элитизма;
\item Проведено исследование влияния параметров генетического алгоритма на качество и скорость нахождения решения для популяций размером 10, 50, 100 и 500 особей с различными значениями вероятностей кроссинговера и мутации;
\item Получено решение с длиной маршрута 6667.03, отклоняющееся от оптимального значения 6659 всего на 0.12\%.
\end{enumerate}
\newpage
\section*{Список литературы}
\addcontentsline{toc}{section}{Список литературы}
\vspace{-1.5cm}
\begin{thebibliography}{0}
\bibitem{vostrov}
Методические указания по выполнению лабораторных работ к курсу «Генетические алгоритмы», 119 стр.
\end{thebibliography}
\end{document}

1
lab4/.python-version Normal file
View File

@@ -0,0 +1 @@
3.14

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)

0
lab4/gp/__init__.py Normal file
View File

87
lab4/gp/chromosome.py Normal file
View File

@@ -0,0 +1,87 @@
import random
from typing import Sequence
from .node import Node
from .primitive import Primitive
class Chromosome:
def __init__(
self,
terminals: Sequence[Primitive],
operations: Sequence[Primitive],
root: Node,
):
self.terminals = terminals
self.operations = operations
self.root = root
def copy(self) -> Chromosome:
return Chromosome(self.terminals, self.operations, self.root.copy_subtree())
def prune(self, max_depth: int) -> None:
self.root.prune(self.terminals, max_depth)
def __str__(self) -> str:
"""Строковое представление хромосомы в виде формулы в инфиксной форме."""
return str(self.root)
@classmethod
def full_init(
cls,
terminals: Sequence[Primitive],
operations: Sequence[Primitive],
max_depth: int,
) -> Chromosome:
"""Полная инициализация.
В полном методе при генерации дерева, пока не достигнута максимальная глубина,
допускается выбор только функциональных символов, а на последнем уровне
(максимальной глубины) выбираются только терминальные символы.
"""
def build(level: int) -> Node:
# Если достигнута максимальная глубина — выбираем терминал
if level == max_depth:
return Node(random.choice(terminals))
# Иначе выбираем операцию и создаём потомков
op = random.choice(operations)
node = Node(op)
for _ in range(op.arity):
node.add_child(build(level + 1))
return node
return cls(terminals, operations, build(1))
@classmethod
def grow_init(
cls,
terminals: Sequence[Primitive],
operations: Sequence[Primitive],
max_depth: int,
# min_depth: int, # ???
terminal_probability: float = 0.5,
) -> Chromosome:
"""Растущая инициализация.
В растущей инициализации генерируются нерегулярные деревья с различной глубиной
листьев вследствие случайного на каждом шаге выбора функционального
или терминального символа. Здесь при выборе терминального символа рост дерева
прекращается по текущей ветви и поэтому дерево имеет нерегулярную структуру.
"""
def build(level: int) -> Node:
# Если достигнута максимальная глубина, либо сыграла заданная вероятность
# — выбираем терминал
if level == max_depth or random.random() < terminal_probability:
return Node(random.choice(terminals))
# Иначе выбираем случайную операцию и создаём потомков
op = random.choice(operations)
node = Node(op)
for _ in range(op.arity):
node.add_child(build(level + 1))
return node
return cls(terminals, operations, build(1))

31
lab4/gp/crossovers.py Normal file
View File

@@ -0,0 +1,31 @@
import random
from .chromosome import Chromosome
from .node import swap_subtrees
def crossover_subtree(
parent1: Chromosome, parent2: Chromosome, max_depth: int
) -> tuple[Chromosome, Chromosome]:
"""Кроссовер поддеревьев.
Выбираются случайные узлы в каждом родителе, затем соответствующие им поддеревья
меняются местами. Если глубина результирующих хромосом превышает max_depth,
то их деревья обрезаются до max_depth.
"""
child1 = parent1.copy()
child2 = parent2.copy()
# Выбираем случайные узлы, не включая корень
if child1.root.get_depth() <= 1 or child2.root.get_depth() <= 1:
return child1, child2
cut1 = random.choice(child1.root.list_nodes()[1:])
cut2 = random.choice(child2.root.list_nodes()[1:])
swap_subtrees(cut1, cut2)
child1.prune(max_depth)
child2.prune(max_depth)
return child1, child2

133
lab4/gp/fitness.py Normal file
View File

@@ -0,0 +1,133 @@
from abc import ABC, abstractmethod
from typing import Callable
import numpy as np
from numpy.typing import NDArray
from .chromosome import Chromosome
type FitnessFn = Callable[
[
Chromosome,
NDArray[np.float64],
Callable[[NDArray[np.float64]], NDArray[np.float64]],
],
float,
]
type TargetFunction = Callable[[NDArray[np.float64]], NDArray[np.float64]]
type TestPointsFn = Callable[[], NDArray[np.float64]]
class BaseFitness(ABC):
def __init__(self, target_fn: TargetFunction, test_points_fn: TestPointsFn):
self.target_function = target_fn
self.test_points_fn = test_points_fn
@abstractmethod
def fitness_fn(
self,
chromosome: Chromosome,
predicted: NDArray[np.float64],
true_values: NDArray[np.float64],
) -> float: ...
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)
class MSEFitness(BaseFitness):
"""Среднеквадратичная ошибка"""
def fitness_fn(
self,
chromosome: Chromosome,
predicted: NDArray[np.float64],
true_values: NDArray[np.float64],
) -> float:
return float(np.mean((predicted - true_values) ** 2))
class RMSEFitness(BaseFitness):
"""Корень из среднеквадратичной ошибки"""
def fitness_fn(
self,
chromosome: Chromosome,
predicted: NDArray[np.float64],
true_values: NDArray[np.float64],
) -> float:
return float(np.sqrt(np.mean((predicted - true_values) ** 2)))
class MAEFitness(BaseFitness):
"""Средняя абсолютная ошибка"""
def fitness_fn(
self,
chromosome: Chromosome,
predicted: NDArray[np.float64],
true_values: NDArray[np.float64],
) -> float:
return float(np.mean(np.abs(predicted - true_values)))
class NRMSEFitness(BaseFitness):
"""Нормализованный RMSE (масштаб-инвариантен)"""
def fitness_fn(
self,
chromosome: Chromosome,
predicted: NDArray[np.float64],
true_values: NDArray[np.float64],
) -> float:
denom = np.std(true_values)
if denom == 0:
return 1e6
return float(np.sqrt(np.mean((predicted - true_values) ** 2)) / denom)
class PenalizedFitness(BaseFitness):
"""Фитнес со штрафом за размер и глубину дерева: ошибка + λ * (размер + depth_weight * глубина)"""
def __init__(
self,
target_fn: TargetFunction,
test_points_fn: TestPointsFn,
base_fitness: BaseFitness,
lambda_: float = 0.001,
depth_weight: float = 0.2,
scale_penalty: bool | None = None,
):
super().__init__(target_fn, test_points_fn)
self.base_fitness = base_fitness
self.lambda_ = lambda_
self.depth_weight = depth_weight
# Масштабировать штраф необязательно, если функция фитнеса нормализована
if scale_penalty is None:
scale_penalty = not isinstance(base_fitness, NRMSEFitness)
self.scale_penalty = scale_penalty
def fitness_fn(
self,
chromosome: Chromosome,
predicted: NDArray[np.float64],
true_values: NDArray[np.float64],
) -> float:
base = self.base_fitness.fitness_fn(chromosome, predicted, true_values)
size = chromosome.root.get_size()
depth = chromosome.root.get_depth()
penalty = self.lambda_ * (size + self.depth_weight * depth)
if self.scale_penalty:
penalty *= base
return float(base + penalty)

382
lab4/gp/ga.py Normal file
View File

@@ -0,0 +1,382 @@
import os
import random
import shutil
import time
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 .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], Chromosome]
type SelectionFn = Callable[[Population, Fitnesses], Population]
@dataclass(frozen=True)
class GARunConfig:
fitness_func: FitnessFn
crossover_fn: CrossoverFn
mutation_fn: MutationFn
selection_fn: SelectionFn
init_population: Population
pc: float # вероятность кроссинговера
pm: float # вероятность мутации
max_generations: int # максимальное количество поколений
elitism: int = (
0 # сколько лучших особей перенести без изменения в следующее поколение
)
max_best_repetitions: int | None = (
None # остановка при повторении лучшего результата
)
seed: int | None = None # seed для генератора случайных чисел
minimize: bool = True # если True, ищем минимум вместо максимума
save_generations: list[int] | None = (
None # индексы поколений для сохранения графиков
)
results_dir: str = "results" # папка для сохранения графиков
fitness_avg_threshold: float | None = (
None # порог среднего значения фитнес функции для остановки
)
best_value_threshold: float | None = (
None # остановка при достижении значения фитнеса лучше заданного
)
log_every_generation: bool = False # логировать каждое поколение
def save(self, filename: str = "GARunConfig.txt"):
"""Сохраняет конфиг в results_dir."""
os.makedirs(self.results_dir, exist_ok=True)
path = os.path.join(self.results_dir, filename)
with open(path, "w", encoding="utf-8") as f:
for k, v in asdict(self).items():
f.write(f"{k}: {v}\n")
@dataclass(frozen=True)
class Generation:
number: int
best: Chromosome
best_fitness: float
avg_fitness: float
population: Population
fitnesses: Fitnesses
@dataclass(frozen=True)
class GARunResult:
generations_count: int
best_generation: Generation
history: list[Generation]
time_ms: float
def save(self, path: str, filename: str = "GARunResult.txt"):
"""Сохраняет конфиг в results_dir."""
os.makedirs(path, exist_ok=True)
path = os.path.join(path, filename)
with open(path, "w", encoding="utf-8") as f:
for k, v in asdict(self).items():
if k == "history":
continue
if k == "best_generation":
f.write(
f"{k}: Number: {v['number']}, Best Fitness: {v['best_fitness']}, Best: {v['best']}\n"
)
else:
f.write(f"{k}: {v}\n")
def crossover(
population: Population,
pc: float,
crossover_fn: CrossoverFn,
) -> Population:
"""Оператор кроссинговера (скрещивания) выполняется с заданной вероятностью pc.
Две хромосомы (родители) выбираются случайно из промежуточной популяции.
Если популяция нечетного размера, то последняя хромосома скрещивается со случайной
другой хромосомой из популяции. В таком случае одна из хромосом может поучаствовать
в кроссовере дважды.
"""
# Создаем копию популяции и перемешиваем её для случайного выбора пар
shuffled_population = population.copy()
random.shuffle(shuffled_population)
next_population = []
pop_size = len(shuffled_population)
for i in range(0, pop_size, 2):
p1 = shuffled_population[i]
p2 = shuffled_population[(i + 1) % pop_size]
if np.random.random() <= pc:
p1, p2 = crossover_fn(p1, p2)
next_population.append(p1)
next_population.append(p2)
return next_population[:pop_size]
def mutation(
population: Population, pm: float, gen_num: int, mutation_fn: MutationFn
) -> Population:
"""Мутация происходит с вероятностью pm."""
next_population = []
for chrom in population:
next_population.append(
mutation_fn(chrom) if np.random.random() <= pm else chrom
)
return next_population
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 eval_population(population: Population, fitness_func: FitnessFn) -> Fitnesses:
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)
# Создаем граф для визуализации дерева
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}"
filepath = os.path.join(config.results_dir, filename)
dot.render(filepath, format="png", cleanup=True)
def genetic_algorithm(config: GARunConfig) -> GARunResult:
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)
population = config.init_population
start = time.perf_counter()
history: list[Generation] = []
best: Generation | None = None
generation_number = 1
best_repetitions = 0
while True:
# Вычисляем фитнес для всех особей в популяции
fitnesses = eval_population(population, config.fitness_func)
# Сохраняем лучших особей для переноса в следующее поколение
elites: list[Chromosome] = []
if config.elitism:
elites = deepcopy(
[
population[i]
for i in sorted(
range(len(fitnesses)),
key=lambda i: fitnesses[i],
reverse=not config.minimize,
)
][: config.elitism]
)
# Находим лучшую особь в поколении
best_index = (
int(np.argmin(fitnesses)) if config.minimize else int(np.argmax(fitnesses))
)
# Добавляем эпоху в историю
current = Generation(
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),
fitnesses=np.array([]),
)
history.append(current)
if config.log_every_generation:
print(
f"Generation #{generation_number} best: {current.best_fitness},"
f" avg: {np.mean(fitnesses)}"
)
# Обновляем лучшую эпоху
if (
best is None
or (config.minimize and current.best_fitness < best.best_fitness)
or (not config.minimize and current.best_fitness > best.best_fitness)
):
best = current
# Проверка критериев остановки
stop_algorithm = False
if generation_number >= config.max_generations:
stop_algorithm = True
if config.max_best_repetitions is not None and generation_number > 1:
if history[-2].best_fitness == current.best_fitness:
best_repetitions += 1
if best_repetitions == config.max_best_repetitions:
stop_algorithm = True
else:
best_repetitions = 0
if config.best_value_threshold is not None:
if (
config.minimize and current.best_fitness < config.best_value_threshold
) or (
not config.minimize
and current.best_fitness > config.best_value_threshold
):
stop_algorithm = True
if config.fitness_avg_threshold is not None:
mean_fitness = np.mean(fitnesses)
if (config.minimize and mean_fitness < config.fitness_avg_threshold) or (
not config.minimize and mean_fitness > config.fitness_avg_threshold
):
stop_algorithm = True
# Сохраняем указанные поколения и последнее поколение
if config.save_generations and (
stop_algorithm or generation_number in config.save_generations
):
save_generation(current, history, config)
if stop_algorithm:
break
# селекция (для минимума инвертируем знак)
parents = config.selection_fn(
population, fitnesses if not config.minimize else -fitnesses
)
# кроссинговер попарно
next_population = crossover(parents, config.pc, config.crossover_fn)
# мутация
next_population = mutation(
next_population,
config.pm,
generation_number,
config.mutation_fn,
)
# Вставляем элиту в новую популяцию
population = next_population[: len(population) - config.elitism] + elites
generation_number += 1
end = time.perf_counter()
assert best is not None, "Best was never set"
result = GARunResult(
len(history),
best,
history,
(end - start) * 1000.0,
)
# Автоматически строим графики истории фитнеса
if config.save_generations:
plot_fitness_history(result, save_dir=config.results_dir)
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 = [gen.avg_fitness for gen in result.history]
# График лучших значений
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)
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_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)

131
lab4/gp/mutations.py Normal file
View File

@@ -0,0 +1,131 @@
import random
from abc import ABC, abstractmethod
from typing import Sequence
from .chromosome import 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):
"""Усекающая мутация. Заменяет случайно выбранную операцию на случайный терминал."""
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)
return chromosome
class GrowMutation(BaseMutation):
"""Растущая мутация. Заменяет случайно выбранный узел на случайное поддерево."""
def __init__(self, max_depth: int):
self.max_depth = max_depth
def mutate(self, chromosome: Chromosome) -> Chromosome:
target_node = random.choice(chromosome.root.list_nodes())
max_subtree_depth = self.max_depth - target_node.get_level() + 1
subtree = Chromosome.grow_init(
chromosome.terminals, chromosome.operations, max_subtree_depth
).root
if target_node.parent:
target_node.parent.replace_child(target_node, subtree)
else:
chromosome.root = subtree
return chromosome
class NodeReplacementMutation(BaseMutation):
"""Мутация замены операции (Node Replacement Mutation).
Выбирает случайный узел и заменяет его
на случайную другую операцию той же арности или терминал, сохраняя поддеревья.
Если подходящей альтернативы нет — возвращает копию без изменений.
"""
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
return chromosome
class HoistMutation(BaseMutation):
def mutate(self, chromosome: Chromosome) -> Chromosome:
"""Hoist-мутация (анти-bloat).
Выбирает случайное поддерево, затем внутри него — случайное поддерево меньшей
глубины, и заменяет исходное поддерево на это внутреннее.
В результате дерево становится короче, сохраняя часть структуры.
"""
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
class CombinedMutation(BaseMutation):
"""Комбинированная мутация.
Принимает список (или словарь) мутаций и случайно выбирает одну из них
для применения. Можно задать веса вероятностей.
"""
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
def mutate(self, chromosome: Chromosome) -> Chromosome:
mutation = random.choices(self.mutations, weights=self.probs, k=1)[0]
return mutation(chromosome)

118
lab4/gp/node.py Normal file
View File

@@ -0,0 +1,118 @@
import random
from typing import Sequence
from .primitive import Primitive
from .types import Context, Value
class Node:
def __init__(self, value: Primitive):
self.value = value
self.parent: Node | None = None
self.children: list[Node] = []
def add_child(self, child: Node) -> None:
self.children.append(child)
child.parent = self
def remove_child(self, child: Node) -> None:
self.children.remove(child)
child.parent = None
def replace_child(self, old_child: Node, new_child: Node) -> None:
self.children[self.children.index(old_child)] = new_child
old_child.parent = None
new_child.parent = self
def remove_children(self) -> None:
for child in self.children:
child.parent = None
self.children = []
def copy_subtree(self) -> Node:
node = Node(self.value)
for child in self.children:
node.add_child(child.copy_subtree())
return node
def list_nodes(self) -> list[Node]:
"""Список всех узлов поддерева, начиная с текущего (aka depth-first-search)."""
nodes: list[Node] = [self]
for child in self.children:
nodes.extend(child.list_nodes())
return nodes
def prune(self, terminals: Sequence[Primitive], max_depth: int) -> None:
"""Усечение поддерева до заданной глубины.
Заменяет операции на глубине max_depth на случайные терминалы.
"""
def prune_recursive(node: Node, current_depth: int) -> None:
if node.value.arity == 0: # Терминалы остаются без изменений
return
if current_depth >= max_depth:
node.remove_children()
node.value = random.choice(terminals)
return
for child in node.children:
prune_recursive(child, current_depth + 1)
prune_recursive(self, 1)
def get_depth(self) -> int:
"""Вычисляет глубину поддерева, начиная с текущего узла."""
return (
max(child.get_depth() for child in self.children) + 1
if self.children
else 1
)
def get_size(self) -> int:
"""Вычисляет размер поддерева, начиная с текущего узла."""
return sum(child.get_size() for child in self.children) + 1
def get_level(self) -> int:
"""Вычисляет уровень узла в дереве (расстояние от корня). Корень имеет уровень 1."""
return self.parent.get_level() + 1 if self.parent else 1
def eval(self, context: Context) -> Value:
return self.value.eval(
[child.eval(context) for child in self.children], context
)
def __str__(self) -> str:
"""Рекурсивный перевод древовидного вида формулы в строку в инфиксной форме."""
if self.value.arity == 0:
return self.value.name
if self.value.arity == 2:
return f"({self.children[0]} {self.value.name} {self.children[1]})"
return f"{self.value.name}({', '.join(str(child) for child in self.children)})"
def to_str_tree(self, prefix="", is_last: bool = True) -> str:
"""Строковое представление древовидной структуры."""
lines = prefix + ("└── " if is_last else "├── ") + self.value.name + "\n"
child_prefix = prefix + (" " if is_last else "")
for i, child in enumerate(self.children):
is_child_last = i == len(self.children) - 1
lines += child.to_str_tree(child_prefix, is_child_last)
return lines
def swap_subtrees(a: Node, b: Node) -> None:
if a.parent is None or b.parent is None:
raise ValueError("Нельзя обменять корни деревьев")
# Сохраняем ссылки на родителей
a_parent = a.parent
b_parent = b.parent
i = a_parent.children.index(a)
j = b_parent.children.index(b)
a_parent.children[i], b_parent.children[j] = b, a
a.parent, b.parent = b_parent, a_parent

63
lab4/gp/ops.py Normal file
View File

@@ -0,0 +1,63 @@
from typing import Callable, Sequence
import numpy as np
from numpy.typing import NDArray
from .primitive import Operation
type Value = NDArray[np.float64]
def make_safe(
fn: Callable[[Sequence[Value]], Value],
) -> Callable[[Sequence[Value]], Value]:
"""Обёртка для стабилизации результатов векторных операций."""
def wrapped(args: Sequence[Value]) -> Value:
with np.errstate(
over="ignore", invalid="ignore", divide="ignore", under="ignore"
):
res = fn(args)
# гарантируем, что на выходе всегда NDArray[np.float64]
if not isinstance(res, np.ndarray):
res = np.array(res, dtype=np.float64)
res = np.nan_to_num(res, nan=0.0, posinf=1e6, neginf=-1e6)
res = np.clip(res, -1e6, 1e6)
return res
return wrapped
# Унарные операции
NEG = Operation("-", 1, make_safe(lambda x: -x[0]))
SIN = Operation("sin", 1, make_safe(lambda x: np.sin(x[0])))
COS = Operation("cos", 1, make_safe(lambda x: np.cos(x[0])))
SQUARE = Operation("pow2", 1, make_safe(lambda x: np.clip(x[0], -1e3, 1e3) ** 2))
EXP = Operation("exp", 1, make_safe(lambda x: np.exp(np.clip(x[0], -10, 10))))
# Бинарные операции
ADD = Operation("+", 2, lambda x: x[0] + x[1])
SUB = Operation("-", 2, lambda x: x[0] - x[1])
MUL = Operation("*", 2, lambda x: x[0] * x[1])
ADD = Operation("+", 2, make_safe(lambda x: x[0] + x[1]))
SUB = Operation("-", 2, make_safe(lambda x: x[0] - x[1]))
MUL = Operation("*", 2, make_safe(lambda x: x[0] * x[1]))
DIV = Operation(
"/",
2,
make_safe(lambda x: np.divide(x[0], np.where(np.abs(x[1]) < 1e-10, 1e-10, x[1]))),
)
POW = Operation(
"^",
2,
make_safe(lambda x: np.power(np.clip(x[0], -1e3, 1e3), np.clip(x[1], -3, 3))),
)

35
lab4/gp/primitive.py Normal file
View File

@@ -0,0 +1,35 @@
from dataclasses import dataclass
from typing import Callable, Sequence
from .types import Context, Value
type OperationFn = Callable[[Sequence[Value]], Value]
@dataclass(frozen=True)
class Primitive:
name: str
arity: int
operation_fn: OperationFn | None
def eval(self, args: Sequence[Value], context: Context) -> Value:
if self.operation_fn is None:
return context[self]
return self.operation_fn(args)
def __post_init__(self) -> None:
if self.arity != 0 and self.operation_fn is None:
raise ValueError("Operation is required for primitive with non-zero arity")
def Var(name: str) -> Primitive:
return Primitive(name=name, arity=0, operation_fn=None)
def Const(name: str, val: Value) -> Primitive:
return Primitive(name=name, arity=0, operation_fn=lambda _args: val)
def Operation(name: str, arity: int, operation_fn: OperationFn) -> Primitive:
return Primitive(name=name, arity=arity, operation_fn=operation_fn)

88
lab4/gp/selection.py Normal file
View File

@@ -0,0 +1,88 @@
import numpy as np
from .types import Fitnesses, Population
def roulette_selection(population: Population, fitnesses: Fitnesses) -> Population:
"""Селекция методом рулетки.
Чем больше значение фитнеса, тем больше вероятность выбора особи. Для минимизации
значения фитнеса нужно предварительно инвертировать.
"""
# Чтобы работать с отрицательными f, сдвигаем значения фитнес функции на минимальное
# значение в популяции. Вычитаем min_fit, т. к. min_fit может быть отрицательным.
min_fit = np.min(fitnesses)
shifted_fitnesses = fitnesses - min_fit + 1e-12
# Получаем вероятности для каждой особи
probs = shifted_fitnesses / np.sum(shifted_fitnesses)
cum = np.cumsum(probs)
# Выбираем особей методом рулетки
selected = []
for _ in population:
r = np.random.random()
idx = int(np.searchsorted(cum, r, side="left"))
selected.append(population[idx])
return selected
def tournament_selection(
population: Population,
fitnesses: Fitnesses,
k: int = 3,
) -> Population:
"""Турнирная селекция.
В каждом турнире случайно выбирается k особей, и побеждает та,
у которой лучшее (наибольшее) значение фитнеса. Для минимизации
значения фитнеса нужно предварительно инвертировать.
Args:
population: список особей (Population)
fitnesses: список или массив фитнесов (Fitnesses)
k: размер турнира
Returns:
Новая популяция того же размера
"""
size = len(population)
selected = []
for _ in range(size):
idxs = np.random.choice(size, size=k, replace=False)
fits = fitnesses[idxs]
winner_idx = idxs[np.argmax(fits)]
selected.append(population[winner_idx])
return selected
def stochastic_tournament_selection(
population: Population,
fitnesses: Fitnesses,
k: int = 3,
p_best: float = 0.75,
) -> Population:
"""Стохастическая турнирная селекция.
Побеждает лучший в турнире с вероятностью p_best, иначе выбирается
случайный участник турнира.
"""
size = len(population)
selected = []
for _ in range(size):
idxs = np.random.choice(size, size=k, replace=False)
fits = fitnesses[idxs]
order = np.argsort(-fits)
if np.random.random() < p_best:
winner_idx = idxs[order[0]]
else:
winner_idx = np.random.choice(idxs[1:]) if k > 1 else idxs[0]
selected.append(population[winner_idx])
return selected

18
lab4/gp/types.py Normal file
View File

@@ -0,0 +1,18 @@
from typing import TYPE_CHECKING, Callable, Protocol
import numpy as np
from numpy.typing import NDArray
if TYPE_CHECKING:
from .chromosome import Chromosome
from .node import Node
from .primitive import Primitive
type Population = list[Chromosome]
type Fitnesses = NDArray[np.float64]
type InitFunc = Callable[[Chromosome], Node]
type Value = NDArray[np.float64]
class Context(Protocol):
def __getitem__(self, key: Primitive, /) -> Value: ...

29
lab4/gp/utils.py Normal file
View File

@@ -0,0 +1,29 @@
from typing import Sequence
from .chromosome import Chromosome
from .primitive import Primitive
from .types import Population
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

108
lab4/main.py Normal file
View File

@@ -0,0 +1,108 @@
"""
graphviz должен быть доступен в PATH (недостаточно просто установить через pip)
Можно проверить командой
dot -V
"""
import random
import numpy as np
from numpy.typing import NDArray
from gp.crossovers import crossover_subtree
from gp.fitness import (
MAEFitness,
MSEFitness,
NRMSEFitness,
RMSEFitness,
)
from gp.ga import GARunConfig, genetic_algorithm
from gp.mutations import (
CombinedMutation,
GrowMutation,
HoistMutation,
NodeReplacementMutation,
ShrinkMutation,
)
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
MAX_DEPTH = 10
MAX_GENERATIONS = 200
SEED = 17
np.random.seed(SEED)
random.seed(SEED)
X = np.random.uniform(-5.536, 5.536, size=(TEST_POINTS, NUM_VARS))
operations = [SQUARE, SIN, COS, EXP, ADD, SUB, MUL, DIV, POW]
terminals = [Var(f"x{i}") for i in range(1, NUM_VARS + 1)]
def target_function(x: NDArray[np.float64]) -> NDArray[np.float64]:
"""
Векторизованная версия функции:
f(x) = sum_{i=1}^n sum_{j=1}^i x_j^2
x имеет форму (n_samples, n_vars)
"""
# Префиксные суммы квадратов по оси переменных
x_sq = x**2
prefix_sums = np.cumsum(x_sq, axis=1)
# Суммируем по i (ось 1)
return np.sum(prefix_sums, axis=1)
fitness_function = RMSEFitness(target_function, lambda: X)
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
)
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=combined_mutation,
selection_fn=lambda p, f: tournament_selection(p, f, k=3),
init_population=init_population,
seed=SEED,
pc=0.85,
pm=0.15,
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)
# Выводим результаты
print(f"Лучшая особь: {result.best_generation.best}")
print(result.best_generation.best.root.to_str_tree())
print(f"Лучшее значение фитнеса: {result.best_generation.best_fitness:.6f}")
print(f"Количество поколений: {result.generations_count}")
print(f"Время выполнения: {result.time_ms:.2f} мс")
print("Population size:", len(init_population))
mse_fitness = MSEFitness(target_function, lambda: X)
print(f"MSE: {mse_fitness(result.best_generation.best):.6f}")
rmse_fitness = RMSEFitness(target_function, lambda: X)
print(f"RMSE: {rmse_fitness(result.best_generation.best):.6f}")
mae_fitness = MAEFitness(target_function, lambda: X)
print(f"MAE: {mae_fitness(result.best_generation.best):.6f}")
nrmse_fitness = NRMSEFitness(target_function, lambda: X)
print(f"NRMSE: {nrmse_fitness(result.best_generation.best):.6f}")

BIN
lab4/original_tree.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

12
lab4/pyproject.toml Normal file
View File

@@ -0,0 +1,12 @@
[project]
name = "lab4"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
"graphviz>=0.21",
"matplotlib>=3.10.7",
"numpy>=2.3.4",
]
[tool.ruff]
target-version = "py314"

26
lab4/pytest.ini Normal file
View File

@@ -0,0 +1,26 @@
[tool:pytest]
# Пути для поиска тестов
testpaths = tests
# Паттерны для имён файлов с тестами
python_files = test_*.py
# Паттерны для имён классов с тестами
python_classes = Test*
# Паттерны для имён функций-тестов
python_functions = test_*
# Опции для более подробного вывода
addopts =
-v
--strict-markers
--tb=short
--disable-warnings
# Маркеры для категоризации тестов
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
unit: unit tests
integration: integration tests

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}

265
lab4/uv.lock generated Normal file
View File

@@ -0,0 +1,265 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "contourpy"
version = "1.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" },
{ url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" },
{ url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" },
{ url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" },
{ url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" },
{ url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" },
{ url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" },
{ url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" },
{ url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" },
{ url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" },
{ url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" },
{ url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" },
{ url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" },
{ url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" },
{ url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" },
{ url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" },
{ url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" },
{ url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" },
{ url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" },
{ url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" },
{ url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" },
]
[[package]]
name = "cycler"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
]
[[package]]
name = "fonttools"
version = "4.60.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4b/42/97a13e47a1e51a5a7142475bbcf5107fe3a68fc34aef331c897d5fb98ad0/fonttools-4.60.1.tar.gz", hash = "sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9", size = 3559823, upload-time = "2025-09-29T21:13:27.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/83/752ca11c1aa9a899b793a130f2e466b79ea0cf7279c8d79c178fc954a07b/fonttools-4.60.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a884aef09d45ba1206712c7dbda5829562d3fea7726935d3289d343232ecb0d3", size = 2822830, upload-time = "2025-09-29T21:12:24.406Z" },
{ url = "https://files.pythonhosted.org/packages/57/17/bbeab391100331950a96ce55cfbbff27d781c1b85ebafb4167eae50d9fe3/fonttools-4.60.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8a44788d9d91df72d1a5eac49b31aeb887a5f4aab761b4cffc4196c74907ea85", size = 2345524, upload-time = "2025-09-29T21:12:26.819Z" },
{ url = "https://files.pythonhosted.org/packages/3d/2e/d4831caa96d85a84dd0da1d9f90d81cec081f551e0ea216df684092c6c97/fonttools-4.60.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e852d9dda9f93ad3651ae1e3bb770eac544ec93c3807888798eccddf84596537", size = 4843490, upload-time = "2025-09-29T21:12:29.123Z" },
{ url = "https://files.pythonhosted.org/packages/49/13/5e2ea7c7a101b6fc3941be65307ef8df92cbbfa6ec4804032baf1893b434/fonttools-4.60.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:154cb6ee417e417bf5f7c42fe25858c9140c26f647c7347c06f0cc2d47eff003", size = 4944184, upload-time = "2025-09-29T21:12:31.414Z" },
{ url = "https://files.pythonhosted.org/packages/0c/2b/cf9603551c525b73fc47c52ee0b82a891579a93d9651ed694e4e2cd08bb8/fonttools-4.60.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5664fd1a9ea7f244487ac8f10340c4e37664675e8667d6fee420766e0fb3cf08", size = 4890218, upload-time = "2025-09-29T21:12:33.936Z" },
{ url = "https://files.pythonhosted.org/packages/fd/2f/933d2352422e25f2376aae74f79eaa882a50fb3bfef3c0d4f50501267101/fonttools-4.60.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583b7f8e3c49486e4d489ad1deacfb8d5be54a8ef34d6df824f6a171f8511d99", size = 4999324, upload-time = "2025-09-29T21:12:36.637Z" },
{ url = "https://files.pythonhosted.org/packages/38/99/234594c0391221f66216bc2c886923513b3399a148defaccf81dc3be6560/fonttools-4.60.1-cp314-cp314-win32.whl", hash = "sha256:66929e2ea2810c6533a5184f938502cfdaea4bc3efb7130d8cc02e1c1b4108d6", size = 2220861, upload-time = "2025-09-29T21:12:39.108Z" },
{ url = "https://files.pythonhosted.org/packages/3e/1d/edb5b23726dde50fc4068e1493e4fc7658eeefcaf75d4c5ffce067d07ae5/fonttools-4.60.1-cp314-cp314-win_amd64.whl", hash = "sha256:f3d5be054c461d6a2268831f04091dc82753176f6ea06dc6047a5e168265a987", size = 2270934, upload-time = "2025-09-29T21:12:41.339Z" },
{ url = "https://files.pythonhosted.org/packages/fb/da/1392aaa2170adc7071fe7f9cfd181a5684a7afcde605aebddf1fb4d76df5/fonttools-4.60.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:b6379e7546ba4ae4b18f8ae2b9bc5960936007a1c0e30b342f662577e8bc3299", size = 2894340, upload-time = "2025-09-29T21:12:43.774Z" },
{ url = "https://files.pythonhosted.org/packages/bf/a7/3b9f16e010d536ce567058b931a20b590d8f3177b2eda09edd92e392375d/fonttools-4.60.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9d0ced62b59e0430b3690dbc5373df1c2aa7585e9a8ce38eff87f0fd993c5b01", size = 2375073, upload-time = "2025-09-29T21:12:46.437Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b5/e9bcf51980f98e59bb5bb7c382a63c6f6cac0eec5f67de6d8f2322382065/fonttools-4.60.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:875cb7764708b3132637f6c5fb385b16eeba0f7ac9fa45a69d35e09b47045801", size = 4849758, upload-time = "2025-09-29T21:12:48.694Z" },
{ url = "https://files.pythonhosted.org/packages/e3/dc/1d2cf7d1cba82264b2f8385db3f5960e3d8ce756b4dc65b700d2c496f7e9/fonttools-4.60.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a184b2ea57b13680ab6d5fbde99ccef152c95c06746cb7718c583abd8f945ccc", size = 5085598, upload-time = "2025-09-29T21:12:51.081Z" },
{ url = "https://files.pythonhosted.org/packages/5d/4d/279e28ba87fb20e0c69baf72b60bbf1c4d873af1476806a7b5f2b7fac1ff/fonttools-4.60.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:026290e4ec76583881763fac284aca67365e0be9f13a7fb137257096114cb3bc", size = 4957603, upload-time = "2025-09-29T21:12:53.423Z" },
{ url = "https://files.pythonhosted.org/packages/78/d4/ff19976305e0c05aa3340c805475abb00224c954d3c65e82c0a69633d55d/fonttools-4.60.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0e8817c7d1a0c2eedebf57ef9a9896f3ea23324769a9a2061a80fe8852705ed", size = 4974184, upload-time = "2025-09-29T21:12:55.962Z" },
{ url = "https://files.pythonhosted.org/packages/63/22/8553ff6166f5cd21cfaa115aaacaa0dc73b91c079a8cfd54a482cbc0f4f5/fonttools-4.60.1-cp314-cp314t-win32.whl", hash = "sha256:1410155d0e764a4615774e5c2c6fc516259fe3eca5882f034eb9bfdbee056259", size = 2282241, upload-time = "2025-09-29T21:12:58.179Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cb/fa7b4d148e11d5a72761a22e595344133e83a9507a4c231df972e657579b/fonttools-4.60.1-cp314-cp314t-win_amd64.whl", hash = "sha256:022beaea4b73a70295b688f817ddc24ed3e3418b5036ffcd5658141184ef0d0c", size = 2345760, upload-time = "2025-09-29T21:13:00.375Z" },
{ 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"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806, upload-time = "2025-08-10T21:27:01.537Z" },
{ url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605, upload-time = "2025-08-10T21:27:03.335Z" },
{ url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925, upload-time = "2025-08-10T21:27:04.339Z" },
{ url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414, upload-time = "2025-08-10T21:27:05.437Z" },
{ url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272, upload-time = "2025-08-10T21:27:07.063Z" },
{ url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578, upload-time = "2025-08-10T21:27:08.452Z" },
{ url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607, upload-time = "2025-08-10T21:27:10.125Z" },
{ url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150, upload-time = "2025-08-10T21:27:11.484Z" },
{ url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979, upload-time = "2025-08-10T21:27:12.917Z" },
{ url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456, upload-time = "2025-08-10T21:27:14.353Z" },
{ url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621, upload-time = "2025-08-10T21:27:15.808Z" },
{ url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417, upload-time = "2025-08-10T21:27:17.436Z" },
{ url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582, upload-time = "2025-08-10T21:27:18.436Z" },
{ url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514, upload-time = "2025-08-10T21:27:19.465Z" },
{ url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905, upload-time = "2025-08-10T21:27:20.51Z" },
{ url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399, upload-time = "2025-08-10T21:27:21.496Z" },
{ url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197, upload-time = "2025-08-10T21:27:22.604Z" },
{ url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125, upload-time = "2025-08-10T21:27:24.036Z" },
{ url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612, upload-time = "2025-08-10T21:27:25.773Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990, upload-time = "2025-08-10T21:27:27.089Z" },
{ url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601, upload-time = "2025-08-10T21:27:29.343Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041, upload-time = "2025-08-10T21:27:30.754Z" },
{ url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897, upload-time = "2025-08-10T21:27:32.803Z" },
{ url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" },
{ url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" },
{ url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" },
]
[[package]]
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" },
]
[[package]]
name = "matplotlib"
version = "3.10.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "contourpy" },
{ name = "cycler" },
{ name = "fonttools" },
{ name = "kiwisolver" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pillow" },
{ name = "pyparsing" },
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/e2/d2d5295be2f44c678ebaf3544ba32d20c1f9ef08c49fe47f496180e1db15/matplotlib-3.10.7.tar.gz", hash = "sha256:a06ba7e2a2ef9131c79c49e63dad355d2d878413a0376c1727c8b9335ff731c7", size = 34804865, upload-time = "2025-10-09T00:28:00.669Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/4b/e5bc2c321b6a7e3a75638d937d19ea267c34bd5a90e12bee76c4d7c7a0d9/matplotlib-3.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d883460c43e8c6b173fef244a2341f7f7c0e9725c7fe68306e8e44ed9c8fb100", size = 8273787, upload-time = "2025-10-09T00:27:23.27Z" },
{ url = "https://files.pythonhosted.org/packages/86/ad/6efae459c56c2fbc404da154e13e3a6039129f3c942b0152624f1c621f05/matplotlib-3.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07124afcf7a6504eafcb8ce94091c5898bbdd351519a1beb5c45f7a38c67e77f", size = 8131348, upload-time = "2025-10-09T00:27:24.926Z" },
{ url = "https://files.pythonhosted.org/packages/a6/5a/a4284d2958dee4116359cc05d7e19c057e64ece1b4ac986ab0f2f4d52d5a/matplotlib-3.10.7-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c17398b709a6cce3d9fdb1595c33e356d91c098cd9486cb2cc21ea2ea418e715", size = 9533949, upload-time = "2025-10-09T00:27:26.704Z" },
{ url = "https://files.pythonhosted.org/packages/de/ff/f3781b5057fa3786623ad8976fc9f7b0d02b2f28534751fd5a44240de4cf/matplotlib-3.10.7-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7146d64f561498764561e9cd0ed64fcf582e570fc519e6f521e2d0cfd43365e1", size = 9804247, upload-time = "2025-10-09T00:27:28.514Z" },
{ url = "https://files.pythonhosted.org/packages/47/5a/993a59facb8444efb0e197bf55f545ee449902dcee86a4dfc580c3b61314/matplotlib-3.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90ad854c0a435da3104c01e2c6f0028d7e719b690998a2333d7218db80950722", size = 9595497, upload-time = "2025-10-09T00:27:30.418Z" },
{ url = "https://files.pythonhosted.org/packages/0d/a5/77c95aaa9bb32c345cbb49626ad8eb15550cba2e6d4c88081a6c2ac7b08d/matplotlib-3.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:4645fc5d9d20ffa3a39361fcdbcec731382763b623b72627806bf251b6388866", size = 8252732, upload-time = "2025-10-09T00:27:32.332Z" },
{ url = "https://files.pythonhosted.org/packages/74/04/45d269b4268d222390d7817dae77b159651909669a34ee9fdee336db5883/matplotlib-3.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:9257be2f2a03415f9105c486d304a321168e61ad450f6153d77c69504ad764bb", size = 8124240, upload-time = "2025-10-09T00:27:33.94Z" },
{ url = "https://files.pythonhosted.org/packages/4b/c7/ca01c607bb827158b439208c153d6f14ddb9fb640768f06f7ca3488ae67b/matplotlib-3.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1e4bbad66c177a8fdfa53972e5ef8be72a5f27e6a607cec0d8579abd0f3102b1", size = 8316938, upload-time = "2025-10-09T00:27:35.534Z" },
{ url = "https://files.pythonhosted.org/packages/84/d2/5539e66e9f56d2fdec94bb8436f5e449683b4e199bcc897c44fbe3c99e28/matplotlib-3.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8eb7194b084b12feb19142262165832fc6ee879b945491d1c3d4660748020c4", size = 8178245, upload-time = "2025-10-09T00:27:37.334Z" },
{ url = "https://files.pythonhosted.org/packages/77/b5/e6ca22901fd3e4fe433a82e583436dd872f6c966fca7e63cf806b40356f8/matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d41379b05528091f00e1728004f9a8d7191260f3862178b88e8fd770206318", size = 9541411, upload-time = "2025-10-09T00:27:39.387Z" },
{ url = "https://files.pythonhosted.org/packages/9e/99/a4524db57cad8fee54b7237239a8f8360bfcfa3170d37c9e71c090c0f409/matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a74f79fafb2e177f240579bc83f0b60f82cc47d2f1d260f422a0627207008ca", size = 9803664, upload-time = "2025-10-09T00:27:41.492Z" },
{ url = "https://files.pythonhosted.org/packages/e6/a5/85e2edf76ea0ad4288d174926d9454ea85f3ce5390cc4e6fab196cbf250b/matplotlib-3.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:702590829c30aada1e8cef0568ddbffa77ca747b4d6e36c6d173f66e301f89cc", size = 9594066, upload-time = "2025-10-09T00:27:43.694Z" },
{ url = "https://files.pythonhosted.org/packages/39/69/9684368a314f6d83fe5c5ad2a4121a3a8e03723d2e5c8ea17b66c1bad0e7/matplotlib-3.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:f79d5de970fc90cd5591f60053aecfce1fcd736e0303d9f0bf86be649fa68fb8", size = 8342832, upload-time = "2025-10-09T00:27:45.543Z" },
{ url = "https://files.pythonhosted.org/packages/04/5f/e22e08da14bc1a0894184640d47819d2338b792732e20d292bf86e5ab785/matplotlib-3.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:cb783436e47fcf82064baca52ce748af71725d0352e1d31564cbe9c95df92b9c", size = 8172585, upload-time = "2025-10-09T00:27:47.185Z" },
]
[[package]]
name = "numpy"
version = "2.3.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/f4/098d2270d52b41f1bd7db9fc288aaa0400cb48c2a3e2af6fa365d9720947/numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a", size = 20582187, upload-time = "2025-10-15T16:18:11.77Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/71/ae6170143c115732470ae3a2d01512870dd16e0953f8a6dc89525696069b/numpy-2.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e", size = 20955580, upload-time = "2025-10-15T16:17:02.509Z" },
{ url = "https://files.pythonhosted.org/packages/af/39/4be9222ffd6ca8a30eda033d5f753276a9c3426c397bb137d8e19dedd200/numpy-2.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff", size = 14188056, upload-time = "2025-10-15T16:17:04.873Z" },
{ url = "https://files.pythonhosted.org/packages/6c/3d/d85f6700d0a4aa4f9491030e1021c2b2b7421b2b38d01acd16734a2bfdc7/numpy-2.3.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f", size = 5116555, upload-time = "2025-10-15T16:17:07.499Z" },
{ url = "https://files.pythonhosted.org/packages/bf/04/82c1467d86f47eee8a19a464c92f90a9bb68ccf14a54c5224d7031241ffb/numpy-2.3.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b", size = 6643581, upload-time = "2025-10-15T16:17:09.774Z" },
{ url = "https://files.pythonhosted.org/packages/0c/d3/c79841741b837e293f48bd7db89d0ac7a4f2503b382b78a790ef1dc778a5/numpy-2.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7", size = 14299186, upload-time = "2025-10-15T16:17:11.937Z" },
{ url = "https://files.pythonhosted.org/packages/e8/7e/4a14a769741fbf237eec5a12a2cbc7a4c4e061852b6533bcb9e9a796c908/numpy-2.3.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2", size = 16638601, upload-time = "2025-10-15T16:17:14.391Z" },
{ url = "https://files.pythonhosted.org/packages/93/87/1c1de269f002ff0a41173fe01dcc925f4ecff59264cd8f96cf3b60d12c9b/numpy-2.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52", size = 16074219, upload-time = "2025-10-15T16:17:17.058Z" },
{ url = "https://files.pythonhosted.org/packages/cd/28/18f72ee77408e40a76d691001ae599e712ca2a47ddd2c4f695b16c65f077/numpy-2.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26", size = 18576702, upload-time = "2025-10-15T16:17:19.379Z" },
{ url = "https://files.pythonhosted.org/packages/c3/76/95650169b465ececa8cf4b2e8f6df255d4bf662775e797ade2025cc51ae6/numpy-2.3.4-cp314-cp314-win32.whl", hash = "sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc", size = 6337136, upload-time = "2025-10-15T16:17:22.886Z" },
{ url = "https://files.pythonhosted.org/packages/dc/89/a231a5c43ede5d6f77ba4a91e915a87dea4aeea76560ba4d2bf185c683f0/numpy-2.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9", size = 12920542, upload-time = "2025-10-15T16:17:24.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/0c/ae9434a888f717c5ed2ff2393b3f344f0ff6f1c793519fa0c540461dc530/numpy-2.3.4-cp314-cp314-win_arm64.whl", hash = "sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868", size = 10480213, upload-time = "2025-10-15T16:17:26.935Z" },
{ url = "https://files.pythonhosted.org/packages/83/4b/c4a5f0841f92536f6b9592694a5b5f68c9ab37b775ff342649eadf9055d3/numpy-2.3.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec", size = 21052280, upload-time = "2025-10-15T16:17:29.638Z" },
{ url = "https://files.pythonhosted.org/packages/3e/80/90308845fc93b984d2cc96d83e2324ce8ad1fd6efea81b324cba4b673854/numpy-2.3.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3", size = 14302930, upload-time = "2025-10-15T16:17:32.384Z" },
{ url = "https://files.pythonhosted.org/packages/3d/4e/07439f22f2a3b247cec4d63a713faae55e1141a36e77fb212881f7cda3fb/numpy-2.3.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365", size = 5231504, upload-time = "2025-10-15T16:17:34.515Z" },
{ url = "https://files.pythonhosted.org/packages/ab/de/1e11f2547e2fe3d00482b19721855348b94ada8359aef5d40dd57bfae9df/numpy-2.3.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252", size = 6739405, upload-time = "2025-10-15T16:17:36.128Z" },
{ url = "https://files.pythonhosted.org/packages/3b/40/8cd57393a26cebe2e923005db5134a946c62fa56a1087dc7c478f3e30837/numpy-2.3.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e", size = 14354866, upload-time = "2025-10-15T16:17:38.884Z" },
{ url = "https://files.pythonhosted.org/packages/93/39/5b3510f023f96874ee6fea2e40dfa99313a00bf3ab779f3c92978f34aace/numpy-2.3.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0", size = 16703296, upload-time = "2025-10-15T16:17:41.564Z" },
{ url = "https://files.pythonhosted.org/packages/41/0d/19bb163617c8045209c1996c4e427bccbc4bbff1e2c711f39203c8ddbb4a/numpy-2.3.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0", size = 16136046, upload-time = "2025-10-15T16:17:43.901Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c1/6dba12fdf68b02a21ac411c9df19afa66bed2540f467150ca64d246b463d/numpy-2.3.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f", size = 18652691, upload-time = "2025-10-15T16:17:46.247Z" },
{ url = "https://files.pythonhosted.org/packages/f8/73/f85056701dbbbb910c51d846c58d29fd46b30eecd2b6ba760fc8b8a1641b/numpy-2.3.4-cp314-cp314t-win32.whl", hash = "sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d", size = 6485782, upload-time = "2025-10-15T16:17:48.872Z" },
{ url = "https://files.pythonhosted.org/packages/17/90/28fa6f9865181cb817c2471ee65678afa8a7e2a1fb16141473d5fa6bacc3/numpy-2.3.4-cp314-cp314t-win_amd64.whl", hash = "sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6", size = 13113301, upload-time = "2025-10-15T16:17:50.938Z" },
{ url = "https://files.pythonhosted.org/packages/54/23/08c002201a8e7e1f9afba93b97deceb813252d9cfd0d3351caed123dcf97/numpy-2.3.4-cp314-cp314t-win_arm64.whl", hash = "sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29", size = 10547532, upload-time = "2025-10-15T16:17:53.48Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pillow"
version = "12.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" },
{ url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" },
{ url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" },
{ url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" },
{ url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" },
{ url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" },
{ url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" },
{ url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" },
{ url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" },
{ url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" },
{ url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" },
{ url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" },
{ url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" },
{ url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" },
{ url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" },
{ url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" },
{ url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" },
{ url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" },
{ url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" },
{ url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" },
{ url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" },
{ url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
]
[[package]]
name = "pyparsing"
version = "3.2.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/a5/181488fc2b9d093e3972d2a472855aae8a03f000592dbfce716a512b3359/pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6", size = 1099274, upload-time = "2025-09-21T04:11:06.277Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]

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}

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}

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Some files were not shown because too many files have changed in this diff Show More