lab2 предварительная версия

This commit is contained in:
2025-10-08 14:23:02 +03:00
parent 27f4a8f2d8
commit 42387c70cb
2 changed files with 451 additions and 0 deletions

422
lab2/gen.py Normal file
View File

@@ -0,0 +1,422 @@
import os
import random
import shutil
import time
from copy import deepcopy
from dataclasses import dataclass
from typing import Callable
import numpy as np
import plotly.graph_objects as go
from matplotlib import pyplot as plt
from matplotlib.axes import Axes
from mpl_toolkits.mplot3d import Axes3D
from numpy.typing import NDArray
type Chromosome = NDArray[np.float64]
type Population = list[Chromosome]
type Fitnesses = NDArray[np.float64]
type FitnessFn = Callable[[Chromosome], Fitnesses]
type CrossoverFn = Callable[[Chromosome, Chromosome], tuple[Chromosome, Chromosome]]
type MutationFn = Callable[[Chromosome], Chromosome]
@dataclass
class GARunConfig:
x_min: Chromosome
x_max: Chromosome
fitness_func: FitnessFn
pop_size: int # размер популяции
pc: float # вероятность кроссинговера
pm: float # вероятность мутации
max_generations: int # максимальное количество поколений
seed: int | None = None # seed для генератора случайных чисел
minimize: bool = False # если True, ищем минимум вместо максимума
save_generations: list[int] | None = (
None # индексы поколений для сохранения графиков
)
results_dir: str = "results" # папка для сохранения графиков
fitness_avg_threshold: float | None = (
None # порог среднего значения фитнес функции для остановки
)
@dataclass(frozen=True)
class Generation:
number: int
best: Chromosome
best_fitness: float
population: Population
fitnesses: Fitnesses
@dataclass(frozen=True)
class GARunResult:
generations_count: int
best_generation: Generation
history: list[Generation]
time_ms: float
def initialize_population(
pop_size: int, x_min: Chromosome, x_max: Chromosome
) -> Population:
"""Инициализирует популяцию случайными векторами из заданного диапазона."""
return [np.random.uniform(x_min, x_max, x_min.shape) for _ in range(pop_size)]
def reproduction(population: Population, fitnesses: Fitnesses) -> Population:
"""Репродукция (селекция) методом рулетки.
Чем больше значение фитнеса, тем больше вероятность выбора особи. Для минимизации
значения фитнеса нужно предварительно инвертировать.
"""
# Чтобы работать с отрицательными f, сдвигаем значения фитнес функции на минимальное
# значение в популяции. Вычитаем min_fit, т. к. min_fit может быть отрицательным.
min_fit = np.min(fitnesses)
shifted_fitnesses = fitnesses - min_fit + 1e-12
# Получаем вероятности для каждой особи
probs = shifted_fitnesses / np.sum(shifted_fitnesses)
cum = np.cumsum(probs)
# Выбираем особей методом рулетки
selected = []
for _ in population:
r = np.random.random()
idx = int(np.searchsorted(cum, r, side="left"))
selected.append(population[idx])
return selected
def arithmetical_crossover_fn(
p1: Chromosome, p2: Chromosome, w: float = 0.5
) -> tuple[Chromosome, Chromosome]:
"""Арифметический кроссинговер."""
h1 = w * p1 + (1 - w) * p2
h2 = (1 - w) * p1 + w * p2
return h1, h2
def geometrical_crossover_fn(
p1: Chromosome, p2: Chromosome, w: float = 0.5
) -> tuple[Chromosome, Chromosome]:
"""Геометрический кроссинговер."""
h1 = np.power(p1, w) * np.power(p2, 1 - w)
h2 = np.power(p2, w) * np.power(p1, 1 - w)
return h1, h2
def crossover(
population: Population,
pc: float,
crossover_fn: CrossoverFn,
) -> Population:
"""Оператор кроссинговера (скрещивания) выполняется с заданной вероятностью pc.
Две хромосомы (родители) выбираются случайно из промежуточной популяции.
Если популяция нечетного размера, то последняя хромосома скрещивается со случайной
другой хромосомой из популяции. В таком случае одна из хромосом может поучаствовать
в кроссовере дважды.
"""
# Создаем копию популяции и перемешиваем её для случайного выбора пар
shuffled_population = population.copy()
np.random.shuffle(shuffled_population)
next_population = []
pop_size = len(shuffled_population)
for i in range(0, pop_size, 2):
p1 = shuffled_population[i]
p2 = shuffled_population[(i + 1) % pop_size]
if np.random.random() <= pc:
p1, p2 = crossover_fn(p1, p2)
next_population.append(p1)
next_population.append(p2)
return next_population[:pop_size]
def build_random_mutation_fn(x_min: Chromosome, x_max: Chromosome) -> MutationFn:
"""Создаёт функцию случайной мутации."""
def mutation_fn(chrom: Chromosome) -> Chromosome:
chrom_new = chrom.copy()
k = np.random.randint(0, chrom_new.shape[0])
chrom_new[k] = np.random.uniform(x_min[k], x_max[k])
return chrom_new
return mutation_fn
def mutation(population: Population, pm: float, mutation_fn: MutationFn) -> Population:
"""Мутация происходит с вероятностью pm."""
next_population = []
for chrom in population:
next_population.append(
mutation_fn(chrom) if np.random.random() <= pm else chrom
)
return next_population
def clear_results_directory(results_dir: str) -> None:
"""Очищает папку с результатами перед началом эксперимента."""
if os.path.exists(results_dir):
shutil.rmtree(results_dir)
os.makedirs(results_dir, exist_ok=True)
def eval_population(population: Population, fitness_func: FitnessFn) -> Fitnesses:
return np.array([fitness_func(chrom) for chrom in population])
def plot_fitness_surface(
fitness_func: FitnessFn,
x_min: Chromosome,
x_max: Chromosome,
ax: Axes3D,
num_points: int = 100,
) -> None:
"""Рисует поверхность функции фитнеса в 3D."""
assert (
x_min.shape == x_max.shape == (2,)
), "Рисовать графики можно только для функции от двух переменных"
X = np.linspace(x_min[0], x_max[0], num_points)
Y = np.linspace(x_min[1], x_max[1], num_points)
X, Y = np.meshgrid(X, Y)
vectorized_fitness = np.vectorize(lambda x, y: fitness_func(np.array([x, y])))
Z = vectorized_fitness(X, Y)
return ax.plot_surface(
X, Y, Z, cmap="viridis", edgecolor="none", alpha=0.7, shade=False
)
def plot_fitness_contour(
fitness_func: FitnessFn,
x_min: Chromosome,
x_max: Chromosome,
ax: Axes,
num_points: int = 100,
) -> None:
"""Рисует контурный график функции фитнеса в 2D."""
X = np.linspace(x_min[0], x_max[0], num_points)
Y = np.linspace(x_min[1], x_max[1], num_points)
X, Y = np.meshgrid(X, Y)
vectorized_fitness = np.vectorize(lambda x, y: fitness_func(np.array([x, y])))
Z = vectorized_fitness(X, Y)
# Рисуем контуры
# X и Y поменяны местами для единообразия с 3D графиками, там ось Y изображена
# горизонтально из-за особенностей функции в моём варианте
contourf = ax.contourf(Y, X, Z, levels=20, cmap="viridis", alpha=0.7)
ax.set_xlabel("Y")
ax.set_ylabel("X")
# Добавляем цветовую шкалу
plt.colorbar(contourf, ax=ax, shrink=0.5)
# По умолчанию matplotlib пытается растянуть график по оси Y, тут мы это отключаем
ax.set_aspect("equal")
def save_generation(
generation: Generation, history: list[Generation], config: GARunConfig
) -> None:
"""Сохраняем графики поколения.
Функция не самая универсальная, тут есть хардкод, однако для большинства вариантов
должна работать и так.
"""
assert (
config.x_min.shape == config.x_max.shape == (2,)
), "Рисовать графики можно только для функции от двух переменных"
os.makedirs(config.results_dir, exist_ok=True)
fig = plt.figure(figsize=(21, 7))
fig.suptitle(
f"Поколение #{generation.number}. "
f"Лучшая особь: {generation.best_fitness:.4f}. "
f"Среднее значение: {np.mean(generation.fitnesses):.4f}",
fontsize=14,
y=0.85,
)
# Контурный график (как вид сверху)
ax1 = fig.add_subplot(1, 3, 1)
plot_fitness_contour(config.fitness_func, config.x_min, config.x_max, ax1)
# Популяция на контурном графике
arr = np.array(generation.population)
# Координаты специально поменяны местами (см. plot_fitness_contour)
ax1.scatter(
arr[:, 1],
arr[:, 0],
c="red",
marker="o",
alpha=0.9,
s=20,
)
# Подпись под первым графиком
ax1.text(
0.5,
-0.3,
"(a)",
transform=ax1.transAxes,
ha="center",
fontsize=16,
)
# 3D графики с разных ракурсов
views_3d = [
# (elev, azim)
(50, 0),
(50, 15),
]
for i, (elev, azim) in enumerate(views_3d):
ax = fig.add_subplot(1, 3, i + 2, projection="3d", computed_zorder=False)
plot_fitness_surface(config.fitness_func, config.x_min, config.x_max, ax)
ax.set_xlabel("X")
ax.set_ylabel("Y")
ax.set_zlabel("f(X, Y)")
ax.scatter(
arr[:, 0],
arr[:, 1],
generation.fitnesses + 1, # type: ignore
c="red",
s=10,
marker="o",
alpha=0.9,
)
# Устанавливаем угол обзора
ax.view_init(elev=elev, azim=azim)
# Подпись под 3D графиками
label = chr(ord("b") + i) # 'b' для i=0, 'c' для i=1
ax.text2D(
0.5,
-0.15,
f"({label})",
transform=ax.transAxes,
ha="center",
fontsize=16,
)
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")
# Можно раскомментировать, чтобы подобрать более удачные ракурсы
# в интерактивном режиме
# fig.show()
# plt.pause(1000)
plt.close(fig)
def genetic_algorithm(config: GARunConfig) -> GARunResult:
if config.seed is not None:
random.seed(config.seed)
np.random.seed(config.seed)
if config.save_generations:
clear_results_directory(config.results_dir)
population = initialize_population(config.pop_size, config.x_min, config.x_max)
start = time.perf_counter()
history: list[Generation] = []
best: Generation | None = None
generation_number = 1
while True:
# Вычисляем фитнес для всех особей в популяции
fitnesses = eval_population(population, config.fitness_func)
# Находим лучшую особь в поколении
best_index = (
int(np.argmin(fitnesses)) if config.minimize else int(np.argmax(fitnesses))
)
# Добавляем эпоху в историю
current = Generation(
number=generation_number,
best=population[best_index],
best_fitness=fitnesses[best_index],
population=deepcopy(population),
fitnesses=deepcopy(fitnesses),
)
history.append(current)
# Обновляем лучшую эпоху
if (
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.variance_threshold is not None:
# fitness_variance = np.var(fitnesses)
# if fitness_variance < config.variance_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)
save_generation(current, history, config)
if stop_algorithm:
break
# селекция (для минимума инвертируем знак)
parents = reproduction(
population, fitnesses if not config.minimize else -fitnesses
)
# кроссинговер попарно
next_population = crossover(parents, config.pc, arithmetical_crossover_fn)
# мутация
next_population = mutation(
next_population,
config.pm,
build_random_mutation_fn(config.x_min, config.x_max),
)
population = next_population[: config.pop_size]
generation_number += 1
end = time.perf_counter()
assert best is not None, "Best was never set"
return GARunResult(
len(history),
best,
history,
(end - start) * 1000.0,
)

29
lab2/main.py Normal file
View File

@@ -0,0 +1,29 @@
import matplotlib.pyplot as plt
import numpy as np
from gen import GARunConfig, genetic_algorithm
def fitness_function(chromosome: np.ndarray) -> np.ndarray:
return chromosome[0] ** 2 + 2 * chromosome[1] ** 2
config = GARunConfig(
x_min=np.array([-5.12, -5.12]),
x_max=np.array([5.12, 5.12]),
fitness_func=fitness_function,
max_generations=200,
pop_size=25,
pc=0.5,
pm=0.01,
minimize=True,
seed=17,
save_generations=[1, 2, 3, 5, 7, 10, 15, 20, 50, 100],
)
result = genetic_algorithm(config)
# Выводим результаты
print(f"Лучшая особь: {result.best_generation.best}")
print(f"Лучшее значение фитнеса: {result.best_generation.best_fitness:.6f}")
print(f"Количество поколений: {result.generations_count}")
print(f"Время выполнения: {result.time_ms:.2f} мс")