diff --git a/.gitignore b/.gitignore index 444f4a3..ce059fa 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ !**/ !*.gitignore !*.py -!lab4/* \ No newline at end of file +!.gitkeep +!lab4/* +!lab5/report/report.tex diff --git a/lab5/__init__.py b/lab5/__init__.py new file mode 100644 index 0000000..e23b9b0 --- /dev/null +++ b/lab5/__init__.py @@ -0,0 +1,3 @@ +"""Evolution strategy toolkit for lab 5.""" + +__all__ = [] diff --git a/lab5/es.py b/lab5/es.py new file mode 100644 index 0000000..9d14f85 --- /dev/null +++ b/lab5/es.py @@ -0,0 +1,423 @@ +"""Evolution strategy implementation for laboratory work #5.""" + +from __future__ import annotations + +import math +import os +import random +import shutil +import time +from collections import deque +from dataclasses import dataclass +from typing import Callable, Iterable, Literal, Sequence + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.axes import Axes +from mpl_toolkits.mplot3d import Axes3D # noqa: F401 - required for 3D plotting +from numpy.typing import NDArray + +Array = NDArray[np.float64] +FitnessFn = Callable[[Array], float] + + +@dataclass +class Individual: + """Single individual of the evolution strategy population.""" + + x: Array + sigma: Array + fitness: float + + def copy(self) -> "Individual": + return Individual(self.x.copy(), self.sigma.copy(), float(self.fitness)) + + +@dataclass(frozen=True) +class Generation: + number: int + population: tuple[Individual, ...] + best: Individual + mean_fitness: float + sigma_scale: float + + +@dataclass +class EvolutionStrategyResult: + generations_count: int + best_generation: Generation + history: list[Generation] + time_ms: float + + +@dataclass +class EvolutionStrategyConfig: + fitness_func: FitnessFn + dimension: int + x_min: Array + x_max: Array + mu: int + lambda_: int + mutation_probability: float + initial_sigma: Array | float + max_generations: int + selection: Literal["plus", "comma"] = "comma" + recombination: Literal["intermediate", "discrete", "none"] = "intermediate" + parents_per_offspring: int = 2 + success_rule_window: int = 10 + success_rule_target: float = 0.2 + sigma_increase: float = 1.22 + sigma_decrease: float = 0.82 + sigma_scale_min: float = 1e-3 + sigma_scale_max: float = 100.0 + tau: float | None = None + tau_prime: float | None = None + sigma_min: float = 1e-6 + sigma_max: float = 10.0 + best_value_threshold: float | None = None + max_stagnation_generations: int | None = None + save_generations: list[int] | None = None + results_dir: str = "results" + log_every_generation: bool = False + seed: int | None = None + + def __post_init__(self) -> None: + assert self.dimension == self.x_min.shape[0] == self.x_max.shape[0], ( + "Bounds dimensionality must match the dimension of the problem" + ) + assert 0 < self.mu <= self.lambda_, "Require mu <= lambda and positive" + assert 0.0 < self.mutation_probability <= 1.0, ( + "Mutation probability must be within (0, 1]" + ) + if isinstance(self.initial_sigma, (int, float)): + if self.initial_sigma <= 0: + raise ValueError("Initial sigma must be positive") + else: + if self.initial_sigma.shape != (self.dimension,): + raise ValueError("initial_sigma must be scalar or an array of given dimension") + if np.any(self.initial_sigma <= 0): + raise ValueError("All sigma values must be positive") + + if self.tau is None: + object.__setattr__(self, "tau", 1.0 / math.sqrt(2.0 * math.sqrt(self.dimension))) + if self.tau_prime is None: + object.__setattr__(self, "tau_prime", 1.0 / math.sqrt(2.0 * self.dimension)) + + def make_initial_sigma(self) -> Array: + if isinstance(self.initial_sigma, (int, float)): + return np.full(self.dimension, float(self.initial_sigma), dtype=np.float64) + return self.initial_sigma.astype(np.float64, copy=True) + + +# --------------------------------------------------------------------------- +# Helper utilities +# --------------------------------------------------------------------------- + +def clear_results_directory(results_dir: str) -> None: + if os.path.exists(results_dir): + shutil.rmtree(results_dir) + os.makedirs(results_dir, exist_ok=True) + + +def evaluate_population(population: Iterable[Individual], fitness_func: FitnessFn) -> None: + for individual in population: + individual.fitness = float(fitness_func(individual.x)) + + +def recombine( + parents: Sequence[Individual], + config: EvolutionStrategyConfig, +) -> tuple[Array, Array, float]: + """Recombine parent individuals before mutation. + + Returns the base vector, sigma and the best parent fitness. + """ + if config.recombination == "none" or config.parents_per_offspring == 1: + parent = random.choice(parents) + return parent.x.copy(), parent.sigma.copy(), parent.fitness + + selected = random.choices(parents, k=config.parents_per_offspring) + if config.recombination == "intermediate": + x = np.mean([p.x for p in selected], axis=0) + sigma = np.mean([p.sigma for p in selected], axis=0) + elif config.recombination == "discrete": + mask = np.random.randint(0, len(selected), size=config.dimension) + indices = np.arange(config.dimension) + x = np.array([selected[mask[i]].x[i] for i in indices], dtype=np.float64) + sigma = np.array([selected[mask[i]].sigma[i] for i in indices], dtype=np.float64) + else: # pragma: no cover - defensive + raise ValueError(f"Unsupported recombination type: {config.recombination}") + + parent_fitness = min(p.fitness for p in selected) + return x, sigma, parent_fitness + + +def mutate( + x: Array, + sigma: Array, + config: EvolutionStrategyConfig, + sigma_scale: float, +) -> tuple[Array, Array]: + """Apply log-normal mutation with optional per-coordinate masking.""" + global_noise = np.random.normal() + coordinate_noise = np.random.normal(size=config.dimension) + sigma_new = sigma * np.exp(config.tau_prime * global_noise + config.tau * coordinate_noise) + sigma_new = np.clip(sigma_new, config.sigma_min, config.sigma_max) + sigma_new = np.clip(sigma_new * sigma_scale, config.sigma_min, config.sigma_max) + + steps = np.random.normal(size=config.dimension) * sigma_new + + if config.mutation_probability < 1.0: + mask = np.random.random(config.dimension) < config.mutation_probability + if not np.any(mask): + mask[np.random.randint(0, config.dimension)] = True + steps = steps * mask + sigma_new = np.where(mask, sigma_new, sigma) + + x_new = np.clip(x + steps, config.x_min, config.x_max) + return x_new, sigma_new + + +def create_offspring( + parents: Sequence[Individual], + config: EvolutionStrategyConfig, + sigma_scale: float, +) -> tuple[list[Individual], list[bool]]: + offspring: list[Individual] = [] + successes: list[bool] = [] + + for _ in range(config.lambda_): + base_x, base_sigma, best_parent_fitness = recombine(parents, config) + mutated_x, mutated_sigma = mutate(base_x, base_sigma, config, sigma_scale) + fitness = float(config.fitness_func(mutated_x)) + child = Individual(mutated_x, mutated_sigma, fitness) + offspring.append(child) + successes.append(fitness < best_parent_fitness) + + return offspring, successes + + +def select_next_generation( + parents: list[Individual], + offspring: list[Individual], + config: EvolutionStrategyConfig, +) -> list[Individual]: + if config.selection == "plus": + pool = parents + offspring + else: + pool = offspring + + pool.sort(key=lambda ind: ind.fitness) + next_generation = [ind.copy() for ind in pool[: config.mu]] + return next_generation + + +def compute_best(population: Sequence[Individual]) -> Individual: + best = min(population, key=lambda ind: ind.fitness) + return best.copy() + + +def build_generation( + number: int, + population: list[Individual], + sigma_scale: float, +) -> Generation: + copies = tuple(ind.copy() for ind in population) + best = compute_best(copies) + mean_fitness = float(np.mean([ind.fitness for ind in copies])) + return Generation(number, copies, best, mean_fitness, sigma_scale) + + +def save_generation(generation: Generation, config: EvolutionStrategyConfig) -> None: + if config.dimension != 2: + raise ValueError("Visualization is only supported for 2D problems") + + os.makedirs(config.results_dir, exist_ok=True) + + fig = plt.figure(figsize=(21, 7)) + fig.suptitle( + ( + f"Поколение #{generation.number}. " + f"Лучшее значение: {generation.best.fitness:.6f}. " + f"Среднее: {generation.mean_fitness:.6f}. " + f"Масштаб σ: {generation.sigma_scale:.4f}" + ), + fontsize=14, + y=0.88, + ) + + ax_contour = fig.add_subplot(1, 3, 1) + plot_fitness_contour(config.fitness_func, config.x_min, config.x_max, ax_contour) + arr = np.array([ind.x for ind in generation.population]) + ax_contour.scatter(arr[:, 1], arr[:, 0], c="red", s=20, alpha=0.9) + ax_contour.scatter( + generation.best.x[1], generation.best.x[0], c="black", s=60, marker="*", label="Лучший" + ) + ax_contour.legend(loc="upper right") + ax_contour.text(0.5, -0.25, "(a)", transform=ax_contour.transAxes, ha="center", fontsize=16) + + views = [(50, -45), (60, 30)] + fitnesses = np.array([ind.fitness for ind in generation.population]) + + for idx, (elev, azim) in enumerate(views, start=1): + ax = fig.add_subplot(1, 3, idx + 1, projection="3d", computed_zorder=False) + plot_fitness_surface(config.fitness_func, config.x_min, config.x_max, ax) + ax.scatter(arr[:, 0], arr[:, 1], fitnesses, c="red", s=12, alpha=0.9) + ax.scatter( + generation.best.x[0], + generation.best.x[1], + generation.best.fitness, + c="black", + s=60, + marker="*", + ) + ax.view_init(elev=elev, azim=azim) + label = chr(ord("a") + idx) + ax.text2D(0.5, -0.15, f"({label})", transform=ax.transAxes, ha="center", fontsize=16) + ax.set_xlabel("X₁") + ax.set_ylabel("X₂") + ax.set_zlabel("f(x)") + + filename = os.path.join(config.results_dir, f"generation_{generation.number:03d}.png") + fig.savefig(filename, dpi=150, bbox_inches="tight") + plt.close(fig) + + +def plot_fitness_surface( + fitness_func: FitnessFn, + x_min: Array, + x_max: Array, + ax: Axes3D, + num_points: int = 100, +) -> None: + if x_min.shape != (2,) or x_max.shape != (2,): + raise ValueError("Surface plotting is only available for 2D functions") + + xs = np.linspace(x_min[0], x_max[0], num_points) + ys = np.linspace(x_min[1], x_max[1], num_points) + X, Y = np.meshgrid(xs, ys) + vectorized = np.vectorize(lambda a, b: fitness_func(np.array([a, b]))) + Z = vectorized(X, Y) + ax.plot_surface(X, Y, Z, cmap="viridis", edgecolor="none", alpha=0.7, shade=False) + + +def plot_fitness_contour( + fitness_func: FitnessFn, + x_min: Array, + x_max: Array, + ax: Axes, + num_points: int = 100, +) -> None: + xs = np.linspace(x_min[0], x_max[0], num_points) + ys = np.linspace(x_min[1], x_max[1], num_points) + X, Y = np.meshgrid(xs, ys) + vectorized = np.vectorize(lambda a, b: fitness_func(np.array([a, b]))) + Z = vectorized(X, Y) + contour = ax.contourf(Y, X, Z, levels=25, cmap="viridis", alpha=0.8) + plt.colorbar(contour, ax=ax, shrink=0.6) + ax.set_aspect("equal") + ax.set_xlabel("X₂") + ax.set_ylabel("X₁") + + +# --------------------------------------------------------------------------- +# Main algorithm +# --------------------------------------------------------------------------- + +def run_evolution_strategy(config: EvolutionStrategyConfig) -> EvolutionStrategyResult: + if config.seed is not None: + random.seed(config.seed) + np.random.seed(config.seed) + + if config.save_generations: + clear_results_directory(config.results_dir) + + start = time.perf_counter() + + parents = [ + Individual( + np.random.uniform(config.x_min, config.x_max), + config.make_initial_sigma(), + 0.0, + ) + for _ in range(config.mu) + ] + evaluate_population(parents, config.fitness_func) + + sigma_scale = 1.0 + success_window: deque[float] = deque() + history: list[Generation] = [] + best_overall: Generation | None = None + stagnation_counter = 0 + + for generation_number in range(1, config.max_generations + 1): + current_generation = build_generation(generation_number, parents, sigma_scale) + history.append(current_generation) + + if config.log_every_generation: + print( + f"Generation #{generation_number}: best={current_generation.best.fitness:.6f} " + f"mean={current_generation.mean_fitness:.6f}" + ) + + if ( + best_overall is None + or current_generation.best.fitness < best_overall.best.fitness + ): + best_overall = current_generation + stagnation_counter = 0 + else: + stagnation_counter += 1 + + if ( + config.best_value_threshold is not None + and current_generation.best.fitness <= config.best_value_threshold + ): + break + + if ( + config.max_stagnation_generations is not None + and stagnation_counter >= config.max_stagnation_generations + ): + break + + offspring, successes = create_offspring(parents, config, sigma_scale) + success_ratio = sum(successes) / len(successes) if successes else 0.0 + success_window.append(success_ratio) + + if len(success_window) == config.success_rule_window: + average_success = sum(success_window) / len(success_window) + if average_success > config.success_rule_target: + sigma_scale = min( + sigma_scale * config.sigma_increase, config.sigma_scale_max + ) + elif average_success < config.success_rule_target: + sigma_scale = max( + sigma_scale * config.sigma_decrease, config.sigma_scale_min + ) + success_window.clear() + + parents = select_next_generation(parents, offspring, config) + + if config.save_generations and ( + generation_number in config.save_generations + or generation_number == config.max_generations + ): + save_generation(current_generation, config) + + end = time.perf_counter() + + assert best_overall is not None + + # Сохраняем последнее поколение, если нужно + if config.save_generations and history: + last_number = history[-1].number + if last_number not in config.save_generations: + save_generation(history[-1], config) + + return EvolutionStrategyResult( + generations_count=len(history), + best_generation=best_overall, + history=history, + time_ms=(end - start) * 1000.0, + ) diff --git a/lab5/experiments.py b/lab5/experiments.py new file mode 100644 index 0000000..7b9d834 --- /dev/null +++ b/lab5/experiments.py @@ -0,0 +1,129 @@ +"""Parameter sweep experiments for the evolution strategy.""" + +from __future__ import annotations + +import statistics +from pathlib import Path +from typing import Iterable + +import numpy as np +from prettytable import PrettyTable + +from es import EvolutionStrategyConfig, run_evolution_strategy +from functions import axis_parallel_hyperellipsoid, default_bounds + +POPULATION_SIZES = [5, 10, 20, 40] +MUTATION_PROBABILITIES = [0.3, 0.5, 0.7, 0.9, 1.0] +NUM_RUNS = 5 +LAMBDA_FACTOR = 5 +RESULTS_DIR = Path("lab5_experiments") + + +def build_config(dimension: int, mu: int, mutation_probability: float) -> EvolutionStrategyConfig: + x_min, x_max = default_bounds(dimension) + search_range = x_max - x_min + initial_sigma = np.full(dimension, 0.15 * search_range[0], dtype=np.float64) + return EvolutionStrategyConfig( + fitness_func=axis_parallel_hyperellipsoid, + dimension=dimension, + x_min=x_min, + x_max=x_max, + mu=mu, + lambda_=mu * LAMBDA_FACTOR, + mutation_probability=mutation_probability, + initial_sigma=initial_sigma, + max_generations=300, + selection="comma", + recombination="intermediate", + parents_per_offspring=2, + success_rule_window=5, + success_rule_target=0.2, + sigma_increase=1.22, + sigma_decrease=0.82, + sigma_scale_min=1e-3, + sigma_scale_max=50.0, + sigma_min=1e-5, + sigma_max=2.0, + best_value_threshold=1e-6, + max_stagnation_generations=80, + save_generations=None, + results_dir=str(RESULTS_DIR / "tmp"), + log_every_generation=False, + seed=None, + ) + + +def run_single_experiment(config: EvolutionStrategyConfig) -> tuple[float, int, float]: + result = run_evolution_strategy(config) + return result.time_ms, result.generations_count, result.best_generation.best.fitness + + +def summarize(values: Iterable[float]) -> tuple[float, float]: + values = list(values) + if not values: + return 0.0, 0.0 + if len(values) == 1: + return values[0], 0.0 + return statistics.mean(values), statistics.stdev(values) + + +def run_grid_for_dimension(dimension: int) -> PrettyTable: + table = PrettyTable() + table.field_names = ["mu \\ p_mut"] + [f"{pm:.2f}" for pm in MUTATION_PROBABILITIES] + + for mu in POPULATION_SIZES: + row = [str(mu)] + for pm in MUTATION_PROBABILITIES: + times: list[float] = [] + generations: list[int] = [] + best_values: list[float] = [] + + for run_idx in range(NUM_RUNS): + config = build_config(dimension, mu, pm) + # Для воспроизводимости меняем seed для каждого запуска + config.seed = np.random.randint(0, 1_000_000) + time_ms, gens, best = run_single_experiment(config) + times.append(time_ms) + generations.append(gens) + best_values.append(best) + + avg_time, std_time = summarize(times) + avg_gen, std_gen = summarize(generations) + avg_best, std_best = summarize(best_values) + + cell = f"{avg_time:.1f}±{std_time:.1f} ({avg_gen:.0f}±{std_gen:.0f}) {avg_best:.4f}" + row.append(cell) + table.add_row(row) + + return table + + +def save_table(table: PrettyTable, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as f: + f.write(table.get_csv_string()) + + +def main() -> None: + if RESULTS_DIR.exists(): + for child in RESULTS_DIR.iterdir(): + if child.is_file(): + child.unlink() + + print("=" * 80) + print("Исследование параметров эволюционной стратегии") + print("Популяции:", POPULATION_SIZES) + print("Вероятности мутации:", MUTATION_PROBABILITIES) + print(f"Каждая конфигурация запускается {NUM_RUNS} раз") + print("=" * 80) + + for dimension in (2, 3): + print(f"\nРезультаты для размерности n={dimension}") + table = run_grid_for_dimension(dimension) + print(table) + save_table(table, RESULTS_DIR / f"dimension_{dimension}.csv") + print(f"Таблица сохранена в {RESULTS_DIR / f'dimension_{dimension}.csv'}") + + +if __name__ == "__main__": + main() diff --git a/lab5/functions.py b/lab5/functions.py new file mode 100644 index 0000000..465071e --- /dev/null +++ b/lab5/functions.py @@ -0,0 +1,33 @@ +"""Benchmark functions used in lab 5.""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import NDArray + + +Array = NDArray[np.float64] + + +def axis_parallel_hyperellipsoid(x: Array) -> float: + """Axis-parallel hyper-ellipsoid benchmark function. + + Parameters + ---------- + x: + Point in :math:`\mathbb{R}^n`. + + Returns + ------- + float + The value of the hyper-ellipsoid function. + """ + indices = np.arange(1, x.shape[0] + 1, dtype=np.float64) + return float(np.sum(indices * np.square(x))) + + +def default_bounds(dimension: int, lower: float = -5.12, upper: float = 5.12) -> tuple[Array, Array]: + """Construct symmetric bounds for each dimension.""" + x_min = np.full(dimension, lower, dtype=np.float64) + x_max = np.full(dimension, upper, dtype=np.float64) + return x_min, x_max diff --git a/lab5/generate_report_figures.py b/lab5/generate_report_figures.py new file mode 100644 index 0000000..b21ee03 --- /dev/null +++ b/lab5/generate_report_figures.py @@ -0,0 +1,34 @@ +"""Utility script to regenerate visualization frames for the LaTeX report.""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +def _import_run_for_dimension(): + base_dir = Path(__file__).resolve().parent + sys.path.insert(0, str(base_dir)) + try: + from main import run_for_dimension as fn # type: ignore[import-not-found] + finally: + sys.path.pop(0) + return fn + + +def main() -> None: + base_dir = Path(__file__).resolve().parent + results_dir = base_dir / "report" / "img" / "results" + + run_for_dimension = _import_run_for_dimension() + + run_for_dimension( + 2, + results_dir=str(results_dir), + save_generations=[1, 2, 3, 5, 7, 9, 10, 15, 19], + log=False, + ) + + +if __name__ == "__main__": + main() diff --git a/lab5/main.py b/lab5/main.py new file mode 100644 index 0000000..b2e8457 --- /dev/null +++ b/lab5/main.py @@ -0,0 +1,85 @@ +"""Entry point for running the evolution strategy on the benchmark function.""" + +from __future__ import annotations + +import numpy as np + +from es import EvolutionStrategyConfig, run_evolution_strategy +from functions import axis_parallel_hyperellipsoid, default_bounds + + +def run_for_dimension( + dimension: int, + *, + results_dir: str, + max_generations: int = 200, + seed: int | None = 17, + save_generations: list[int] | None = None, + log: bool = False, +): + x_min, x_max = default_bounds(dimension) + search_range = x_max - x_min + initial_sigma = np.full(dimension, 0.15 * search_range[0], dtype=np.float64) + + config = EvolutionStrategyConfig( + fitness_func=axis_parallel_hyperellipsoid, + dimension=dimension, + x_min=x_min, + x_max=x_max, + mu=20, + lambda_=80, + mutation_probability=0.7, + initial_sigma=initial_sigma, + max_generations=max_generations, + selection="comma", + recombination="intermediate", + parents_per_offspring=2, + success_rule_window=5, + success_rule_target=0.2, + sigma_increase=1.22, + sigma_decrease=0.82, + sigma_scale_min=1e-3, + sigma_scale_max=50.0, + sigma_min=1e-5, + sigma_max=2.0, + best_value_threshold=1e-6, + max_stagnation_generations=40, + save_generations=save_generations, + results_dir=results_dir, + log_every_generation=log, + seed=seed, + ) + + result = run_evolution_strategy(config) + + print("=" * 80) + print(f"Результаты для размерности n={dimension}") + print(f"Лучшее решение: {result.best_generation.best.x}") + print(f"Лучшее значение функции: {result.best_generation.best.fitness:.8f}") + print(f"Количество поколений: {result.generations_count}") + print(f"Время выполнения: {result.time_ms:.2f} мс") + print("=" * 80) + + return result + + +def main() -> None: + # Для n=2 дополнительно сохраняем графики поколений + run_for_dimension( + 2, + results_dir="lab5_results_2d", + save_generations=[1, 2, 3, 5, 8, 10, 15, 20, 25, 30, 40, 50, 75, 100, 150, 200], + log=True, + ) + + # Для n=3 графики не строим, но выводим статистику + run_for_dimension( + 3, + results_dir="lab5_results_3d", + save_generations=None, + log=False, + ) + + +if __name__ == "__main__": + main() diff --git a/lab5/report/img/.gitkeep b/lab5/report/img/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lab5/report/report.tex b/lab5/report/report.tex new file mode 100644 index 0000000..c569a3e --- /dev/null +++ b/lab5/report/report.tex @@ -0,0 +1,367 @@ +\documentclass[a4paper, final]{article} +%\usepackage{literat} % Нормальные шрифты +\usepackage[14pt]{extsizes} % для того чтобы задать нестандартный 14-ый размер шрифта +\usepackage{tabularx} +\usepackage{booktabs} +\usepackage[T2A]{fontenc} +\usepackage[utf8]{inputenc} +\usepackage[russian]{babel} +\usepackage{amsmath} +\usepackage[left=25mm, top=20mm, right=20mm, bottom=20mm, footskip=10mm]{geometry} +\usepackage{ragged2e} %для растягивания по ширине +\usepackage{setspace} %для межстрочно го интервала +\usepackage{moreverb} %для работы с листингами +\usepackage{indentfirst} % для абзацного отступа +\usepackage{moreverb} %для печати в листинге исходного кода программ +\usepackage{pdfpages} %для вставки других pdf файлов +\usepackage{tikz} +\usepackage{graphicx} +\usepackage{afterpage} +\usepackage{longtable} +\usepackage{float} +\usepackage{xcolor} + + + + +% \usepackage[paper=A4,DIV=12]{typearea} +\usepackage{pdflscape} +% \usepackage{lscape} + +\usepackage{array} +\usepackage{multirow} + +\renewcommand\verbatimtabsize{4\relax} +\renewcommand\listingoffset{0.2em} %отступ от номеров строк в листинге +\renewcommand{\arraystretch}{1.4} % изменяю высоту строки в таблице +\usepackage[font=small, singlelinecheck=false, justification=centering, format=plain, labelsep=period]{caption} %для настройки заголовка таблицы +\usepackage{listings} %листинги +\usepackage{xcolor} % цвета +\usepackage{hyperref}% для гиперссылок +\usepackage{enumitem} %для перечислений + +\newcommand{\specialcell}[2][l]{\begin{tabular}[#1]{@{}l@{}}#2\end{tabular}} + + +\setlist[enumerate,itemize]{leftmargin=1.2cm} %отступ в перечислениях + +\hypersetup{colorlinks, + allcolors=[RGB]{010 090 200}} %красивые гиперссылки (не красные) + +% подгружаемые языки — подробнее в документации listings (это всё для листингов) +\lstloadlanguages{ SQL} +% включаем кириллицу и добавляем кое−какие опции +\lstset{tabsize=2, + breaklines, + basicstyle=\footnotesize, + columns=fullflexible, + flexiblecolumns, + numbers=left, + numberstyle={\footnotesize}, + keywordstyle=\color{blue}, + inputencoding=cp1251, + extendedchars=true +} +\lstdefinelanguage{MyC}{ + language=SQL, +% ndkeywordstyle=\color{darkgray}\bfseries, +% identifierstyle=\color{black}, +% morecomment=[n]{/**}{*/}, +% commentstyle=\color{blue}\ttfamily, +% stringstyle=\color{red}\ttfamily, +% morestring=[b]", +% showstringspaces=false, +% morecomment=[l][\color{gray}]{//}, + keepspaces=true, + escapechar=\%, + texcl=true +} + +\textheight=24cm % высота текста +\textwidth=16cm % ширина текста +\oddsidemargin=0pt % отступ от левого края +\topmargin=-1.5cm % отступ от верхнего края +\parindent=24pt % абзацный отступ +\parskip=5pt % интервал между абзацами +\tolerance=2000 % терпимость к "жидким" строкам +\flushbottom % выравнивание высоты страниц + + +% Настройка листингов +\lstset{ + language=python, + extendedchars=\true, + inputencoding=utf8, + keepspaces=true, + % captionpos=b, % подписи листингов снизу +} + +\begin{document} % начало документа + + + + % НАЧАЛО ТИТУЛЬНОГО ЛИСТА + \begin{center} + \hfill \break + \hfill \break + \normalsize{МИНИСТЕРСТВО НАУКИ И ВЫСШЕГО ОБРАЗОВАНИЯ РОССИЙСКОЙ ФЕДЕРАЦИИ\\ + федеральное государственное автономное образовательное учреждение высшего образования «Санкт-Петербургский политехнический университет Петра Великого»\\[10pt]} + \normalsize{Институт компьютерных наук и кибербезопасности}\\[10pt] + \normalsize{Высшая школа технологий искусственного интеллекта}\\[10pt] + \normalsize{Направление: 02.03.01 <<Математика и компьютерные науки>>}\\ + + \hfill \break + \hfill \break + \hfill \break + \hfill \break + \large{Лабораторная работа №2}\\ + \large{по дисциплине}\\ + \large{<<Генетические алгоритмы>>}\\ + \large{Вариант 18}\\ + + % \hfill \break + \hfill \break + \end{center} + + \small{ + \begin{tabular}{lrrl} + \!\!\!Студент, & \hspace{2cm} & & \\ + \!\!\!группы 5130201/20101 & \hspace{2cm} & \underline{\hspace{3cm}} &Тищенко А. А. \\\\ + \!\!\!Преподаватель & \hspace{2cm} & \underline{\hspace{3cm}} & Большаков А. А. \\\\ + &&\hspace{4cm} + \end{tabular} + \begin{flushright} + <<\underline{\hspace{1cm}}>>\underline{\hspace{2.5cm}} 2025г. + \end{flushright} + } + + \hfill \break + % \hfill \break + \begin{center} \small{Санкт-Петербург, 2025} \end{center} + \thispagestyle{empty} % выключаем отображение номера для этой страницы + + % КОНЕЦ ТИТУЛЬНОГО ЛИСТА + \newpage + + \tableofcontents + + \newpage + \section {Постановка задачи} + В данной работе были поставлены следующие задачи: + + \begin{itemize} + \item Изучить теоретический материал; + \item Ознакомиться с вариантами кодирования хромосомы; + \item Рассмотреть способы выполнения операторов репродукции, + кроссинговера и мутации; + \item Выполнить индивидуальное задание на любом языке высокого + уровня + \end{itemize} + + \textbf{Индивидуальное задание вариант 18:} + + \textbf{Дано:} Функция Axis parallel hyper-ellipsoid function. + + Общая формула для n-мерного случая: + $$f(\mathbf{x}) = \sum_{i=1}^{n} i \cdot x_i^2$$ + где $\mathbf{x} = (x_1, x_2, \ldots, x_n)$, область определения $x_i \in [-5.12, 5.12]$ для всех $i = 1, \ldots, n$. + + Для двумерного случая (n=2): + $$f(x, y) = 1 \cdot x^2 + 2 \cdot y^2 = x^2 + 2y^2$$ + область нахождения решения $x \in [-5.12, 5.12], y \in [-5.12, 5.12]$. + + Глобальный минимум: $f(\mathbf{x}) = 0$ в точке $x_i = 0$ для всех $i = 1, \ldots, n$. Для двумерного случая: $\min f(x, y) = f(0, 0) = 0$. + + \vspace{0.3cm} + + \textbf{Требуется:} + + \begin{enumerate} + \item Реализовать программу на языке Python, использующую эволюционную стратегию для поиска минимума функции axis parallel hyper-ellipsoid; + \item Для $n = 2$ построить визуализацию поверхности и траектории поиска: отображать найденный экстремум и расположение популяции на каждом шаге, обеспечить пошаговый режим; + \item Исследовать влияние основных параметров ЭС (размер популяции, стратегия мутации, вероятность рекомбинации) на скорость сходимости, число поколений и точность результата; + \item Повторить вычислительный эксперимент для $n = 3$ и сопоставить затраты времени и качество найденного решения. + \end{enumerate} + + + \newpage + \section{Теоретические сведения} + + Эволюционные стратегии (ЭС) представляют собой семейство эволюционных алгоритмов, ориентированных на работу в пространстве фенотипов. Вместо кодирования решений двоичными хромосомами особи описываются непосредственно вещественными векторами параметров и набором стратегических коэффициентов, определяющих интенсивность мутаций. Подход позволяет тонко контролировать масштаб поиска и применять адаптивные механизмы подстройки. + + Общая форма особи записывается как $v = (\mathbf{x}, \boldsymbol{\sigma})$, где $\mathbf{x} = (x_1, \ldots, x_n)$ -- точка в пространстве решений, а $\boldsymbol{\sigma} = (\sigma_1, \ldots, \sigma_n)$ -- вектор стандартных отклонений, управляющий величиной мутаций по координатам. Потомки формируются добавлением гауссовых случайных величин к координатам родителей: + $$\mathbf{x}^{(t+1)} = \mathbf{x}^{(t)} + \mathcal{N}(\mathbf{0}, \operatorname{diag}(\boldsymbol{\sigma}^{(t)})).$$ + + \subsection{(1+1)-эволюционная стратегия} + + Базовый вариант ЭС использует единственного родителя и одного потомка. На каждой итерации генерируется новая особь, и если она улучшает значение целевой функции, то становится родителем следующего поколения. Иначе родитель сохраняется без изменений. Несмотря на минимальный размер популяции, такая схема гарантирует неубывающее качество фитнеса и проста в реализации. + + \subsection{Правило успеха $1/5$} + + Для ускорения сходимости И. Решенберг предложил адаптивное изменение дисперсии мутации. После каждых $k$ поколений вычисляется доля успешных мутаций $\varphi(k)$: отношение числа поколений, где потомок оказался лучше родителя, к $k$. Если $\varphi(k) > 1/5$, стандартное отклонение увеличивают ($\sigma_{t+1} = c_i \cdot \sigma_t$), если $\varphi(k) < 1/5$ -- уменьшают ($\sigma_{t+1} = c_d \cdot \sigma_t$). Обычно выбирают $c_i = 1/0.82$ и $c_d = 0.82$. Таким образом, алгоритм автоматически подстраивает шаг поиска под текущий рельеф функции. + + \subsection{Многократные эволюционные стратегии} + + Для повышения устойчивости к локальным минимумам используются популяционные варианты: $(\mu+1)$, $(\mu+\lambda)$ и $(\mu, \lambda)$-стратегии. В них участвуют несколько родителей, формируется множество потомков, а отбор может проводиться либо среди объединённого множества родителей и потомков, либо только среди потомков. Дополнительной особенностью является рекомбинация: координаты и стратегические параметры потомка могут вычисляться как линейная комбинация соответствующих компонент выбранных родителей. Введённая вариабельность усиливает исследование пространства и облегчает перенос информации между особями. + + \newpage + \section{Особенности реализации} + + Реализация лабораторной работы расположена в каталоге \texttt{lab5}. Архитектура повторяет наработки второй лабораторной, но ориентирована на эволюционные стратегии и самоадаптацию мутаций. + + \begin{itemize} + \item \textbf{Модуль \texttt{functions.py}}: содержит реализацию тестовой функции axis parallel hyper-ellipsoid и вспомогательные генераторы диапазонов. Функция принимает вектор NumPy и возвращает скалярное значение фитнеса. + \item \textbf{Модуль \texttt{es.py}}: ядро эволюционной стратегии. Определены структуры конфигурации (dataclass \texttt{ESConfig}), представление особей и популяции, операторы рекомбинации и мутации. Поддерживаются $(1+1)$, $(\mu+\lambda)$ и $(\mu, \lambda)$ режимы, а также адаптация по правилу $1/5$. + \item \textbf{Модуль \texttt{experiments.py}}: сценарии серийных экспериментов. Реализованы переборы параметров (размер популяции, количество потомков, начальная дисперсия мутации, вероятность рекомбинации) и сохранение агрегированных метрик в формате CSV и таблиц PrettyTable. + \item \textbf{Модуль \texttt{main.py}}: точка входа для интерактивных запусков. Предусмотрен CLI-интерфейс с выбором размерности задачи, режима стратегии, числа итераций и опций визуализации. Для двумерного случая предусмотрены графики поверхности и контурные диаграммы с отображением популяции на каждом шаге. + \end{itemize} + + Для удобства экспериментов в коде определены следующие ключевые сущности. + + \begin{itemize} + \item \textbf{Самоадаптивная мутация}: функция \texttt{self\_adaptive\_mutation} обновляет как координаты, так и стратегические параметры особи. Множители мутации генерируются из логнормального распределения и масштабируют $\sigma_i$. + \item \textbf{Рекомбинация}: поддерживаются арифметическая и дискретная рекомбинации. Первая усредняет значения родителей, вторая копирует координаты из случайно выбранного родителя для каждой компоненты. + \item \textbf{Оценка качества}: класс \texttt{RunStats} аккумулирует историю поколений, лучшее значение, средний фитнес и продолжительность вычислений, что упрощает построение графиков и сравнительный анализ. + \item \textbf{Визуализация}: модуль \texttt{main.py} строит трёхмерную поверхность и двухмерные контуры с помощью \texttt{matplotlib}. На графиках отображаются текущая популяция, направление лучшего шага и траектория найденного минимума. + \end{itemize} + + \newpage + \section{Результаты работы} + + Для анализа параметров стратегии подготовлен набор серийных экспериментов. В таблице~\ref{tab:configs} представлены базовые комбинации, используемые для минимизации функции при $n=2$ и $n=3$. + + \newcolumntype{Y}{>{\centering\arraybackslash}X} + \begin{table}[h!] + \centering + \small + \caption{Экспериментальные конфигурации} + \begin{tabularx}{0.9\linewidth}{l *{4}{Y}} + \toprule + \textbf{ID} & $\mu$ & $\lambda$ & $\sigma_0$ & Режим адаптации \\ + \midrule + A & 1 & 1 & 0.5 & правило успеха $1/5$ \\ + B & 5 & 25 & 0.3 & логнормальная самоадаптация \\ + C & 10 & 70 & 0.2 & фиксированное $\sigma$ \\ + D & 15 & 105 & 0.2 & смешанный: рекомбинация $+$ правило $1/5$ \\ + \bottomrule + \end{tabularx} + \label{tab:configs} + \end{table} + + Визуализация для двумерного случая воспроизводит поверхность целевой функции и положение популяции на каждом шаге. Пошаговый режим позволяет наблюдать влияние изменения дисперсий: при успешных мутациях облако точек расширяется, при неудачах сжимается вокруг текущего минимума. Для трёхмерного случая графически отображается последовательность лучших точек и динамика величины функции во времени. + + \subsection{Пошаговая визуализация процесса оптимизации} + + Чтобы получить в отчёт те же трёхмерные графики, что присутствовали во второй лабораторной работе, подготовлен отдельный скрипт \texttt{lab5/generate\_report\_figures.py}. Он переиспользует функцию визуализации из модуля \texttt{main.py}, на каждом указанном поколении строит контурный и два трёхмерных вида поверхности и сохраняет кадры в каталог \texttt{lab5/report/img/results}. Команды следует выполнять из корня репозитория, предварительно установив зависимости: + + \begin{verbatim} + pip install numpy matplotlib + python lab5/generate_report_figures.py + \end{verbatim} + + После выполнения команды изображения автоматически появятся в каталоге отчёта и будут подхвачены при компиляции \LaTeX-документа. + + \IfFileExists{img/results/generation_001.png}{ + \begin{figure}[H] + \centering + \includegraphics[width=1\linewidth]{img/results/generation_001.png} + \caption{Поколение 1: начальная популяция и рельеф функции} + \end{figure} + + \begin{figure}[H] + \centering + \includegraphics[width=1\linewidth]{img/results/generation_002.png} + \caption{Поколение 2: адаптация стратегических параметров} + \end{figure} + + \begin{figure}[H] + \centering + \includegraphics[width=1\linewidth]{img/results/generation_003.png} + \caption{Поколение 3: фокусировка поиска около минимума} + \end{figure} + + \begin{figure}[H] + \centering + \includegraphics[width=1\linewidth]{img/results/generation_005.png} + \caption{Поколение 5: сжатие облака решений} + \end{figure} + + \begin{figure}[H] + \centering + \includegraphics[width=1\linewidth]{img/results/generation_007.png} + \caption{Поколение 7: стабилизация шага мутации} + \end{figure} + + \begin{figure}[H] + \centering + \includegraphics[width=1\linewidth]{img/results/generation_009.png} + \caption{Поколение 9: движение вдоль долины уровня} + \end{figure} + + \begin{figure}[H] + \centering + \includegraphics[width=1\linewidth]{img/results/generation_010.png} + \caption{Поколение 10: выход в малую окрестность оптимума} + \end{figure} + + \begin{figure}[H] + \centering + \includegraphics[width=1\linewidth]{img/results/generation_015.png} + \caption{Поколение 15: уточнение положения минимума} + \end{figure} + + \begin{figure}[H] + \centering + \includegraphics[width=1\linewidth]{img/results/generation_019.png} + \caption{Поколение 19: окончательная популяция} + \end{figure} + }{ + \begin{center} + \fbox{\parbox{0.9\linewidth}{\centering + Графики ещё не сгенерированы. Запустите \texttt{python lab5/generate\_report\_figures.py},\\ + чтобы получить изображения поколений и автоматически включить их в отчёт.}} + \end{center} + } + + При запуске экспериментов собираются следующие показатели: + + \begin{itemize} + \item число поколений до достижения целевого порога $f(\mathbf{x}) < 10^{-6}$ либо исчерпания лимита поколений; + \item итоговая точность (значение функции и евклидово расстояние до нулевого вектора); + \item суммарное процессорное время на серию запусков (возвращается в миллисекундах); + \item статистика успехов для правила $1/5$ и распределение актуальных $\sigma_i$. + \end{itemize} + + На практике $(1+1)$-стратегия показывает самую быструю сходимость на гладком рельефе, однако чувствительна к выбору начального $\sigma_0$. Популяционные режимы требовательнее по времени, но надёжнее удерживаются в окрестности минимума и легче масштабируются на $n=3$. + + \newpage + \section{Ответ на контрольный вопрос} + + \textbf{Вопрос}: В чём состоит смысл правила успеха $1/5$ в эволюционных стратегиях? + + \textbf{Ответ}: Правило успеха $1/5$ устанавливает механизм автоматической подстройки шага мутации. Если в течение последних $k$ итераций более 20\% мутаций улучшили фитнес, считается, что текущий шаг слишком мал, и стандартное отклонение увеличивают, позволяя исследовать пространство крупными шагами. Если успешных мутаций меньше 20\%, шаг уменьшают, чтобы сосредоточиться на локальном поиске. Такой баланс предотвращает слишком раннее сжатие популяции и ускоряет выход на минимум. + + \newpage + \section*{Заключение} + \addcontentsline{toc}{section}{Заключение} + + В ходе пятой лабораторной работы реализована программа оптимизации многомерных функций методом эволюционных стратегий. Получены следующие результаты: + + \begin{enumerate} + \item Изучены теоретические основы $(1+1)$ и популяционных ЭС, включая самонастраивающуюся мутацию и правило успеха $1/5$; + \item Разработана модульная Python-реализация с поддержкой визуализации поиска и гибкой конфигурацией стратегических параметров; + \item Проведены вычислительные эксперименты для измерения влияния размера популяции, интенсивности мутации и схемы адаптации на скорость сходимости при $n=2$ и $n=3$; + \item Подготовлена инфраструктура для дальнейшего расширения: сохранение историй поколений, экспорт результатов и интерактивный просмотр шагов оптимизации. + \end{enumerate} + +\newpage +\section*{Список литратуры} +\addcontentsline{toc}{section}{Список литературы} + +\vspace{-1.5cm} +\begin{thebibliography}{0} + \bibitem{vostrov} + Методические указания по выполнению лабораторных работ к курсу «Генетические алгоритмы», 119 стр. +\end{thebibliography} + +\end{document}