12 Commits

Author SHA1 Message Date
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
39 changed files with 3468 additions and 4 deletions

3
.gitignore vendored
View File

@@ -2,4 +2,5 @@
!**/
!*.gitignore
!*.py
!*.py
!lab4/*

View File

@@ -16,7 +16,7 @@ from numpy.typing import NDArray
type Chromosome = NDArray[np.float64]
type Population = list[Chromosome]
type Fitnesses = NDArray[np.float64]
type FitnessFn = Callable[[Chromosome], Fitnesses]
type FitnessFn = Callable[[Chromosome], np.float64]
type CrossoverFn = Callable[[Chromosome, Chromosome], tuple[Chromosome, Chromosome]]
type MutationFn = Callable[[Chromosome], Chromosome]
@@ -185,7 +185,7 @@ def plot_fitness_surface(
x_max: Chromosome,
ax: Axes3D,
num_points: int = 100,
) -> None:
):
"""Рисует поверхность функции фитнеса в 3D."""
assert (
x_min.shape == x_max.shape == (2,)

View File

@@ -3,7 +3,7 @@ import numpy as np
from gen import GARunConfig, genetic_algorithm
def fitness_function(chromosome: np.ndarray) -> np.ndarray:
def fitness_function(chromosome: np.ndarray) -> np.float64:
return chromosome[0] ** 2 + 2 * chromosome[1] ** 2

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()

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

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

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

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)

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

@@ -0,0 +1,341 @@
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
from matplotlib import pyplot as plt
from numpy.typing import NDArray
from .chromosome import Chromosome
from .types import Fitnesses, Population
type FitnessFn = Callable[[Chromosome], float]
type InitializePopulationFn = Callable[[int], Population]
type CrossoverFn = Callable[[Chromosome, Chromosome], tuple[Chromosome, Chromosome]]
type MutationFn = Callable[[Chromosome, int], Chromosome]
type 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
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, gen_num) 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 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,
)
# Рисуем
...
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.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],
# 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"
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)

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

@@ -0,0 +1,94 @@
import random
from .chromosome import Chromosome
def shrink_mutation(chromosome: Chromosome) -> Chromosome:
"""Усекающая мутация. Заменяет случайно выбранную операцию на случайный терминал."""
chromosome = chromosome.copy()
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
def grow_mutation(chromosome: Chromosome, max_depth: int) -> Chromosome:
"""Растущая мутация. Заменяет случайно выбранный узел на случайное поддерево."""
chromosome = chromosome.copy()
target_node = random.choice(chromosome.root.list_nodes())
max_subtree_depth = 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
def node_replacement_mutation(chromosome: Chromosome) -> Chromosome:
"""Мутация замены операции (Node Replacement Mutation).
Выбирает случайный узел и заменяет его
на случайную другую операцию той же арности или терминал, сохраняя поддеревья.
Если подходящей альтернативы нет — возвращает копию без изменений.
"""
chromosome = chromosome.copy()
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
def hoist_mutation(chromosome: Chromosome) -> Chromosome:
"""Hoist-мутация (анти-bloat).
Выбирает случайное поддерево, затем внутри него — случайное поддерево меньшей глубины,
и заменяет исходное поддерево на это внутреннее.
В результате дерево становится короче, сохраняя часть структуры.
"""
chromosome = chromosome.copy()
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

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))),
)

30
lab4/gp/population.py Normal file
View File

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

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

128
lab4/main.py Normal file
View File

@@ -0,0 +1,128 @@
import random
from math import log
import numpy as np
from numpy.typing import NDArray
from gp import Chromosome
from gp.crossovers import crossover_subtree
from gp.fitness import (
MAEFitness,
MSEFitness,
NRMSEFitness,
PenalizedFitness,
RMSEFitness,
)
from gp.ga import GARunConfig, genetic_algorithm
from gp.mutations import (
grow_mutation,
hoist_mutation,
node_replacement_mutation,
shrink_mutation,
)
from gp.ops import ADD, COS, DIV, EXP, MUL, NEG, POW, SIN, SQUARE, SUB
from gp.population import ramped_initialization
from gp.primitive import Const, Var
from gp.selection import roulette_selection, tournament_selection
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))
# axes = [np.linspace(-5.536, 5.536, TEST_POINTS) for _ in range(NUM_VARS)]
# X = np.array(np.meshgrid(*axes)).T.reshape(-1, NUM_VARS)
operations = [SQUARE, SIN, COS, EXP, ADD, SUB, MUL, DIV, POW]
# operations = [SQUARE, ADD, SUB, MUL]
terminals = [Var(f"x{i}") for i in range(1, NUM_VARS + 1)]
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 = MSEFitness(target_function, lambda: X)
# fitness_function = HuberFitness(target_function, lambda: X, delta=0.5)
# fitness_function = PenalizedFitness(
# target_function, lambda: X, base_fitness=fitness, lambda_=0.1
# )
# fitness_function = NRMSEFitness(target_function, lambda: X)
fitness_function = RMSEFitness(target_function, lambda: X)
# fitness_function = PenalizedFitness(
# target_function, lambda: X, base_fitness=fitness_function, lambda_=0.0001
# )
def adaptive_mutation(
chromosome: Chromosome,
generation: int,
max_generations: int,
max_depth: int,
) -> Chromosome:
r = random.random()
if r < 0.4:
return grow_mutation(chromosome, max_depth=max_depth)
elif r < 0.7:
return node_replacement_mutation(chromosome)
elif r < 0.85:
return hoist_mutation(chromosome)
return shrink_mutation(chromosome)
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=lambda chrom, gen_num: adaptive_mutation(
chrom, gen_num, MAX_GENERATIONS, MAX_DEPTH
),
# selection_fn=roulette_selection,
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,
)
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}")

11
lab4/pyproject.toml Normal file
View File

@@ -0,0 +1,11 @@
[project]
name = "lab4"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
"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

254
lab4/uv.lock generated Normal file
View File

@@ -0,0 +1,254 @@
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 = "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 = "matplotlib" },
{ name = "numpy" },
]
[package.metadata]
requires-dist = [
{ 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" },
]