Código fuente para PySAG.ga

"""
Clase principal que implementa el Algoritmo Genético (AG).

Esta clase encapsula la lógica de un algoritmo genético estándar, permitiendo
la configuración de sus componentes clave como la inicialización, selección,
cruce y mutación a través de funciones personalizadas.
"""

import random
from typing import Any, Callable, Dict, List, Optional, Tuple

import numpy as np
from numpy.typing import NDArray

from . import crossover as default_crossover
from . import initialization as default_init
from . import mutation as default_mutation
from . import selection as default_selection
from .exceptions import (
    GeneticAlgorithmError,
    InitializationError,
    validate_parameter,
)

# Tipos para mayor claridad
IndividualType = NDArray[Any]  # Un individuo es un array NumPy
PopulationType = List[IndividualType]  # La población es una lista de individuos
FitnessFunctionType = Callable[[IndividualType], float]
OperatorFunctionType = Callable[..., Any]


[documentos] class GA: """ Clase principal que implementa el Algoritmo Genético (AG). Permite configurar cada etapa del AG (inicialización, evaluación, selección, cruce y mutación) y ejecutar el proceso evolutivo. """
[documentos] def __init__( self, fitness_func: FitnessFunctionType, num_genes: int, population_size: int = 50, num_generations: int = 100, num_parents_mating: int = 10, initial_population_func: OperatorFunctionType = ( default_init.init_random_uniform ), initial_pop_args: Optional[Dict[str, Any]] = None, selection_func: OperatorFunctionType = ( default_selection.selection_roulette_wheel ), selection_args: Optional[Dict[str, Any]] = None, crossover_func: Optional[ OperatorFunctionType ] = default_crossover.crossover_single_point, crossover_args: Optional[Dict[str, Any]] = None, crossover_probability: float = 0.9, mutation_func: OperatorFunctionType = ( default_mutation.mutation_random_gene_uniform ), mutation_args: Optional[Dict[str, Any]] = None, keep_elitism_percentage: float = 0.1, random_seed: Optional[int] = None, ) -> None: """ Inicializa el algoritmo genético con los parámetros dados. Args: fitness_func: Función que toma un individuo y devuelve su valor de fitness (float). num_genes: Número de genes en cada individuo. population_size: Tamaño de la población. Por defecto es 50. num_generations: Número de generaciones a ejecutar. Por defecto es 100. num_parents_mating: Número de individuos a seleccionar como padres para el cruce. Por defecto es 10. initial_population_func: Función para crear la población inicial. Por defecto es `init_random_uniform`. initial_pop_args: Argumentos adicionales para `initial_population_func`. selection_func: Función para seleccionar padres. Por defecto es `selection_roulette_wheel`. selection_args: Argumentos adicionales para `selection_func`. crossover_func: Función para realizar el cruce. Puede ser None para no usar cruce. Por defecto es `crossover_single_point`. crossover_args: Argumentos adicionales para `crossover_func`. crossover_probability: Probabilidad de que ocurra el cruce. Debe estar entre 0 y 1. Por defecto es 0.9. mutation_func: Función para realizar la mutación. Por defecto es `mutation_random_gene_uniform`. mutation_args: Argumentos adicionales para `mutation_func`. keep_elitism_percentage: Porcentaje de los mejores individuos de la generación actual que se pasan directamente a la siguiente. Debe estar entre 0 y 1. Por defecto es 0.1. random_seed: Semilla opcional para el generador de números aleatorios de NumPy y Python, para reproducibilidad. Raises: TypeValidationError: Si alguna de las funciones de operador no es callable. ParameterError: Si alguno de los parámetros numéricos está fuera de rango. """ if random_seed is not None: random.seed(random_seed) np.random.seed(random_seed) # Validación de parámetros validate_parameter( fitness_func, "fitness_func", expected_type=Callable ) # type: ignore validate_parameter(num_genes, "num_genes", expected_type=int, min_val=1) validate_parameter( population_size, "population_size", expected_type=int, min_val=1 ) validate_parameter( num_generations, "num_generations", expected_type=int, min_val=0 ) validate_parameter( num_parents_mating, "num_parents_mating", expected_type=int, min_val=1 ) validate_parameter( initial_population_func, "initial_population_func", expected_type=Callable, ) # type: ignore validate_parameter( selection_func, "selection_func", expected_type=Callable ) # type: ignore if crossover_func is not None: validate_parameter( crossover_func, "crossover_func", expected_type=Callable ) # type: ignore validate_parameter( mutation_func, "mutation_func", expected_type=Callable ) # type: ignore validate_parameter( crossover_probability, "crossover_probability", expected_type=(float, int), min_val=0.0, max_val=1.0, ) validate_parameter( keep_elitism_percentage, "keep_elitism_percentage", expected_type=(float, int), min_val=0.0, max_val=1.0, ) self.fitness_func: FitnessFunctionType = fitness_func self.num_genes: int = num_genes self.population_size: int = population_size self.num_generations: int = num_generations self.num_parents_mating: int = num_parents_mating self.initial_population_func: OperatorFunctionType = initial_population_func self.initial_pop_args: Dict[str, Any] = ( initial_pop_args if initial_pop_args is not None else {} ) self.selection_func: OperatorFunctionType = selection_func self.selection_args: Dict[str, Any] = ( selection_args if selection_args is not None else {} ) self.crossover_func: Optional[OperatorFunctionType] = crossover_func self.crossover_args: Dict[str, Any] = ( crossover_args if crossover_args is not None else {} ) self.crossover_probability: float = crossover_probability self.mutation_func: OperatorFunctionType = mutation_func self.mutation_args: Dict[str, Any] = ( mutation_args if mutation_args is not None else {} ) elitism_count_float = self.population_size * keep_elitism_percentage self.keep_elitism_count: int = int(elitism_count_float) if ( self.keep_elitism_count == 0 and elitism_count_float > 0 and population_size > 0 ): pass # Por ahora, se confía en la conversión a int directa. self.population: Optional[PopulationType] = None self.best_solutions_fitness: List[float] = [] self.best_solution_overall: Optional[IndividualType] = None self.best_fitness_overall: float = -np.inf # Asume maximización
def _initialize_population(self) -> None: """ Inicializa la población utilizando la función y argumentos especificados. Este método utiliza los parámetros `initial_population_func` y `initial_pop_args` definidos durante la instanciación de la clase GA. Modifica `self.population`. """ self.population = self.initial_population_func( pop_size=self.population_size, chromosome_length=self.num_genes, **self.initial_pop_args, # Aquí deben estar 'low', 'high', 'dtype' ) if not isinstance(self.population, list) or not all( isinstance(ind, np.ndarray) for ind in self.population ): str_error = """La función de inicialización debe devolver una lista de arrays NumPy (individuos).""" raise InitializationError(str_error) # type: ignore if len(self.population) != self.population_size: str_error = """La población inicializada tiene tamaño {len(self.population)}, """ str_error += f"pero se esperaba {self.population_size}." raise InitializationError(str_error) # type: ignore def _calculate_population_fitness(self) -> NDArray[np.float64]: """ Calcula la aptitud de todos los individuos en la población actual. Returns: Un array NumPy con los valores de fitness de la población. Raises: GeneticAlgorithmError: Si la población no ha sido inicializada. """ if self.population is None: str_error = """La población no ha sido inicializada antes de calcular el fitness.""" raise GeneticAlgorithmError(str_error) # type: ignore fitness_values: List[float] = [] for individual in self.population: try: fitness_values.append(self.fitness_func(individual)) except Exception as e: raise GeneticAlgorithmError( # type: ignore f"Error al calcular el fitness para el individuo {individual}: {e}", details={"original_error": str(e)}, # type: ignore ) from e return np.array(fitness_values, dtype=np.float64)
[documentos] def run(self) -> Tuple[Optional[IndividualType], float]: """ Ejecuta el algoritmo genético a través de las generaciones. Returns: Una tupla con la mejor solución global encontrada (Individuo) y su valor de fitness (float). Raises: GeneticAlgorithmError: Si ocurren errores irrecuperables durante la ejecución del AG. """ if self.population is None: try: self._initialize_population() except InitializationError as e: str_error = "Error de inicialización:" str_error += f" {e}" raise GeneticAlgorithmError(str_error) from e # type: ignore if self.population is None: str_error = "Error crítico: La población sigue siendo None " str_error += "después de la inicialización." print(str_error) return None, -np.inf for generation in range(self.num_generations): try: fitness_values = self._calculate_population_fitness() except GeneticAlgorithmError as e: print( f"Error en la generación {generation + 1} calculando fitness: {e}" ) # Podría decidir terminar o intentar continuar si es recuperable raise # Re-elevar por ahora current_best_fitness_idx = np.argmax(fitness_values) current_best_fitness = fitness_values[current_best_fitness_idx] self.best_solutions_fitness.append(current_best_fitness) if current_best_fitness > self.best_fitness_overall: self.best_fitness_overall = current_best_fitness self.best_solution_overall = self.population[ current_best_fitness_idx ].copy() print( f"Generación {generation + 1}/{self.num_generations}: " f"Mejor Fitness = {self.best_fitness_overall:.4f} " f"(Actual: {current_best_fitness:.4f})" ) # Elitismo elite_individuals: PopulationType = [] if self.keep_elitism_count > 0 and self.population: # Ordenar por fitness descendente y tomar los 'k' mejores elite_indices = np.argsort(fitness_values)[-self.keep_elitism_count :] elite_individuals = [self.population[i].copy() for i in elite_indices] # Selección de Padres try: # Asegurar que num_parents_mating no exceda population_size actual_num_parents_mating = min( self.num_parents_mating, len(self.population) ) if actual_num_parents_mating < 2 and self.crossover_func is not None: str_error = "Advertencia: No hay suficientes padres" str_error += f" ({actual_num_parents_mating}) para el cruce. " str_error += "Se omitirá el cruce." print(str_error) parents = self.selection_func( self.population, fitness_values, actual_num_parents_mating, # Usar el valor ajustado **self.selection_args, ) except Exception as e: # Captura errores de selección str_error = "Error durante la selección en la generación" str_error += f" {generation + 1}: {e}" raise GeneticAlgorithmError(str_error) from e # type: ignore if not parents: # Si la selección no devuelve padres print( "Advertencia: La selección no devolvió padres en la generación" f" {generation + 1}. " "Rellenando descendencia con clones de la población actual." ) # Como fallback # clonar de la población existente para mantener el tamaño parents = [ random.choice(self.population).copy() for _ in range(actual_num_parents_mating) ] if ( not parents and self.population ): # Si la población también está vacía, es un problema mayor str_error = ( "La población está vacía y no se pueden seleccionar padres." ) raise GeneticAlgorithmError(str_error) # type: ignore # Generación de Descendencia (Cruce y Mutación) num_offspring_to_generate = self.population_size - len(elite_individuals) offspring_population: PopulationType = [] # Condición para cruce: función de cruce definida y suficientes padres can_crossover = self.crossover_func is not None and len(parents) >= 2 current_parent_idx = 0 while len(offspring_population) < num_offspring_to_generate: if not parents: # No hay padres de donde generar descendencia if ( self.population ): # Si la población original aún existe, clonar de ahí offspring_population.append( random.choice(self.population).copy() ) if len(offspring_population) >= num_offspring_to_generate: break continue else: # No hay forma de generar más individuos str_error = "Error: No hay padres ni población " str_error += "base para generar descendencia." break p1 = parents[current_parent_idx % len(parents)] if can_crossover and random.random() < self.crossover_probability: p2 = parents[ (current_parent_idx + 1) % len(parents) ] # Siguiente padre para cruce try: offspring1, offspring2 = self.crossover_func( p1.copy(), p2.copy(), **self.crossover_args ) except Exception as e: # Captura errores de cruce str_error = "Error durante el cruce en la generación" str_error += f" {generation + 1}: {e}" raise GeneticAlgorithmError(str_error) from e # type: ignore offspring_population.append(offspring1) if len(offspring_population) < num_offspring_to_generate: offspring_population.append(offspring2) current_parent_idx += 2 # Avanzar dos padres else: # Clonación (sin cruce o probabilidad no cumplida) offspring_population.append(p1.copy()) current_parent_idx += 1 # Avanzar un padre # Mutación de la descendencia generada mutated_offspring_population: PopulationType = [] for i in range(len(offspring_population)): individual_to_mutate = offspring_population[i] # Ya es una copia try: mutated_individual = self.mutation_func( individual_to_mutate, **self.mutation_args # Pasar la copia ) except Exception as e: # Captura errores de mutación str_error = "Error durante la mutación en la generación" str_error += f" {generation + 1}: {e}" raise GeneticAlgorithmError(str_error) from e # type: ignore mutated_offspring_population.append(mutated_individual) # Formar la nueva población self.population = elite_individuals + mutated_offspring_population # Asegurar que la población tenga el tamaño correcto, # truncando si es necesario self.population = self.population[: self.population_size] print("\nOptimización Finalizada.") if self.best_solution_overall is not None: print(f"Mejor fitness global encontrado: {self.best_fitness_overall:.4f}") else: print( "No se encontró ninguna solución (la población podría haber colapsado)." ) return self.best_solution_overall, self.best_fitness_overall
[documentos] def plot_fitness(self, save_path: Optional[str] = None) -> None: """ Grafica la evolución del mejor fitness a lo largo de las generaciones. Utiliza Matplotlib para generar la gráfica. Si Matplotlib no está instalado, imprime un mensaje de advertencia. Args: save_path: Ruta opcional para guardar la gráfica como archivo de imagen. Ej: "fitness_evolution.png". Si es None, solo muestra la gráfica. """ if not self.best_solutions_fitness: print("No hay datos de fitness para graficar (ejecute el AG primero).") return try: import matplotlib.pyplot as plt plt.figure(figsize=(10, 6)) plt.plot( range(1, len(self.best_solutions_fitness) + 1), # Generaciones desde 1 self.best_solutions_fitness, marker="o", linestyle="-", markersize=4, color="b", label="Mejor Fitness por Generación", ) plt.axhline( y=self.best_fitness_overall, color="r", linestyle="--", label=f"Mejor Fitness Global: {self.best_fitness_overall:.4f}", ) plt.title("Evolución del Fitness por Generación") plt.xlabel("Generación") plt.ylabel("Mejor Fitness") plt.legend() plt.grid(True, linestyle="--", alpha=0.7) plt.tight_layout() if save_path: plt.savefig(save_path) print(f"Gráfica de fitness guardada en: {save_path}") else: plt.show() except ImportError: print( "Matplotlib no está instalado. No se puede graficar. " "Instálalo con: pip install matplotlib" ) except Exception as e: print(f"Ocurrió un error al graficar el fitness: {e}")