12 Commits
lab3 ... lab4

Author SHA1 Message Date
7ec38a3385 Отчёт 2025-11-08 23:22:02 +03:00
4b2398ae05 рисование дерева 2025-11-08 23:21:42 +03:00
bacfa20061 mutation 2025-11-07 12:54:27 +03:00
74e02df205 i think i've done this shit RMSE: 0.64 !!!! 2025-11-07 01:44:59 +03:00
cfae423f11 best for now RMSE: 30.937 2025-11-07 00:11:02 +03:00
cb2b031e9c safe operations 2025-11-06 23:12:48 +03:00
cc180dc700 fitnesses 2025-11-06 22:50:10 +03:00
e6765c9254 vectorized 2025-11-05 20:32:09 +03:00
26bd6da1b4 another save 2025-11-05 20:07:35 +03:00
8e8e0abd0d save 2025-11-04 15:02:02 +03:00
83be98e923 Вынес методы инициализации из хромосомы 2025-10-21 18:14:20 +03:00
afd7a700ca Хромосомы для лаб4 2025-10-21 12:26:43 +03:00
35 changed files with 2290 additions and 1 deletions

3
.gitignore vendored
View File

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

1
lab4/.python-version Normal file
View File

@@ -0,0 +1 @@
3.14

50
lab4/draw_tree.py Normal file
View File

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,382 @@
import os
import random
import shutil
import time
from copy import deepcopy
from dataclasses import asdict, dataclass
from typing import Callable
import graphviz
import numpy as np
from matplotlib import pyplot as plt
from .chromosome import Chromosome
from .node import Node
from .types import Fitnesses, Population
type FitnessFn = Callable[[Chromosome], float]
type InitializePopulationFn = Callable[[int], Population]
type CrossoverFn = Callable[[Chromosome, Chromosome], tuple[Chromosome, Chromosome]]
type MutationFn = Callable[[Chromosome], Chromosome]
type SelectionFn = Callable[[Population, Fitnesses], Population]
@dataclass(frozen=True)
class GARunConfig:
fitness_func: FitnessFn
crossover_fn: CrossoverFn
mutation_fn: MutationFn
selection_fn: SelectionFn
init_population: Population
pc: float # вероятность кроссинговера
pm: float # вероятность мутации
max_generations: int # максимальное количество поколений
elitism: int = (
0 # сколько лучших особей перенести без изменения в следующее поколение
)
max_best_repetitions: int | None = (
None # остановка при повторении лучшего результата
)
seed: int | None = None # seed для генератора случайных чисел
minimize: bool = True # если True, ищем минимум вместо максимума
save_generations: list[int] | None = (
None # индексы поколений для сохранения графиков
)
results_dir: str = "results" # папка для сохранения графиков
fitness_avg_threshold: float | None = (
None # порог среднего значения фитнес функции для остановки
)
best_value_threshold: float | None = (
None # остановка при достижении значения фитнеса лучше заданного
)
log_every_generation: bool = False # логировать каждое поколение
def save(self, filename: str = "GARunConfig.txt"):
"""Сохраняет конфиг в results_dir."""
os.makedirs(self.results_dir, exist_ok=True)
path = os.path.join(self.results_dir, filename)
with open(path, "w", encoding="utf-8") as f:
for k, v in asdict(self).items():
f.write(f"{k}: {v}\n")
@dataclass(frozen=True)
class Generation:
number: int
best: Chromosome
best_fitness: float
avg_fitness: float
population: Population
fitnesses: Fitnesses
@dataclass(frozen=True)
class GARunResult:
generations_count: int
best_generation: Generation
history: list[Generation]
time_ms: float
def save(self, path: str, filename: str = "GARunResult.txt"):
"""Сохраняет конфиг в results_dir."""
os.makedirs(path, exist_ok=True)
path = os.path.join(path, filename)
with open(path, "w", encoding="utf-8") as f:
for k, v in asdict(self).items():
if k == "history":
continue
if k == "best_generation":
f.write(
f"{k}: Number: {v['number']}, Best Fitness: {v['best_fitness']}, Best: {v['best']}\n"
)
else:
f.write(f"{k}: {v}\n")
def crossover(
population: Population,
pc: float,
crossover_fn: CrossoverFn,
) -> Population:
"""Оператор кроссинговера (скрещивания) выполняется с заданной вероятностью pc.
Две хромосомы (родители) выбираются случайно из промежуточной популяции.
Если популяция нечетного размера, то последняя хромосома скрещивается со случайной
другой хромосомой из популяции. В таком случае одна из хромосом может поучаствовать
в кроссовере дважды.
"""
# Создаем копию популяции и перемешиваем её для случайного выбора пар
shuffled_population = population.copy()
random.shuffle(shuffled_population)
next_population = []
pop_size = len(shuffled_population)
for i in range(0, pop_size, 2):
p1 = shuffled_population[i]
p2 = shuffled_population[(i + 1) % pop_size]
if np.random.random() <= pc:
p1, p2 = crossover_fn(p1, p2)
next_population.append(p1)
next_population.append(p2)
return next_population[:pop_size]
def mutation(
population: Population, pm: float, gen_num: int, mutation_fn: MutationFn
) -> Population:
"""Мутация происходит с вероятностью pm."""
next_population = []
for chrom in population:
next_population.append(
mutation_fn(chrom) if np.random.random() <= pm else chrom
)
return next_population
def clear_results_directory(results_dir: str) -> None:
"""Очищает папку с результатами перед началом эксперимента."""
if os.path.exists(results_dir):
shutil.rmtree(results_dir)
os.makedirs(results_dir, exist_ok=True)
def eval_population(population: Population, fitness_func: FitnessFn) -> Fitnesses:
return np.array([fitness_func(chrom) for chrom in population])
def render_tree_to_graphviz(
node: Node, graph: graphviz.Digraph, node_id: str = "0"
) -> None:
"""Рекурсивно добавляет узлы дерева в graphviz граф."""
graph.node(node_id, label=node.value.name)
for i, child in enumerate(node.children):
child_id = f"{node_id}_{i}"
render_tree_to_graphviz(child, graph, child_id)
graph.edge(node_id, child_id)
def save_generation(
generation: Generation, history: list[Generation], config: GARunConfig
) -> None:
"""Сохраняет визуализацию лучшей хромосомы поколения в виде дерева."""
os.makedirs(config.results_dir, exist_ok=True)
# Создаем граф для визуализации дерева
dot = graphviz.Digraph(comment=f"Generation {generation.number}")
dot.attr(rankdir="TB") # Top to Bottom direction
dot.attr("node", shape="circle", style="filled", fillcolor="lightblue")
# Добавляем заголовок
depth = generation.best.root.get_depth()
title = (
f"Поколение #{generation.number}\\n"
f"Лучшая особь: {generation.best_fitness:.4f}\\n"
f"Глубина дерева: {depth}"
)
dot.attr(label=title, labelloc="t", fontsize="14")
# Рендерим дерево
render_tree_to_graphviz(generation.best.root, dot)
# Сохраняем
filename = f"generation_{generation.number:03d}"
filepath = os.path.join(config.results_dir, filename)
dot.render(filepath, format="png", cleanup=True)
def genetic_algorithm(config: GARunConfig) -> GARunResult:
if config.seed is not None:
random.seed(config.seed)
np.random.seed(config.seed)
if config.save_generations:
clear_results_directory(config.results_dir)
population = config.init_population
start = time.perf_counter()
history: list[Generation] = []
best: Generation | None = None
generation_number = 1
best_repetitions = 0
while True:
# Вычисляем фитнес для всех особей в популяции
fitnesses = eval_population(population, config.fitness_func)
# Сохраняем лучших особей для переноса в следующее поколение
elites: list[Chromosome] = []
if config.elitism:
elites = deepcopy(
[
population[i]
for i in sorted(
range(len(fitnesses)),
key=lambda i: fitnesses[i],
reverse=not config.minimize,
)
][: config.elitism]
)
# Находим лучшую особь в поколении
best_index = (
int(np.argmin(fitnesses)) if config.minimize else int(np.argmax(fitnesses))
)
# Добавляем эпоху в историю
current = Generation(
number=generation_number,
best=population[best_index],
best_fitness=fitnesses[best_index],
avg_fitness=float(np.mean(fitnesses)),
# population=deepcopy(population),
population=[],
# fitnesses=deepcopy(fitnesses),
fitnesses=np.array([]),
)
history.append(current)
if config.log_every_generation:
print(
f"Generation #{generation_number} best: {current.best_fitness},"
f" avg: {np.mean(fitnesses)}"
)
# Обновляем лучшую эпоху
if (
best is None
or (config.minimize and current.best_fitness < best.best_fitness)
or (not config.minimize and current.best_fitness > best.best_fitness)
):
best = current
# Проверка критериев остановки
stop_algorithm = False
if generation_number >= config.max_generations:
stop_algorithm = True
if config.max_best_repetitions is not None and generation_number > 1:
if history[-2].best_fitness == current.best_fitness:
best_repetitions += 1
if best_repetitions == config.max_best_repetitions:
stop_algorithm = True
else:
best_repetitions = 0
if config.best_value_threshold is not None:
if (
config.minimize and current.best_fitness < config.best_value_threshold
) or (
not config.minimize
and current.best_fitness > config.best_value_threshold
):
stop_algorithm = True
if config.fitness_avg_threshold is not None:
mean_fitness = np.mean(fitnesses)
if (config.minimize and mean_fitness < config.fitness_avg_threshold) or (
not config.minimize and mean_fitness > config.fitness_avg_threshold
):
stop_algorithm = True
# Сохраняем указанные поколения и последнее поколение
if config.save_generations and (
stop_algorithm or generation_number in config.save_generations
):
save_generation(current, history, config)
if stop_algorithm:
break
# селекция (для минимума инвертируем знак)
parents = config.selection_fn(
population, fitnesses if not config.minimize else -fitnesses
)
# кроссинговер попарно
next_population = crossover(parents, config.pc, config.crossover_fn)
# мутация
next_population = mutation(
next_population,
config.pm,
generation_number,
config.mutation_fn,
)
# Вставляем элиту в новую популяцию
population = next_population[: len(population) - config.elitism] + elites
generation_number += 1
end = time.perf_counter()
assert best is not None, "Best was never set"
result = GARunResult(
len(history),
best,
history,
(end - start) * 1000.0,
)
# Автоматически строим графики истории фитнеса
if config.save_generations:
plot_fitness_history(result, save_dir=config.results_dir)
return result
def plot_fitness_history(result: GARunResult, save_dir: str | None = None) -> None:
"""Рисует графики изменения лучших и средних значений фитнеса по поколениям.
Создает два отдельных графика:
- fitness_best.png - график лучших значений
- fitness_avg.png - график средних значений
"""
generations = [gen.number for gen in result.history]
best_fitnesses = [gen.best_fitness for gen in result.history]
avg_fitnesses = [gen.avg_fitness for gen in result.history]
# График лучших значений
fig_best, ax_best = plt.subplots(figsize=(10, 6))
ax_best.plot(generations, best_fitnesses, linewidth=2, color="blue")
ax_best.set_xlabel("Поколение", fontsize=12)
ax_best.set_ylabel("Лучшее значение фитнес-функции", fontsize=12)
ax_best.set_title("Лучшее значение фитнеса по поколениям", fontsize=14)
ax_best.grid(True, alpha=0.3)
if save_dir:
best_path = os.path.join(save_dir, "fitness_best.png")
fig_best.savefig(best_path, dpi=150, bbox_inches="tight")
print(f"График лучших значений сохранен в {best_path}")
else:
plt.show()
plt.close(fig_best)
# График средних значений
fig_avg, ax_avg = plt.subplots(figsize=(10, 6))
ax_avg.plot(generations, avg_fitnesses, linewidth=2, color="orange")
ax_avg.set_xlabel("Поколение", fontsize=12)
ax_avg.set_ylabel("Среднее значение фитнес-функции", fontsize=12)
ax_avg.set_title("Среднее значение фитнеса по поколениям", fontsize=14)
ax_avg.grid(True, alpha=0.3)
if save_dir:
avg_path = os.path.join(save_dir, "fitness_avg.png")
fig_avg.savefig(avg_path, dpi=150, bbox_inches="tight")
print(f"График средних значений сохранен в {avg_path}")
else:
plt.show()
plt.close(fig_avg)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

108
lab4/main.py Normal file
View File

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

BIN
lab4/original_tree.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

12
lab4/pyproject.toml Normal file
View File

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

26
lab4/pytest.ini Normal file
View File

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

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

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

265
lab4/uv.lock generated Normal file
View File

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