Source code for porcupy.utils.enhanced_visualization

import os
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from matplotlib.patches import FancyArrowPatch
from mpl_toolkits.mplot3d import Axes3D
from typing import List, Tuple, Dict, Optional, Callable, Union

"""
Enhanced visualization module for the Crested Porcupine Optimizer (CPO).

This module provides specialized visualization tools for the CPO algorithm,
highlighting its unique features such as the four defense mechanisms and
cyclic population reduction.
"""

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from matplotlib.colors import LinearSegmentedColormap
from mpl_toolkits.mplot3d import Axes3D
import matplotlib.cm as cm
from typing import List, Tuple, Optional, Union, Dict, Any, Callable


[docs]def plot_defense_mechanisms( defense_history: Union[Dict[str, List[int]], List[List[str]]], title: str = "Defense Mechanism Activation", figsize: Tuple[int, int] = (12, 6), colors: Dict[str, str] = None, save_path: Optional[str] = None ): """ Plot the activation frequency of each defense mechanism over iterations. Parameters ---------- defense_history : dict or list Either: 1. Dictionary with keys 'sight', 'sound', 'odor', 'physical' and values as lists of counts for each iteration, or 2. List of lists containing defense mechanisms used by each porcupine at each iteration. title : str, optional Title of the plot (default: "Defense Mechanism Activation"). figsize : tuple, optional Figure size as (width, height) in inches (default: (12, 6)). colors : dict, optional Dictionary mapping defense mechanisms to colors. Default: {'sight': 'blue', 'sound': 'green', 'odor': 'orange', 'physical': 'red'}. 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 colors is None: colors = { 'sight': 'blue', 'sound': 'green', 'odor': 'orange', 'physical': 'red' } # Convert list-based defense history to dictionary format if needed if isinstance(defense_history, list): # This is a list of lists with defense types for each porcupine at each iteration defense_counts = { 'sight': [], 'sound': [], 'odor': [], 'physical': [] } # Count each defense type for each iteration for defenses in defense_history: defense_counts['sight'].append(defenses.count('sight')) defense_counts['sound'].append(defenses.count('sound')) defense_counts['odor'].append(defenses.count('odor')) defense_counts['physical'].append(defenses.count('physical')) defense_history = defense_counts plt.figure(figsize=figsize) iterations = np.arange(1, len(list(defense_history.values())[0]) + 1) for mechanism, counts in defense_history.items(): plt.plot(iterations, counts, color=colors[mechanism], linewidth=2, label=mechanism.capitalize()) plt.title(title) plt.xlabel("Iterations") plt.ylabel("Activation Count") plt.grid(True, linestyle='--', alpha=0.7) plt.legend() if save_path: plt.savefig(save_path, dpi=300, bbox_inches='tight') return plt.gcf()
[docs]def plot_population_cycles( pop_size_history: List[int], cycles: int, max_iter: int, title: str = "Population Size Cycles", figsize: Tuple[int, int] = (12, 6), save_path: Optional[str] = None ): """ Plot the population size history with cycle boundaries highlighted. Parameters ---------- pop_size_history : list List of population sizes at each iteration. cycles : int Number of cycles used in the optimization. max_iter : int Maximum number of iterations. title : str, optional Title of the plot (default: "Population Size Cycles"). 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. """ plt.figure(figsize=figsize) iterations = np.arange(1, len(pop_size_history) + 1) # Plot population size plt.plot(iterations, pop_size_history, 'b-', linewidth=2, label='Population Size') # Add cycle boundaries cycle_length = max_iter // cycles for i in range(1, cycles): cycle_boundary = i * cycle_length if cycle_boundary < len(iterations): plt.axvline(x=cycle_boundary, color='r', linestyle='--', alpha=0.7) plt.title(title) plt.xlabel("Iterations") plt.ylabel("Population Size") plt.grid(True, 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): plt.text(cycle_middle, min(pop_size_history) - 2, f"Cycle {i+1}", horizontalalignment='center', color='red') if save_path: plt.savefig(save_path, dpi=300, bbox_inches='tight') return plt.gcf()
[docs]def plot_diversity_history( diversity_history: List[float], title: str = "Population Diversity History", figsize: Tuple[int, int] = (10, 6), save_path: Optional[str] = None ): """ Plot the diversity history of the population. Parameters ---------- diversity_history : list List of diversity measures at each iteration. title : str, optional Title of the plot (default: "Population Diversity History"). figsize : tuple, optional Figure size as (width, height) in inches (default: (10, 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. """ plt.figure(figsize=figsize) iterations = np.arange(1, len(diversity_history) + 1) plt.plot(iterations, diversity_history, 'g-', linewidth=2) plt.title(title) plt.xlabel("Iterations") plt.ylabel("Diversity Measure") plt.grid(True, linestyle='--', alpha=0.7) if save_path: plt.savefig(save_path, dpi=300, bbox_inches='tight') return plt.gcf()
[docs]def plot_2d_porcupines( positions: np.ndarray, func: Callable, bounds: Tuple[np.ndarray, np.ndarray], best_pos: Optional[np.ndarray] = None, defense_types: Optional[List[str]] = None, title: str = "Porcupine Positions", figsize: Tuple[int, int] = (10, 8), cmap: str = 'viridis', contour_levels: int = 20, quill_length: float = 0.5, save_path: Optional[str] = None ): """ Plot porcupines in 2D search space with quill-like directional indicators. Parameters ---------- positions : ndarray Current positions of the porcupines, shape (pop_size, 2). func : callable The objective function to visualize. bounds : tuple A tuple (lb, ub) containing the lower and upper bounds. best_pos : ndarray, optional Global best position, shape (2,). defense_types : list, optional List of defense mechanisms used by each porcupine. Options: 'sight', 'sound', 'odor', 'physical'. title : str, optional Title of the plot (default: "Porcupine Positions"). figsize : tuple, optional Figure size as (width, height) in inches (default: (10, 8)). cmap : str, optional Colormap for the contour plot (default: 'viridis'). contour_levels : int, optional Number of contour levels (default: 20). quill_length : float, optional Length of the directional quills (default: 0.5). 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 len(bounds[0]) != 2 or len(bounds[1]) != 2: raise ValueError("This function only works for 2D search spaces") lb, ub = bounds # Create a grid of points resolution = 100 x = np.linspace(lb[0], ub[0], resolution) y = np.linspace(lb[1], ub[1], resolution) X, Y = np.meshgrid(x, y) # Evaluate the function at each point Z = np.zeros_like(X) for i in range(resolution): for j in range(resolution): Z[i, j] = func([X[i, j], Y[i, j]]) # Create the plot plt.figure(figsize=figsize) # Plot the contour contour = plt.contourf(X, Y, Z, contour_levels, cmap=cmap, alpha=0.8) plt.colorbar(contour, label='Cost') # Define colors for different defense mechanisms defense_colors = { 'sight': 'blue', 'sound': 'green', 'odor': 'orange', 'physical': 'red' } # Plot the porcupines with quill-like indicators if defense_types is not None: for i, (pos, defense) in enumerate(zip(positions, defense_types)): color = defense_colors.get(defense, 'white') plt.scatter(pos[0], pos[1], c=color, edgecolors='black', s=80) # Add quills (8 directions) for angle in np.linspace(0, 2*np.pi, 8, endpoint=False): dx = quill_length * np.cos(angle) dy = quill_length * np.sin(angle) plt.arrow(pos[0], pos[1], dx, dy, head_width=0.1, head_length=0.1, fc=color, ec=color, alpha=0.7) else: plt.scatter(positions[:, 0], positions[:, 1], c='white', edgecolors='black', s=80) # Plot the best position if provided if best_pos is not None: plt.scatter(best_pos[0], best_pos[1], c='red', s=150, marker='*', label='Best Position') plt.title(title) plt.xlabel('x1') plt.ylabel('x2') plt.grid(True, linestyle='--', alpha=0.3) # Add legend for defense mechanisms if used if defense_types is not None: handles = [] labels = [] for defense, color in defense_colors.items(): if defense in defense_types: handles.append(plt.Line2D([0], [0], marker='o', color='w', markerfacecolor=color, markersize=10, label=defense.capitalize())) labels.append(defense.capitalize()) if best_pos is not None: handles.append(plt.Line2D([0], [0], marker='*', color='w', markerfacecolor='red', markersize=15, label='Best Position')) labels.append('Best Position') plt.legend(handles=handles, labels=labels) else: plt.legend() if save_path: plt.savefig(save_path, dpi=300, bbox_inches='tight') return plt.gcf()
[docs]def animate_porcupines_2d( position_history: List[np.ndarray], func: Callable, bounds: Tuple[np.ndarray, np.ndarray], defense_history: Optional[List[List[str]]] = None, best_pos_history: Optional[List[np.ndarray]] = None, best_cost_history: Optional[List[float]] = None, interval: int = 200, figsize: Tuple[int, int] = (14, 10), cmap: str = 'viridis', contour_levels: int = 20, quill_length: float = 0.5, save_path: Optional[str] = None, dpi: int = 100, show_trail: bool = True, max_trail_length: int = 20, show_convergence: bool = True ): """ Create an enhanced animation of porcupines moving in 2D search space with additional visual feedback. Parameters ---------- position_history : list List of position arrays at each iteration, each with shape (pop_size, 2). func : callable The objective function to visualize. bounds : tuple A tuple (lb, ub) containing the lower and upper bounds. defense_history : list, optional List of lists containing defense mechanisms used by each porcupine at each iteration. best_pos_history : list, optional List of best positions at each iteration, each with shape (2,). best_cost_history : list, optional List of best costs at each iteration. interval : int, optional Interval between frames in milliseconds (default: 200). figsize : tuple, optional Figure size as (width, height) in inches (default: (14, 10)). cmap : str, optional Colormap for the contour plot (default: 'viridis'). contour_levels : int, optional Number of contour levels (default: 20). quill_length : float, optional Length of the directional quills (default: 0.5). 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). show_trail : bool, optional Whether to show the trail of best positions (default: True). max_trail_length : int, optional Maximum number of positions to show in the trail (default: 20). show_convergence : bool, optional Whether to show the convergence plot (default: True). Returns ------- matplotlib.animation.FuncAnimation The created animation. """ if len(bounds[0]) != 2 or len(bounds[1]) != 2: raise ValueError("This function only works for 2D search spaces") lb, ub = bounds # Create a grid of points resolution = 100 x = np.linspace(lb[0], ub[0], resolution) y = np.linspace(lb[1], ub[1], resolution) X, Y = np.meshgrid(x, y) # Evaluate the function at each point Z = np.zeros_like(X) for i in range(resolution): for j in range(resolution): Z[i, j] = func([X[i, j], Y[i, j]]) # Create the figure with a grid layout fig = plt.figure(figsize=figsize) # Create main plot for porcupines if show_convergence and best_cost_history is not None: # Create a 2x2 grid with the main plot on the left and convergence plot on top right gs = fig.add_gridspec(2, 2, width_ratios=[3, 1], height_ratios=[1, 3]) ax_main = fig.add_subplot(gs[1, 0]) ax_conv = fig.add_subplot(gs[0, 0]) ax_info = fig.add_subplot(gs[1, 1]) else: # Single plot layout if no convergence plot gs = fig.add_gridspec(1, 2, width_ratios=[3, 1]) ax_main = fig.add_subplot(gs[0]) ax_info = fig.add_subplot(gs[1]) ax_conv = None # Adjust layout to prevent overlap plt.tight_layout() # Main contour plot contour = ax_main.contourf(X, Y, Z, contour_levels, cmap=cmap, alpha=0.8) plt.colorbar(contour, ax=ax_main, label='Cost') # Set up convergence plot if enabled if ax_conv is not None: ax_conv.set_title('Convergence') ax_conv.set_xlabel('Iteration') ax_conv.set_ylabel('Best Cost') ax_conv.grid(True, linestyle='--', alpha=0.3) ax_conv.set_yscale('log') convergence_line, = ax_conv.plot([], [], 'b-', linewidth=2) current_point = ax_conv.plot([], [], 'ro', markersize=6)[0] # Set up info panel ax_info.axis('off') info_text = ax_info.text(0.1, 0.9, '', transform=ax_info.transAxes, verticalalignment='top', fontsize=10) # Add progress bar progress_bar = ax_info.barh(y=0.8, width=0, height=0.1, color='blue', alpha=0.5, left=0.1)[0] # Define colors for different defense mechanisms defense_colors = { 'sight': 'blue', 'sound': 'green', 'odor': 'orange', 'physical': 'red' } # Initialize scatter plots and quills for porcupines scatter_porcupines = ax_main.scatter([], [], c='white', edgecolors='black', s=80, zorder=5) quills = [] # Create quills for each porcupine (8 directions per porcupine) if position_history and len(position_history) > 0: n_porcupines = position_history[0].shape[0] for i in range(n_porcupines): porcupine_quills = [] for angle in np.linspace(0, 2*np.pi, 8, endpoint=False): quill, = ax_main.plot([], [], 'k-', lw=1, alpha=0.7, zorder=4) porcupine_quills.append(quill) quills.append(porcupine_quills) # Initialize best position marker with a star and pulsing effect scatter_best = ax_main.scatter([], [], c='red', s=200, marker='*', label='Best Position', zorder=10) # Initialize best position trail if show_trail and best_pos_history: trail_length = min(max_trail_length, len(best_pos_history)) trail_alphas = np.linspace(0.2, 0.8, trail_length) trail = ax_main.plot([], [], 'r-', alpha=0.5, linewidth=2, zorder=3)[0] # Set labels and title for main plot ax_main.set_xlabel('x1') ax_main.set_ylabel('x2') ax_main.set_title('Porcupine Optimization Process') ax_main.grid(True, linestyle='--', alpha=0.3) # Set axis limits with a small margin margin_x = 0.1 * (ub[0] - lb[0]) margin_y = 0.1 * (ub[1] - lb[1]) ax_main.set_xlim(lb[0] - margin_x, ub[0] + margin_x) ax_main.set_ylim(lb[1] - margin_y, ub[1] + margin_y) # Text for iteration number iteration_text = ax_main.text(0.02, 0.98, '', transform=ax_main.transAxes, verticalalignment='top', fontsize=10, bbox=dict(facecolor='white', alpha=0.7, pad=2)) # Create legend for defense mechanisms if defense_history is not None: legend_elements = [] for defense, color in defense_colors.items(): legend_elements.append(plt.Line2D([0], [0], marker='o', color='w', markerfacecolor=color, markersize=10, label=defense.capitalize())) legend_elements.append(plt.Line2D([0], [0], marker='*', color='w', markerfacecolor='red', markersize=15, label='Best Position')) ax_main.legend(handles=legend_elements, loc='lower right') # Animation update function def update(frame): artists = [] # Get current positions positions = position_history[frame] # Update porcupine positions scatter_porcupines.set_offsets(positions) artists.append(scatter_porcupines) # Update porcupine colors based on defense mechanisms if defense_history is not None and frame < len(defense_history): defense_types = defense_history[frame] colors = [defense_colors.get(defense, 'white') for defense in defense_types] scatter_porcupines.set_color(colors) # Update quills for i, pos in enumerate(positions): for j, angle in enumerate(np.linspace(0, 2*np.pi, 8, endpoint=False)): dx = quill_length * np.cos(angle) dy = quill_length * np.sin(angle) quills[i][j].set_data([pos[0], pos[0] + dx], [pos[1], pos[1] + dy]) # Update quill color based on defense mechanism if defense_history is not None and frame < len(defense_history): defense = defense_history[frame][i] color = defense_colors.get(defense, 'black') quills[i][j].set_color(color) artists.append(quills[i][j]) # Update best position with pulsing effect if best_pos_history is not None and frame < len(best_pos_history): best_pos = best_pos_history[frame] scatter_best.set_offsets([best_pos]) # Pulsing effect size = 200 + 50 * np.sin(frame * 0.5) scatter_best.set_sizes([size]) artists.append(scatter_best) # Update trail if show_trail and frame > 0: start_idx = max(0, frame - max_trail_length + 1) trail_x = [p[0] for p in best_pos_history[start_idx:frame+1]] trail_y = [p[1] for p in best_pos_history[start_idx:frame+1]] trail.set_data(trail_x, trail_y) artists.append(trail) # Update convergence plot if ax_conv is not None and best_cost_history is not None and frame < len(best_cost_history): # Update convergence line iterations = range(frame + 1) convergence_line.set_data(iterations, best_cost_history[:frame+1]) # Update current point current_point.set_data([frame], [best_cost_history[frame]]) # Adjust axes limits ax_conv.relim() ax_conv.autoscale_view() artists.extend([convergence_line, current_point]) # Update info text info = [] info.append(f"Iteration: {frame+1}/{len(position_history)}") if best_cost_history is not None and frame < len(best_cost_history): info.append(f"Best Cost: {best_cost_history[frame]:.4e}") if best_pos_history is not None and frame < len(best_pos_history): best_pos = best_pos_history[frame] info.append(f"Best Position: ({best_pos[0]:.6f}, {best_pos[1]:.6f})") info_text.set_text('\n'.join(info)) artists.append(info_text) # Update progress bar progress = (frame + 1) / len(position_history) progress_bar.set_width(progress) # Update iteration text iteration_text.set_text(f'Iteration: {frame+1}/{len(position_history)}') artists.append(iteration_text) return artists # Create the animation anim = FuncAnimation(fig, update, frames=len(position_history), interval=interval, blit=True) # Save the animation if a path is provided if save_path: # Ensure the directory exists os.makedirs(os.path.dirname(os.path.abspath(save_path)), exist_ok=True) # Use 'ffmpeg' for better quality if available, otherwise fall back to 'pillow' try: anim.save(save_path, dpi=dpi, writer='ffmpeg', fps=1000/interval, bitrate=1800) except: try: anim.save(save_path, dpi=dpi, writer='pillow') except Exception as e: print(f"Warning: Could not save animation: {e}") return anim
[docs]def plot_3d_porcupines( positions: np.ndarray, fitness: np.ndarray, func: Callable, bounds: Tuple[np.ndarray, np.ndarray], best_pos: Optional[np.ndarray] = None, defense_types: Optional[List[str]] = None, title: str = "3D Porcupine Positions", figsize: Tuple[int, int] = (12, 10), cmap: str = 'viridis', alpha: float = 0.7, save_path: Optional[str] = None ): """ Plot porcupines in 3D search space. Parameters ---------- positions : ndarray Current positions of the porcupines, shape (pop_size, 2). fitness : ndarray Fitness values of the porcupines, shape (pop_size,). func : callable The objective function to visualize. bounds : tuple A tuple (lb, ub) containing the lower and upper bounds. best_pos : ndarray, optional Global best position, shape (2,). defense_types : list, optional List of defense mechanisms used by each porcupine. Options: 'sight', 'sound', 'odor', 'physical'. title : str, optional Title of the plot (default: "3D Porcupine Positions"). figsize : tuple, optional Figure size as (width, height) in inches (default: (12, 10)). cmap : str, optional Colormap for the surface plot (default: 'viridis'). alpha : float, optional Transparency of the surface (default: 0.7). 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 len(bounds[0]) != 2 or len(bounds[1]) != 2: raise ValueError("This function only works for 2D search spaces") lb, ub = bounds # Create a grid of points resolution = 50 x = np.linspace(lb[0], ub[0], resolution) y = np.linspace(lb[1], ub[1], resolution) X, Y = np.meshgrid(x, y) # Evaluate the function at each point Z = np.zeros_like(X) for i in range(resolution): for j in range(resolution): Z[i, j] = func([X[i, j], Y[i, j]]) # Create the figure and 3D axis fig = plt.figure(figsize=figsize) ax = fig.add_subplot(111, projection='3d') # Plot the surface surface = ax.plot_surface(X, Y, Z, cmap=cmap, alpha=alpha) fig.colorbar(surface, ax=ax, shrink=0.5, aspect=5, label='Cost') # Define colors for different defense mechanisms defense_colors = { 'sight': 'blue', 'sound': 'green', 'odor': 'orange', 'physical': 'red' } # Plot the porcupines if defense_types is not None: for i, (pos, fit, defense) in enumerate(zip(positions, fitness, defense_types)): color = defense_colors.get(defense, 'white') ax.scatter(pos[0], pos[1], fit, c=color, edgecolors='black', s=80) else: ax.scatter(positions[:, 0], positions[:, 1], fitness, c='white', edgecolors='black', s=80) # Plot the best position if provided if best_pos is not None: best_fitness = func(best_pos) ax.scatter(best_pos[0], best_pos[1], best_fitness, c='red', s=150, marker='*', label='Best Position') ax.set_title(title) ax.set_xlabel('x1') ax.set_ylabel('x2') ax.set_zlabel('Cost') # Add legend for defense mechanisms if used if defense_types is not None: handles = [] labels = [] for defense, color in defense_colors.items(): if defense in defense_types: handles.append(plt.Line2D([0], [0], marker='o', color='w', markerfacecolor=color, markersize=10, label=defense.capitalize())) labels.append(defense.capitalize()) if best_pos is not None: handles.append(plt.Line2D([0], [0], marker='*', color='w', markerfacecolor='red', markersize=15, label='Best Position')) labels.append('Best Position') ax.legend(handles=handles, labels=labels) else: ax.legend() 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))
# Helper function to track defense mechanism usage
[docs]def track_defense_mechanisms( positions: np.ndarray, prev_positions: np.ndarray, best_pos: np.ndarray, tf: float = 0.8 ) -> List[str]: """ Determine which defense mechanism was likely used for each porcupine. Parameters ---------- positions : ndarray Current positions of the porcupines, shape (pop_size, dimensions). prev_positions : ndarray Previous positions of the porcupines, shape (pop_size, dimensions). best_pos : ndarray Global best position, shape (dimensions,). tf : float, optional Tradeoff threshold between third and fourth mechanisms (default: 0.8). Returns ------- list List of defense mechanisms used by each porcupine. """ n_particles = positions.shape[0] defense_types = [] for i in range(n_particles): # Calculate movement vector movement = positions[i] - prev_positions[i] # Calculate distance to best position dist_to_best = np.linalg.norm(positions[i] - best_pos) # Random threshold for exploration vs exploitation random_threshold = np.random.random() if random_threshold < 0.5: # Exploration phase if np.random.random() < 0.5: defense_types.append('sight') else: defense_types.append('sound') else: # Exploitation phase if np.random.random() < tf: defense_types.append('odor') else: defense_types.append('physical') return defense_types