"""
Population dynamics visualization module for the Crested Porcupine Optimizer (CPO).
This module provides specialized visualization tools for the population dynamics
of the CPO algorithm, including cyclic population reduction and diversity visualization.
"""
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
import matplotlib.gridspec as gridspec
from matplotlib.colors import LinearSegmentedColormap
from typing import List, Tuple, Optional, Union, Dict, Any, Callable
[docs]def plot_population_reduction_strategies(
max_iter: int,
pop_size: int,
cycles: int,
strategies: List[str] = ['linear', 'cosine', 'exponential'],
figsize: Tuple[int, int] = (12, 6),
save_path: Optional[str] = None
):
"""
Plot and compare different population reduction strategies.
Parameters
----------
max_iter : int
Maximum number of iterations.
pop_size : int
Initial population size.
cycles : int
Number of cycles.
strategies : list, optional
List of reduction strategies to compare (default: ['linear', 'cosine', 'exponential']).
figsize : tuple, optional
Figure size as (width, height) in inches (default: (12, 6)).
save_path : str, optional
Path to save the figure. If None, the figure is not saved (default: None).
Returns
-------
matplotlib.figure.Figure
The created figure.
"""
# Create the figure and axis
fig, ax = plt.subplots(figsize=figsize)
# Define colors for different strategies
strategy_colors = {
'linear': 'blue',
'cosine': 'green',
'exponential': 'red'
}
# Generate iterations
iterations = np.arange(max_iter)
# Calculate cycle length
cycle_length = max_iter // cycles
# Plot each reduction strategy
for strategy in strategies:
pop_sizes = []
for i in range(max_iter):
cycle = i // cycle_length
cycle_progress = (i % cycle_length) / cycle_length
if strategy == 'linear':
# Linear reduction
current_pop = pop_size - (pop_size - 10) * cycle_progress
elif strategy == 'cosine':
# Cosine reduction
current_pop = pop_size - (pop_size - 10) * (1 - np.cos(cycle_progress * np.pi)) / 2
elif strategy == 'exponential':
# Exponential reduction
current_pop = pop_size - (pop_size - 10) * (1 - np.exp(-5 * cycle_progress)) / (1 - np.exp(-5))
else:
raise ValueError(f"Unknown strategy: {strategy}")
pop_sizes.append(max(10, int(current_pop)))
# Plot the strategy
ax.plot(iterations, pop_sizes, color=strategy_colors.get(strategy, 'black'),
linewidth=2, label=strategy.capitalize())
# Add cycle boundaries
for i in range(1, cycles):
cycle_boundary = i * cycle_length
ax.axvline(x=cycle_boundary, color='gray', linestyle='--', alpha=0.7)
# Set labels and title
ax.set_title('Population Reduction Strategies Comparison')
ax.set_xlabel('Iterations')
ax.set_ylabel('Population Size')
ax.grid(True, linestyle='--', alpha=0.7)
ax.legend()
# Add cycle labels
for i in range(cycles):
cycle_middle = i * cycle_length + cycle_length // 2
ax.text(cycle_middle, pop_size + 2, f"Cycle {i+1}",
horizontalalignment='center', color='gray')
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches='tight')
return fig
[docs]def plot_population_diversity_map(
positions_history: List[np.ndarray],
bounds: Tuple[np.ndarray, np.ndarray],
sample_iterations: List[int],
figsize: Tuple[int, int] = (15, 10),
cmap: str = 'viridis',
save_path: Optional[str] = None
):
"""
Create a grid of plots showing population diversity at different iterations.
Parameters
----------
positions_history : list
List of position arrays at each iteration, each with shape (pop_size, 2).
bounds : tuple
A tuple (lb, ub) containing the lower and upper bounds.
sample_iterations : list
List of iteration indices to visualize.
figsize : tuple, optional
Figure size as (width, height) in inches (default: (15, 10)).
cmap : str, optional
Colormap for the density plot (default: 'viridis').
save_path : str, optional
Path to save the figure. If None, the figure is not saved (default: None).
Returns
-------
matplotlib.figure.Figure
The created figure.
"""
if not positions_history:
raise ValueError("Position history is empty")
if positions_history[0].shape[1] != 2:
raise ValueError("This function only works for 2D search spaces")
# Determine the grid size
n_samples = len(sample_iterations)
n_cols = min(3, n_samples)
n_rows = (n_samples + n_cols - 1) // n_cols
# Create the figure and axes
fig, axes = plt.subplots(n_rows, n_cols, figsize=figsize)
# Flatten axes for easy iteration
if n_rows == 1 and n_cols == 1:
axes = np.array([axes])
axes = np.array(axes).flatten()
# Set the plot limits based on the bounds
lb, ub = bounds
# Create a grid for density estimation
resolution = 50
x = np.linspace(lb[0], ub[0], resolution)
y = np.linspace(lb[1], ub[1], resolution)
X, Y = np.meshgrid(x, y)
# Plot each sampled iteration
for i, iter_idx in enumerate(sample_iterations):
if iter_idx < len(positions_history):
ax = axes[i]
positions = positions_history[iter_idx]
# Calculate density
density = np.zeros((resolution, resolution))
for pos in positions:
# Find the closest grid point
x_idx = np.argmin(np.abs(x - pos[0]))
y_idx = np.argmin(np.abs(y - pos[1]))
density[y_idx, x_idx] += 1
# Apply Gaussian blur for smoother density
from scipy.ndimage import gaussian_filter
density = gaussian_filter(density, sigma=1.0)
# Plot density
contour = ax.contourf(X, Y, density, 20, cmap=cmap, alpha=0.8)
# Plot positions
ax.scatter(positions[:, 0], positions[:, 1], c='white', edgecolors='black', s=30)
# Calculate diversity metrics
mean_pos = np.mean(positions, axis=0)
std_pos = np.std(positions, axis=0)
# Set labels and title
ax.set_title(f'Iteration {iter_idx+1}')
ax.set_xlabel('x1')
ax.set_ylabel('x2')
# Add diversity information
diversity_text = f"Population Size: {len(positions)}\n"
diversity_text += f"Std Dev x1: {std_pos[0]:.4f}\n"
diversity_text += f"Std Dev x2: {std_pos[1]:.4f}"
ax.text(0.05, 0.95, diversity_text, transform=ax.transAxes,
verticalalignment='top', fontsize=8,
bbox=dict(facecolor='white', alpha=0.7))
# Set axis limits
ax.set_xlim(lb[0], ub[0])
ax.set_ylim(lb[1], ub[1])
# Hide any unused subplots
for i in range(len(sample_iterations), len(axes)):
axes[i].axis('off')
# Add a colorbar
fig.subplots_adjust(right=0.9)
cbar_ax = fig.add_axes([0.92, 0.15, 0.02, 0.7])
fig.colorbar(contour, cax=cbar_ax, label='Population Density')
# Set a common title
fig.suptitle('Population Diversity Map Across Iterations', fontsize=16)
plt.tight_layout(rect=[0, 0, 0.9, 0.95])
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches='tight')
return fig
[docs]def animate_population_cycle(
positions_history: List[np.ndarray],
pop_size_history: List[int],
bounds: Tuple[np.ndarray, np.ndarray],
max_iter: int,
cycles: int,
interval: int = 200,
figsize: Tuple[int, int] = (12, 8),
cmap: str = 'viridis',
save_path: Optional[str] = None,
dpi: int = 100
):
"""
Create an animation showing population dynamics throughout cycles.
Parameters
----------
positions_history : list
List of position arrays at each iteration, each with shape (pop_size, 2).
pop_size_history : list
List of population sizes at each iteration.
bounds : tuple
A tuple (lb, ub) containing the lower and upper bounds.
max_iter : int
Maximum number of iterations.
cycles : int
Number of cycles.
interval : int, optional
Interval between frames in milliseconds (default: 200).
figsize : tuple, optional
Figure size as (width, height) in inches (default: (12, 8)).
cmap : str, optional
Colormap for the density plot (default: 'viridis').
save_path : str, optional
Path to save the animation. If None, the animation is not saved (default: None).
dpi : int, optional
DPI for the saved animation (default: 100).
Returns
-------
matplotlib.animation.FuncAnimation
The created animation.
"""
if not positions_history:
raise ValueError("Position history is empty")
if positions_history[0].shape[1] != 2:
raise ValueError("This function only works for 2D search spaces")
# Create the figure and axes
fig = plt.figure(figsize=figsize)
gs = gridspec.GridSpec(2, 2, width_ratios=[3, 1], height_ratios=[1, 3])
# Population size plot
ax_pop = fig.add_subplot(gs[0, 0])
pop_line, = ax_pop.plot([], [], 'b-', linewidth=2)
ax_pop.set_title('Population Size')
ax_pop.set_xlabel('Iterations')
ax_pop.set_ylabel('Population Size')
ax_pop.grid(True, linestyle='--', alpha=0.7)
# Calculate cycle length
cycle_length = max_iter // cycles
# Add cycle boundaries to population plot
for i in range(1, cycles):
cycle_boundary = i * cycle_length
if cycle_boundary < max_iter:
ax_pop.axvline(x=cycle_boundary, color='r', linestyle='--', alpha=0.7)
# Set axis limits for population plot
ax_pop.set_xlim(0, max_iter)
ax_pop.set_ylim(0, max(pop_size_history) * 1.1)
# Add cycle labels
for i in range(cycles):
cycle_middle = i * cycle_length + cycle_length // 2
if cycle_middle < max_iter:
ax_pop.text(cycle_middle, max(pop_size_history) * 1.05, f"Cycle {i+1}",
horizontalalignment='center', color='red')
# Diversity metrics plot
ax_div = fig.add_subplot(gs[0, 1])
div_line, = ax_div.plot([], [], 'g-', linewidth=2)
ax_div.set_title('Diversity')
ax_div.set_xlabel('Iterations')
ax_div.set_ylabel('Std Dev')
ax_div.grid(True, linestyle='--', alpha=0.7)
# Set axis limits for diversity plot
ax_div.set_xlim(0, max_iter)
# Positions plot
ax_pos = fig.add_subplot(gs[1, :])
scatter = ax_pos.scatter([], [], c='blue', edgecolors='black', s=50)
# Set the plot limits based on the bounds
lb, ub = bounds
ax_pos.set_xlim(lb[0], ub[0])
ax_pos.set_ylim(lb[1], ub[1])
# Set labels and title for positions plot
ax_pos.set_title('Population Distribution')
ax_pos.set_xlabel('x1')
ax_pos.set_ylabel('x2')
ax_pos.grid(True, linestyle='--', alpha=0.3)
# Text for iteration and cycle information
info_text = ax_pos.text(0.02, 0.98, '', transform=ax_pos.transAxes,
verticalalignment='top', fontsize=10,
bbox=dict(facecolor='white', alpha=0.7))
# Calculate diversity history
diversity_history = []
for positions in positions_history:
if len(positions) > 1:
std_pos = np.std(positions, axis=0)
diversity = np.mean(std_pos)
else:
diversity = 0
diversity_history.append(diversity)
# Animation update function
def update(frame):
# Update population size plot
iterations = np.arange(frame + 1)
pop_sizes = pop_size_history[:frame + 1]
pop_line.set_data(iterations, pop_sizes)
# Update diversity plot
diversities = diversity_history[:frame + 1]
div_line.set_data(iterations, diversities)
# Update positions plot
positions = positions_history[frame]
scatter.set_offsets(positions)
# Determine current cycle
current_cycle = frame // cycle_length + 1
cycle_progress = (frame % cycle_length) / cycle_length * 100
# Update information text
info_text.set_text(f"Iteration: {frame+1}/{max_iter}\n"
f"Cycle: {current_cycle}/{cycles} ({cycle_progress:.1f}%)\n"
f"Population Size: {pop_size_history[frame]}\n"
f"Diversity: {diversity_history[frame]:.4f}")
# Return all artists that need to be redrawn
return pop_line, div_line, scatter, info_text
# Create the animation
anim = FuncAnimation(fig, update, frames=len(positions_history),
interval=interval, blit=True)
# Adjust layout
plt.tight_layout()
# Save the animation if a path is provided
if save_path:
anim.save(save_path, dpi=dpi, writer='pillow')
return anim
[docs]def plot_exploration_exploitation_balance(
positions_history: List[np.ndarray],
best_positions_history: List[np.ndarray],
bounds: Tuple[np.ndarray, np.ndarray],
sample_iterations: List[int],
figsize: Tuple[int, int] = (15, 10),
save_path: Optional[str] = None
):
"""
Plot the balance between exploration and exploitation at different iterations.
Parameters
----------
positions_history : list
List of position arrays at each iteration, each with shape (pop_size, 2).
best_positions_history : list
List of best position arrays at each iteration, each with shape (2,).
bounds : tuple
A tuple (lb, ub) containing the lower and upper bounds.
sample_iterations : list
List of iteration indices to visualize.
figsize : tuple, optional
Figure size as (width, height) in inches (default: (15, 10)).
save_path : str, optional
Path to save the figure. If None, the figure is not saved (default: None).
Returns
-------
matplotlib.figure.Figure
The created figure.
"""
if not positions_history:
raise ValueError("Position history is empty")
if positions_history[0].shape[1] != 2:
raise ValueError("This function only works for 2D search spaces")
# Determine the grid size
n_samples = len(sample_iterations)
n_cols = min(3, n_samples)
n_rows = (n_samples + n_cols - 1) // n_cols
# Create the figure and axes
fig, axes = plt.subplots(n_rows, n_cols, figsize=figsize)
# Flatten axes for easy iteration
if n_rows == 1 and n_cols == 1:
axes = np.array([axes])
axes = np.array(axes).flatten()
# Set the plot limits based on the bounds
lb, ub = bounds
# Plot each sampled iteration
for i, iter_idx in enumerate(sample_iterations):
if iter_idx < len(positions_history):
ax = axes[i]
positions = positions_history[iter_idx]
best_pos = best_positions_history[iter_idx]
# Calculate distances to best position
distances = np.linalg.norm(positions - best_pos, axis=1)
# Normalize distances for coloring
if np.max(distances) > 0:
normalized_distances = distances / np.max(distances)
else:
normalized_distances = np.zeros_like(distances)
# Create a colormap: blue (close to best) to red (far from best)
cmap = plt.cm.coolwarm
colors = cmap(normalized_distances)
# Plot positions with distance-based coloring
scatter = ax.scatter(positions[:, 0], positions[:, 1], c=colors,
edgecolors='black', s=50)
# Plot best position
ax.scatter(best_pos[0], best_pos[1], c='yellow', edgecolors='black',
s=150, marker='*', label='Best Position')
# Calculate exploration vs exploitation metrics
mean_dist = np.mean(distances)
std_dist = np.std(distances)
# Determine if this iteration is more exploratory or exploitative
if mean_dist > (ub[0] - lb[0]) * 0.2: # Arbitrary threshold
phase = "Exploration"
else:
phase = "Exploitation"
# Set labels and title
ax.set_title(f'Iteration {iter_idx+1}: {phase}')
ax.set_xlabel('x1')
ax.set_ylabel('x2')
# Add metrics information
metrics_text = f"Mean Distance: {mean_dist:.4f}\n"
metrics_text += f"Std Dev Distance: {std_dist:.4f}\n"
metrics_text += f"Population Size: {len(positions)}"
ax.text(0.05, 0.95, metrics_text, transform=ax.transAxes,
verticalalignment='top', fontsize=8,
bbox=dict(facecolor='white', alpha=0.7))
# Set axis limits
ax.set_xlim(lb[0], ub[0])
ax.set_ylim(lb[1], ub[1])
# Add legend
ax.legend(loc='lower right')
# Hide any unused subplots
for i in range(len(sample_iterations), len(axes)):
axes[i].axis('off')
# Add a colorbar
fig.subplots_adjust(right=0.9)
cbar_ax = fig.add_axes([0.92, 0.15, 0.02, 0.7])
cbar = fig.colorbar(scatter, cax=cbar_ax)
cbar.set_label('Distance to Best Position (Normalized)')
# Set a common title
fig.suptitle('Exploration vs Exploitation Balance Across Iterations', fontsize=16)
plt.tight_layout(rect=[0, 0, 0.9, 0.95])
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches='tight')
return fig
[docs]def plot_diversity_vs_convergence(
diversity_history: List[float],
fitness_history: List[float],
cycles: int,
max_iter: int,
title: str = "Diversity vs Convergence",
figsize: Tuple[int, int] = (12, 6),
save_path: Optional[str] = None
):
"""
Plot the relationship between population diversity and convergence.
Parameters
----------
diversity_history : list
List of diversity measures at each iteration.
fitness_history : list
List of best fitness values at each iteration.
cycles : int
Number of cycles.
max_iter : int
Maximum number of iterations.
title : str, optional
Title of the plot (default: "Diversity vs Convergence").
figsize : tuple, optional
Figure size as (width, height) in inches (default: (12, 6)).
save_path : str, optional
Path to save the figure. If None, the figure is not saved (default: None).
Returns
-------
matplotlib.figure.Figure
The created figure.
"""
# Create the figure and axes
fig, ax1 = plt.subplots(figsize=figsize)
# Calculate cycle length
cycle_length = max_iter // cycles
# Generate iterations
iterations = np.arange(1, len(diversity_history) + 1)
# Plot diversity
ax1.set_xlabel('Iterations')
ax1.set_ylabel('Diversity', color='blue')
ax1.plot(iterations, diversity_history, 'b-', linewidth=2, label='Diversity')
ax1.tick_params(axis='y', labelcolor='blue')
# Create a second y-axis for fitness
ax2 = ax1.twinx()
ax2.set_ylabel('Best Fitness', color='red')
# Extract scalar values from fitness history for plotting
scalar_fitness = []
for fitness_array in fitness_history:
# If fitness is an array, take the minimum value (best fitness)
if isinstance(fitness_array, np.ndarray) and fitness_array.size > 0:
scalar_fitness.append(float(np.min(fitness_array)))
# If it's already a scalar, use it directly
elif np.isscalar(fitness_array):
scalar_fitness.append(float(fitness_array))
# If it's a list, take the minimum value
elif isinstance(fitness_array, list) and len(fitness_array) > 0:
scalar_fitness.append(float(min(fitness_array)))
# Default case
else:
scalar_fitness.append(0.0)
ax2.plot(iterations, scalar_fitness, 'r-', linewidth=2, label='Best Fitness')
ax2.tick_params(axis='y', labelcolor='red')
# Add cycle boundaries
for i in range(1, cycles):
cycle_boundary = i * cycle_length
if cycle_boundary < len(iterations):
ax1.axvline(x=cycle_boundary, color='gray', linestyle='--', alpha=0.7)
# Add cycle labels
for i in range(cycles):
cycle_middle = i * cycle_length + cycle_length // 2
if cycle_middle < len(iterations):
ax1.text(cycle_middle, max(diversity_history) * 1.05, f"Cycle {i+1}",
horizontalalignment='center', color='gray')
# Add a legend
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper right')
# Set title
plt.title(title)
# Add grid
ax1.grid(True, linestyle='--', alpha=0.7)
# Calculate correlation between diversity and fitness improvement
# Use the scalar_fitness we calculated earlier
fitness_improvements = np.zeros(len(scalar_fitness))
for i in range(1, len(scalar_fitness)):
fitness_improvements[i] = scalar_fitness[i-1] - scalar_fitness[i]
# Make sure diversity_history and fitness_improvements have the same length
min_length = min(len(diversity_history), len(fitness_improvements))
correlation = np.corrcoef(diversity_history[1:min_length], fitness_improvements[1:min_length])[0, 1]
# Add correlation information
correlation_text = f"Correlation between Diversity and Fitness Improvement: {correlation:.4f}"
plt.figtext(0.5, 0.01, correlation_text, ha='center', fontsize=10,
bbox=dict(facecolor='white', alpha=0.7))
plt.tight_layout(rect=[0, 0.05, 1, 1])
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches='tight')
return fig
# Helper function to calculate population diversity
[docs]def calculate_diversity(positions: np.ndarray) -> float:
"""
Calculate the diversity of a population based on average pairwise distance.
Parameters
----------
positions : ndarray
Positions of the porcupines, shape (pop_size, dimensions).
Returns
-------
float
Diversity measure.
"""
n_particles = positions.shape[0]
if n_particles <= 1:
return 0.0
# Calculate pairwise distances
distances = np.zeros((n_particles, n_particles))
for i in range(n_particles):
for j in range(i+1, n_particles):
distances[i, j] = np.linalg.norm(positions[i] - positions[j])
distances[j, i] = distances[i, j]
# Average pairwise distance
return np.sum(distances) / (n_particles * (n_particles - 1))