diff --git a/lab4/draw_tree.py b/lab4/draw_tree.py new file mode 100644 index 0000000..26d71cc --- /dev/null +++ b/lab4/draw_tree.py @@ -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) diff --git a/lab4/gp/__init__.py b/lab4/gp/__init__.py index 09ceff3..e69de29 100644 --- a/lab4/gp/__init__.py +++ b/lab4/gp/__init__.py @@ -1,3 +0,0 @@ -from .chromosome import Chromosome - -__all__ = ["Chromosome"] diff --git a/lab4/gp/ga.py b/lab4/gp/ga.py index 27356b6..be7d734 100644 --- a/lab4/gp/ga.py +++ b/lab4/gp/ga.py @@ -6,11 +6,12 @@ from copy import deepcopy from dataclasses import asdict, dataclass from typing import Callable +import graphviz import numpy as np from matplotlib import pyplot as plt -from numpy.typing import NDArray from .chromosome import Chromosome +from .node import Node from .types import Fitnesses, Population type FitnessFn = Callable[[Chromosome], float] @@ -66,6 +67,7 @@ class Generation: number: int best: Chromosome best_fitness: float + avg_fitness: float population: Population fitnesses: Fitnesses @@ -148,27 +150,45 @@ def eval_population(population: Population, fitness_func: FitnessFn) -> Fitnesse return np.array([fitness_func(chrom) for chrom in population]) +def render_tree_to_graphviz( + node: Node, graph: graphviz.Digraph, node_id: str = "0" +) -> None: + """Рекурсивно добавляет узлы дерева в graphviz граф.""" + graph.node(node_id, label=node.value.name) + + for i, child in enumerate(node.children): + child_id = f"{node_id}_{i}" + render_tree_to_graphviz(child, graph, child_id) + graph.edge(node_id, child_id) + + def save_generation( generation: Generation, history: list[Generation], config: GARunConfig ) -> None: + """Сохраняет визуализацию лучшей хромосомы поколения в виде дерева.""" os.makedirs(config.results_dir, exist_ok=True) - fig = plt.figure(figsize=(7, 7)) - fig.suptitle( - f"Поколение #{generation.number}. " - f"Лучшая особь: {generation.best_fitness:.0f}. " - f"Среднее значение: {np.mean(generation.fitnesses):.0f}", - fontsize=14, - y=0.95, + # Создаем граф для визуализации дерева + dot = graphviz.Digraph(comment=f"Generation {generation.number}") + dot.attr(rankdir="TB") # Top to Bottom direction + dot.attr("node", shape="circle", style="filled", fillcolor="lightblue") + + # Добавляем заголовок + depth = generation.best.root.get_depth() + title = ( + f"Поколение #{generation.number}\\n" + f"Лучшая особь: {generation.best_fitness:.4f}\\n" + f"Глубина дерева: {depth}" ) + dot.attr(label=title, labelloc="t", fontsize="14") - # Рисуем - ... + # Рендерим дерево + render_tree_to_graphviz(generation.best.root, dot) - filename = f"generation_{generation.number:03d}.png" - path_png = os.path.join(config.results_dir, filename) - fig.savefig(path_png, dpi=150, bbox_inches="tight") - plt.close(fig) + # Сохраняем + filename = f"generation_{generation.number:03d}" + filepath = os.path.join(config.results_dir, filename) + dot.render(filepath, format="png", cleanup=True) def genetic_algorithm(config: GARunConfig) -> GARunResult: @@ -216,6 +236,7 @@ def genetic_algorithm(config: GARunConfig) -> GARunResult: number=generation_number, best=population[best_index], best_fitness=fitnesses[best_index], + avg_fitness=float(np.mean(fitnesses)), # population=deepcopy(population), population=[], # fitnesses=deepcopy(fitnesses), @@ -301,41 +322,61 @@ def genetic_algorithm(config: GARunConfig) -> GARunResult: end = time.perf_counter() assert best is not None, "Best was never set" - return GARunResult( + result = GARunResult( len(history), best, history, (end - start) * 1000.0, ) + # Автоматически строим графики истории фитнеса + if config.save_generations: + plot_fitness_history(result, save_dir=config.results_dir) -def plot_fitness_history(result: GARunResult, save_path: str | None = None) -> None: - """Рисует график изменения лучших и средних значений фитнеса по поколениям.""" + return result + + +def plot_fitness_history(result: GARunResult, save_dir: str | None = None) -> None: + """Рисует графики изменения лучших и средних значений фитнеса по поколениям. + + Создает два отдельных графика: + - fitness_best.png - график лучших значений + - fitness_avg.png - график средних значений + """ generations = [gen.number for gen in result.history] best_fitnesses = [gen.best_fitness for gen in result.history] - avg_fitnesses = [np.mean(gen.fitnesses) for gen in result.history] + avg_fitnesses = [gen.avg_fitness for gen in result.history] - fig, ax = plt.subplots(figsize=(10, 6)) + # График лучших значений + fig_best, ax_best = plt.subplots(figsize=(10, 6)) + ax_best.plot(generations, best_fitnesses, linewidth=2, color="blue") + ax_best.set_xlabel("Поколение", fontsize=12) + ax_best.set_ylabel("Лучшее значение фитнес-функции", fontsize=12) + ax_best.set_title("Лучшее значение фитнеса по поколениям", fontsize=14) + ax_best.grid(True, alpha=0.3) - ax.plot( - generations, best_fitnesses, label="Лучшее значение", linewidth=2, color="blue" - ) - ax.plot( - generations, - avg_fitnesses, - label="Среднее значение", - linewidth=2, - color="orange", - ) - - ax.set_xlabel("Поколение", fontsize=12) - ax.set_ylabel("Значение фитнес-функции", fontsize=12) - ax.legend(fontsize=11) - ax.grid(True, alpha=0.3) - - if save_path: - fig.savefig(save_path, dpi=150, bbox_inches="tight") - print(f"График сохранен в {save_path}") + if save_dir: + best_path = os.path.join(save_dir, "fitness_best.png") + fig_best.savefig(best_path, dpi=150, bbox_inches="tight") + print(f"График лучших значений сохранен в {best_path}") else: plt.show() - plt.close(fig) + + plt.close(fig_best) + + # График средних значений + fig_avg, ax_avg = plt.subplots(figsize=(10, 6)) + ax_avg.plot(generations, avg_fitnesses, linewidth=2, color="orange") + ax_avg.set_xlabel("Поколение", fontsize=12) + ax_avg.set_ylabel("Среднее значение фитнес-функции", fontsize=12) + ax_avg.set_title("Среднее значение фитнеса по поколениям", fontsize=14) + ax_avg.grid(True, alpha=0.3) + + if save_dir: + avg_path = os.path.join(save_dir, "fitness_avg.png") + fig_avg.savefig(avg_path, dpi=150, bbox_inches="tight") + print(f"График средних значений сохранен в {avg_path}") + else: + plt.show() + + plt.close(fig_avg) diff --git a/lab4/gp/population.py b/lab4/gp/population.py deleted file mode 100644 index 74492e1..0000000 --- a/lab4/gp/population.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import Sequence - -from .chromosome import Chromosome -from .primitive import Primitive - -type Population = list[Chromosome] - - -def ramped_initialization( - chromosomes_per_variation: int, - depths: list[int], - terminals: Sequence[Primitive], - operations: Sequence[Primitive], -) -> Population: - """Комбинация методов grow и full инициализации хромосом для инициализации начальной - популяции. - """ - population: Population = [] - - for depth in depths: - population.extend( - Chromosome.full_init(terminals, operations, depth) - for _ in range(chromosomes_per_variation) - ) - population.extend( - Chromosome.grow_init(terminals, operations, depth) - for _ in range(chromosomes_per_variation) - ) - - return population diff --git a/lab4/main.py b/lab4/main.py index 0706b25..b0ea64c 100644 --- a/lab4/main.py +++ b/lab4/main.py @@ -1,16 +1,20 @@ +""" +graphviz должен быть доступен в PATH (недостаточно просто установить через pip) + +Можно проверить командой +dot -V +""" + import random -from math import log import numpy as np from numpy.typing import NDArray -from gp import Chromosome from gp.crossovers import crossover_subtree from gp.fitness import ( MAEFitness, MSEFitness, NRMSEFitness, - PenalizedFitness, RMSEFitness, ) from gp.ga import GARunConfig, genetic_algorithm @@ -21,10 +25,10 @@ from gp.mutations import ( NodeReplacementMutation, ShrinkMutation, ) -from gp.ops import ADD, COS, DIV, EXP, MUL, NEG, POW, SIN, SQUARE, SUB +from gp.ops import ADD, COS, DIV, EXP, MUL, POW, SIN, SQUARE, SUB from gp.population import ramped_initialization -from gp.primitive import Const, Var -from gp.selection import roulette_selection, tournament_selection +from gp.primitive import Var +from gp.selection import tournament_selection NUM_VARS = 8 TEST_POINTS = 10000 @@ -34,8 +38,6 @@ SEED = 17 np.random.seed(SEED) random.seed(SEED) X = np.random.uniform(-5.536, 5.536, size=(TEST_POINTS, NUM_VARS)) -# axes = [np.linspace(-5.536, 5.536, TEST_POINTS) for _ in range(NUM_VARS)] -# X = np.array(np.meshgrid(*axes)).T.reshape(-1, NUM_VARS) operations = [SQUARE, SIN, COS, EXP, ADD, SUB, MUL, DIV, POW] terminals = [Var(f"x{i}") for i in range(1, NUM_VARS + 1)] @@ -74,7 +76,6 @@ 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=roulette_selection, selection_fn=lambda p, f: tournament_selection(p, f, k=3), init_population=init_population, seed=SEED, @@ -83,6 +84,7 @@ config = GARunConfig( elitism=15, max_generations=MAX_GENERATIONS, log_every_generation=True, + save_generations=[1, 10, 20, 30, 40, 50, 100, 150, 200], ) result = genetic_algorithm(config) diff --git a/lab4/original_tree.png b/lab4/original_tree.png new file mode 100644 index 0000000..76a3663 Binary files /dev/null and b/lab4/original_tree.png differ diff --git a/lab4/pyproject.toml b/lab4/pyproject.toml index bf8bfc5..984317a 100644 --- a/lab4/pyproject.toml +++ b/lab4/pyproject.toml @@ -3,6 +3,7 @@ name = "lab4" version = "0.1.0" requires-python = ">=3.14" dependencies = [ + "graphviz>=0.21", "matplotlib>=3.10.7", "numpy>=2.3.4", ] diff --git a/lab4/uv.lock b/lab4/uv.lock index 7da4e88..4e97e8b 100644 --- a/lab4/uv.lock +++ b/lab4/uv.lock @@ -69,6 +69,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/93/0dd45cd283c32dea1545151d8c3637b4b8c53cdb3a625aeb2885b184d74d/fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb", size = 1143175, upload-time = "2025-09-29T21:13:24.134Z" }, ] +[[package]] +name = "graphviz" +version = "0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/b3/3ac91e9be6b761a4b30d66ff165e54439dcd48b83f4e20d644867215f6ca/graphviz-0.21.tar.gz", hash = "sha256:20743e7183be82aaaa8ad6c93f8893c923bd6658a04c32ee115edb3c8a835f78", size = 200434, upload-time = "2025-06-15T09:35:05.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl", hash = "sha256:54f33de9f4f911d7e84e4191749cac8cc5653f815b06738c54db9a15ab8b1e42", size = 47300, upload-time = "2025-06-15T09:35:04.433Z" }, +] + [[package]] name = "kiwisolver" version = "1.4.9" @@ -108,12 +117,14 @@ name = "lab4" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "graphviz" }, { name = "matplotlib" }, { name = "numpy" }, ] [package.metadata] requires-dist = [ + { name = "graphviz", specifier = ">=0.21" }, { name = "matplotlib", specifier = ">=3.10.7" }, { name = "numpy", specifier = ">=2.3.4" }, ]