From 8e8e0abd0db68460e76c4aab3ba5f82acc1d6295 Mon Sep 17 00:00:00 2001 From: Arity-T Date: Tue, 4 Nov 2025 15:02:02 +0300 Subject: [PATCH] save --- lab4/gp/chromosome.py | 15 ++ lab4/gp/crossovers.py | 192 +++++++++++++++++++++ lab4/gp/mutations.py | 143 ++++++++++++++++ lab4/gp/ops.py | 2 +- lab4/tests/__init__.py | 2 + lab4/tests/test_chromosome.py | 304 ++++++++++++++++++++++++++++++++++ 6 files changed, 657 insertions(+), 1 deletion(-) create mode 100644 lab4/gp/crossovers.py create mode 100644 lab4/gp/mutations.py create mode 100644 lab4/tests/__init__.py create mode 100644 lab4/tests/test_chromosome.py diff --git a/lab4/gp/chromosome.py b/lab4/gp/chromosome.py index 74ed413..48d6e69 100644 --- a/lab4/gp/chromosome.py +++ b/lab4/gp/chromosome.py @@ -22,11 +22,20 @@ class Chromosome: """Вычисляет глубину дерева. Дерево из одного только корня имеет глубину 1.""" return self.root.get_depth() if self.root is not None else 0 + def clone(self) -> "Chromosome": + """Создает копию хромосомы.""" + return Chromosome( + self.operations, + self.terminals, + lambda _: self.root.clone(), + ) + def eval(self, values: list[float]) -> float: """Вычисляет значение хромосомы для заданных значений терминалов.""" if self.root is None: raise ValueError("Chromosome is not initialized") + # Мне это не нравится, но, возможно, это будет работать for terminal, value in zip(self.terminals, values): terminal._value = value @@ -65,6 +74,12 @@ class Chromosome: self.value = value self.children = children + def clone(self) -> "Chromosome.Node": + """Создает копию поддерева.""" + return Chromosome.Node( + self.value, [child.clone() for child in self.children] + ) + def get_depth(self) -> int: """Вычисляет глубину поддерева.""" return ( diff --git a/lab4/gp/crossovers.py b/lab4/gp/crossovers.py new file mode 100644 index 0000000..c58b254 --- /dev/null +++ b/lab4/gp/crossovers.py @@ -0,0 +1,192 @@ +import random +from typing import Optional + +from .chromosome import Chromosome +from .operation import Operation +from .terminal import Terminal + + +def crossover_subtree( + p1: Chromosome, p2: Chromosome, max_depth: int | None = None +) -> tuple[Chromosome, Chromosome]: + """ + Кроссовер поддеревьев: выбираются случайные узлы в каждом родителе, + затем соответствующие поддеревья обмениваются местами. Возвращаются два новых потомка. + + Аргументы: + p1 : первый родитель (не изменяется). + p2 : второй родитель (не изменяется). + max_depth : максимальная допустимая глубина потомков. Если None, ограничение не применяется. + + Примечания: + - Для «совместимости» узлов сперва пытаемся подобрать пары одного класса + (оба Terminal или оба Operation). Если подходящей пары не нашлось за + разумное число попыток — допускаем любой обмен. + - Если задан max_depth, проверяется глубина результирующих потомков, + и при превышении лимита выбор узлов повторяется. + - Обмен выполняется на КЛОНАХ родителей, чтобы не портить входные деревья. + """ + + # -------- Вспомогательные функции -------- + + def enumerate_nodes_with_meta( + root: Chromosome.Node, + ) -> list[ + tuple[Chromosome.Node, Optional[Chromosome.Node], Optional[int], list[int], int] + ]: + """ + Возвращает список кортежей: (node, parent, index_in_parent, path_from_root, depth_from_root) + path_from_root — список индексов детей от корня до узла. + depth_from_root: 1 для корня, 2 для детей корня и т.д. + """ + out: list[ + tuple[ + Chromosome.Node, + Optional[Chromosome.Node], + Optional[int], + list[int], + int, + ] + ] = [] + + def dfs( + node: Chromosome.Node, + parent: Optional[Chromosome.Node], + idx: Optional[int], + path: list[int], + depth: int, + ) -> None: + out.append((node, parent, idx, path, depth)) + for i, ch in enumerate(node.children): + dfs(ch, node, i, path + [i], depth + 1) + + dfs(root, None, None, [], 1) + return out + + def get_parent_and_index_by_path( + root: Chromosome.Node, path: list[int] + ) -> tuple[Optional[Chromosome.Node], Optional[int], Chromosome.Node]: + """ + По пути возвращает (parent, index_in_parent, node). + Для корня parent/index равны None. + """ + if not path: + return None, None, root + + parent = root + for i in path[:-1]: + parent = parent.children[i] + idx = path[-1] + return parent, idx, parent.children[idx] + + def is_op(node: Chromosome.Node) -> bool: + return isinstance(node.value, Operation) + + def is_term(node: Chromosome.Node) -> bool: + return isinstance(node.value, Terminal) + + def check_depth_after_swap( + node_depth: int, new_subtree: Chromosome.Node, max_d: int + ) -> bool: + """ + Проверяет, не превысит ли глубина дерева max_d после замены узла на глубине node_depth + на поддерево new_subtree. + + node_depth: глубина узла, который заменяем (1 для корня) + new_subtree: поддерево, которое вставляем + max_d: максимальная допустимая глубина + + Возвращает True, если глубина будет в пределах нормы. + """ + # Глубина нового поддерева + subtree_depth = new_subtree.get_depth() + # Итоговая глубина = глубина узла + глубина поддерева - 1 + # (т.к. node_depth уже включает этот узел) + resulting_depth = node_depth + subtree_depth - 1 + return resulting_depth <= max_d + + # -------- Выбор доминантного/рецессивного родителя (просто случайно) -------- + dom, rec = (p1, p2) if random.random() < 0.5 else (p2, p1) + + # Собираем все узлы с метаданными + dom_nodes = enumerate_nodes_with_meta(dom.root) + rec_nodes = enumerate_nodes_with_meta(rec.root) + + # Пытаемся выбрать совместимые узлы: оба термины ИЛИ оба операции. + # Дадим несколько попыток, затем, если не повезло — возьмём любые. + MAX_TRIES = 64 + chosen_dom = None + chosen_rec = None + + for _ in range(MAX_TRIES): + nd = random.choice(dom_nodes) + # Предпочтём узел того же «класса» + if is_term(nd[0]): + same_type_pool = [nr for nr in rec_nodes if is_term(nr[0])] + elif is_op(nd[0]): + same_type_pool = [nr for nr in rec_nodes if is_op(nr[0])] + else: + same_type_pool = rec_nodes # на всякий + + if same_type_pool: + nr = random.choice(same_type_pool) + + # Если задан max_depth, проверяем, что обмен не приведёт к превышению глубины + if max_depth is not None: + nd_node, _, _, nd_path, nd_depth = nd + nr_node, _, _, nr_path, nr_depth = nr + + # Проверяем обе возможные замены + dom_ok = check_depth_after_swap(nd_depth, nr_node, max_depth) + rec_ok = check_depth_after_swap(nr_depth, nd_node, max_depth) + + if dom_ok and rec_ok: + chosen_dom, chosen_rec = nd, nr + break + # Иначе пробуем другую пару + else: + # Если ограничения нет, принимаем первую подходящую пару + chosen_dom, chosen_rec = nd, nr + break + + # Если подобрать подходящую пару не удалось + if chosen_dom is None or chosen_rec is None: + # Возвращаем клоны родителей без изменений + return (p1.clone(), p2.clone()) + + _, _, _, dom_path, _ = chosen_dom + _, _, _, rec_path, _ = chosen_rec + + # -------- Создаём клоны родителей -------- + c_dom = dom.clone() + c_rec = rec.clone() + + # Выцепляем соответствующие позиции на клонах по тем же путям + c_dom_parent, c_dom_idx, c_dom_node = get_parent_and_index_by_path( + c_dom.root, dom_path + ) + c_rec_parent, c_rec_idx, c_rec_node = get_parent_and_index_by_path( + c_rec.root, rec_path + ) + + # Клонируем поддеревья, чтобы не смешивать ссылки между хромосомами + subtree_dom = c_dom_node.clone() + subtree_rec = c_rec_node.clone() + + # Меняем местами + if c_dom_parent is None: + # Меняем корень + c_dom.root = subtree_rec + else: + c_dom_parent.children[c_dom_idx] = subtree_rec # type: ignore[index] + + if c_rec_parent is None: + c_rec.root = subtree_dom + else: + c_rec_parent.children[c_rec_idx] = subtree_dom # type: ignore[index] + + # Возвращаем потомков в том же порядке, что и вход (p1 -> first, p2 -> second) + if dom is p1: + return (c_dom, c_rec) + else: + return (c_rec, c_dom) diff --git a/lab4/gp/mutations.py b/lab4/gp/mutations.py new file mode 100644 index 0000000..a4383c7 --- /dev/null +++ b/lab4/gp/mutations.py @@ -0,0 +1,143 @@ +import random +from typing import Optional + +from .chromosome import Chromosome +from .operation import Operation +from .terminal import Terminal + + +def mutate_grow(parent: Chromosome, max_depth: int, max_tries: int = 64) -> Chromosome: + """ + Растущая мутация (subtree-growing mutation). + + Аргументы: + parent : исходная хромосома (не изменяется). + max_depth : верхняя граница глубины мутанта. + max_tries : ограничение попыток подбора узла/поддерева. + + Возвращает: + Новый экземпляр Chromosome (мутант). + """ + + # ---------- Вспомогательные ---------- + def enumerate_nodes_with_meta( + root: Chromosome.Node, + ) -> list[ + tuple[Chromosome.Node, Optional[Chromosome.Node], Optional[int], list[int], int] + ]: + """ + (node, parent, index_in_parent, path_from_root, depth_from_root) + depth_from_root: 1 для корня, 2 для детей корня и т.д. + """ + out: list[ + tuple[ + Chromosome.Node, + Optional[Chromosome.Node], + Optional[int], + list[int], + int, + ] + ] = [] + + def dfs( + n: Chromosome.Node, + p: Optional[Chromosome.Node], + idx: Optional[int], + path: list[int], + depth: int, + ) -> None: + out.append((n, p, idx, path, depth)) + for i, ch in enumerate(n.children): + dfs(ch, n, i, path + [i], depth + 1) + + dfs(root, None, None, [], 1) + return out + + def get_parent_and_index_by_path( + root: Chromosome.Node, path: list[int] + ) -> tuple[Optional[Chromosome.Node], Optional[int], Chromosome.Node]: + if not path: + return None, None, root + parent = root + for i in path[:-1]: + parent = parent.children[i] + idx = path[-1] + return parent, idx, parent.children[idx] + + def build_depth_limited_subtree( + chromo: Chromosome, max_depth_limit: int, current_depth: int = 1 + ) -> Chromosome.Node: + """ + Строит случайное поддерево с ограничением по глубине. + current_depth: текущая глубина узла (1 для корня поддерева). + max_depth_limit: максимальная допустимая глубина поддерева. + """ + # Если достигли максимальной глубины — обязательно терминал + if current_depth >= max_depth_limit: + term = random.choice(chromo.terminals) + return Chromosome.Node(term, []) + + # Иначе случайно выбираем между операцией и терминалом + # С большей вероятностью выбираем операцию, если глубина позволяет + if random.random() < 0.7: # 70% вероятность операции + op = random.choice(chromo.operations) + children = [ + build_depth_limited_subtree(chromo, max_depth_limit, current_depth + 1) + for _ in range(op.arity) + ] + return Chromosome.Node(op, children) + else: + term = random.choice(chromo.terminals) + return Chromosome.Node(term, []) + + # ---------- Подготовка ---------- + # Если в дереве только терминал — мутация невозможна (нужен нетерминал) + if isinstance(parent.root.value, Terminal): + return parent.clone() + + # Работаем на клоне + child = parent.clone() + + # Список нетерминальных узлов с путями и глубинами + nodes = enumerate_nodes_with_meta(child.root) + internal = [ + (n, p, i, path, depth) + for (n, p, i, path, depth) in nodes + if isinstance(n.value, Operation) + ] + + if not internal: + # На всякий случай: если всё терминалы + return child + + # ---------- Основной цикл подбора позиции ---------- + for _ in range(max_tries): + node, _, _, path, node_depth = random.choice(internal) + + # Вычисляем максимальную допустимую глубину для нового поддерева + # max_depth - node_depth + 1 (так как node_depth начинается с 1) + allowed_subtree_depth = max_depth - node_depth + 1 + + if allowed_subtree_depth < 1: + # Этот узел слишком глубоко — попробуем другой + continue + + # Строим новое поддерево с ограничением по глубине + new_sub = build_depth_limited_subtree(child, allowed_subtree_depth) + + # Вставляем его на место узла мутации + parent_node, idx, _ = get_parent_and_index_by_path(child.root, path) + if parent_node is None: + child.root = new_sub + else: + parent_node.children[idx] = new_sub # type: ignore[index] + + # Проверяем, что не превысили максимальную глубину + if child.get_depth() <= max_depth: + return child + else: + # Откат: пересоздаём клон + child = parent.clone() + + # Если не удалось подобрать подходящее место/поддерево — вернём немутированного клона + return parent.clone() diff --git a/lab4/gp/ops.py b/lab4/gp/ops.py index d4930ed..b63fdeb 100644 --- a/lab4/gp/ops.py +++ b/lab4/gp/ops.py @@ -41,7 +41,7 @@ def safe_pow(a, b): return float("inf") try: return a**b - except OverflowError, ValueError: + except (OverflowError, ValueError): return float("inf") diff --git a/lab4/tests/__init__.py b/lab4/tests/__init__.py new file mode 100644 index 0000000..e4be4b4 --- /dev/null +++ b/lab4/tests/__init__.py @@ -0,0 +1,2 @@ +# Tests for gp library + diff --git a/lab4/tests/test_chromosome.py b/lab4/tests/test_chromosome.py new file mode 100644 index 0000000..e3fd4ad --- /dev/null +++ b/lab4/tests/test_chromosome.py @@ -0,0 +1,304 @@ +import math +import sys +from pathlib import Path + +import pytest + +# Добавляем путь к родительской директории для корректных импортов +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from gp.chromosome import Chromosome +from gp.operation import Operation +from gp.ops import ADD, COS, DIV, EXP, MUL, NEG, SIN, SUB +from gp.terminal import Terminal + + +class TestChromosomeEval: + """Тесты для метода eval класса Chromosome.""" + + def test_eval_single_terminal(self): + """Тест вычисления хромосомы с одним терминалом.""" + x = Terminal("x") + + def init_single_terminal(chr: Chromosome) -> Chromosome.Node: + return Chromosome.Node(x, []) + + chromosome = Chromosome([ADD], [x], init_single_terminal) + + result = chromosome.eval([5.0]) + assert result == 5.0 + + result = chromosome.eval([-3.5]) + assert result == -3.5 + + def test_eval_addition(self): + """Тест вычисления простого сложения: x + y.""" + x = Terminal("x") + y = Terminal("y") + + def init_addition(chr: Chromosome) -> Chromosome.Node: + # Создаём дерево: x + y + return Chromosome.Node( + ADD, + [ + Chromosome.Node(x, []), + Chromosome.Node(y, []), + ], + ) + + chromosome = Chromosome([ADD], [x, y], init_addition) + + result = chromosome.eval([3.0, 4.0]) + assert result == 7.0 + + result = chromosome.eval([10.0, -5.0]) + assert result == 5.0 + + def test_eval_subtraction(self): + """Тест вычисления вычитания: x - y.""" + x = Terminal("x") + y = Terminal("y") + + def init_subtraction(chr: Chromosome) -> Chromosome.Node: + return Chromosome.Node( + SUB, + [ + Chromosome.Node(x, []), + Chromosome.Node(y, []), + ], + ) + + chromosome = Chromosome([SUB], [x, y], init_subtraction) + + result = chromosome.eval([10.0, 3.0]) + assert result == 7.0 + + result = chromosome.eval([5.0, 8.0]) + assert result == -3.0 + + def test_eval_multiplication(self): + """Тест вычисления умножения: x * y.""" + x = Terminal("x") + y = Terminal("y") + + def init_multiplication(chr: Chromosome) -> Chromosome.Node: + return Chromosome.Node( + MUL, + [ + Chromosome.Node(x, []), + Chromosome.Node(y, []), + ], + ) + + chromosome = Chromosome([MUL], [x, y], init_multiplication) + + result = chromosome.eval([3.0, 4.0]) + assert result == 12.0 + + result = chromosome.eval([-2.0, 5.0]) + assert result == -10.0 + + def test_eval_division(self): + """Тест вычисления деления: x / y.""" + x = Terminal("x") + y = Terminal("y") + + def init_division(chr: Chromosome) -> Chromosome.Node: + return Chromosome.Node( + DIV, + [ + Chromosome.Node(x, []), + Chromosome.Node(y, []), + ], + ) + + chromosome = Chromosome([DIV], [x, y], init_division) + + result = chromosome.eval([10.0, 2.0]) + assert result == 5.0 + + result = chromosome.eval([7.0, 2.0]) + assert result == 3.5 + + def test_eval_division_by_zero(self): + """Тест деления на ноль (должно вернуть inf).""" + x = Terminal("x") + y = Terminal("y") + + def init_division(chr: Chromosome) -> Chromosome.Node: + return Chromosome.Node( + DIV, + [ + Chromosome.Node(x, []), + Chromosome.Node(y, []), + ], + ) + + chromosome = Chromosome([DIV], [x, y], init_division) + + result = chromosome.eval([10.0, 0.0]) + assert result == float("inf") + + def test_eval_unary_negation(self): + """Тест вычисления унарного минуса: -x.""" + x = Terminal("x") + + def init_negation(chr: Chromosome) -> Chromosome.Node: + return Chromosome.Node(NEG, [Chromosome.Node(x, [])]) + + chromosome = Chromosome([NEG], [x], init_negation) + + result = chromosome.eval([5.0]) + assert result == -5.0 + + result = chromosome.eval([-3.0]) + assert result == 3.0 + + def test_eval_sin(self): + """Тест вычисления синуса: sin(x).""" + x = Terminal("x") + + def init_sin(chr: Chromosome) -> Chromosome.Node: + return Chromosome.Node(SIN, [Chromosome.Node(x, [])]) + + chromosome = Chromosome([SIN], [x], init_sin) + + result = chromosome.eval([0.0]) + assert result == pytest.approx(0.0) + + result = chromosome.eval([math.pi / 2]) + assert result == pytest.approx(1.0) + + result = chromosome.eval([math.pi]) + assert result == pytest.approx(0.0, abs=1e-10) + + def test_eval_cos(self): + """Тест вычисления косинуса: cos(x).""" + x = Terminal("x") + + def init_cos(chr: Chromosome) -> Chromosome.Node: + return Chromosome.Node(COS, [Chromosome.Node(x, [])]) + + chromosome = Chromosome([COS], [x], init_cos) + + result = chromosome.eval([0.0]) + assert result == pytest.approx(1.0) + + result = chromosome.eval([math.pi / 2]) + assert result == pytest.approx(0.0, abs=1e-10) + + result = chromosome.eval([math.pi]) + assert result == pytest.approx(-1.0) + + def test_eval_complex_expression(self): + """Тест вычисления сложного выражения: (x + y) * (x - y).""" + x = Terminal("x") + y = Terminal("y") + + def init_complex(chr: Chromosome) -> Chromosome.Node: + # (x + y) * (x - y) + return Chromosome.Node( + MUL, + [ + Chromosome.Node( + ADD, + [ + Chromosome.Node(x, []), + Chromosome.Node(y, []), + ], + ), + Chromosome.Node( + SUB, + [ + Chromosome.Node(x, []), + Chromosome.Node(y, []), + ], + ), + ], + ) + + chromosome = Chromosome([ADD, SUB, MUL], [x, y], init_complex) + + # (5 + 3) * (5 - 3) = 8 * 2 = 16 + result = chromosome.eval([5.0, 3.0]) + assert result == 16.0 + + # (10 + 2) * (10 - 2) = 12 * 8 = 96 + result = chromosome.eval([10.0, 2.0]) + assert result == 96.0 + + def test_eval_nested_expression(self): + """Тест вычисления вложенного выражения: sin(x + y).""" + x = Terminal("x") + y = Terminal("y") + + def init_nested(chr: Chromosome) -> Chromosome.Node: + # sin(x + y) + return Chromosome.Node( + SIN, + [ + Chromosome.Node( + ADD, + [ + Chromosome.Node(x, []), + Chromosome.Node(y, []), + ], + ) + ], + ) + + chromosome = Chromosome([ADD, SIN], [x, y], init_nested) + + result = chromosome.eval([math.pi / 4, math.pi / 4]) + # sin(π/4 + π/4) = sin(π/2) = 1 + assert result == pytest.approx(1.0) + + def test_eval_exp(self): + """Тест вычисления экспоненты: exp(x).""" + x = Terminal("x") + + def init_exp(chr: Chromosome) -> Chromosome.Node: + return Chromosome.Node(EXP, [Chromosome.Node(x, [])]) + + chromosome = Chromosome([EXP], [x], init_exp) + + result = chromosome.eval([0.0]) + assert result == pytest.approx(1.0) + + result = chromosome.eval([1.0]) + assert result == pytest.approx(math.e) + + result = chromosome.eval([2.0]) + assert result == pytest.approx(math.e**2) + + def test_eval_multiple_calls(self): + """Тест многократного вызова eval с разными значениями.""" + x = Terminal("x") + y = Terminal("y") + + def init_mul(chr: Chromosome) -> Chromosome.Node: + return Chromosome.Node( + MUL, + [ + Chromosome.Node(x, []), + Chromosome.Node(y, []), + ], + ) + + chromosome = Chromosome([MUL], [x, y], init_mul) + + # Проверяем, что терминалы правильно обновляются + assert chromosome.eval([2.0, 3.0]) == 6.0 + assert chromosome.eval([4.0, 5.0]) == 20.0 + assert chromosome.eval([10.0, 0.5]) == 5.0 + + def test_eval_without_root_raises_error(self): + """Тест, что eval вызывает ошибку, если root = None.""" + + def init_none(chr: Chromosome) -> Chromosome.Node: + return None # type: ignore + + chromosome = Chromosome([ADD], [Terminal("x")], init_none) + + with pytest.raises(ValueError, match="Chromosome is not initialized"): + chromosome.eval([1.0])