aletheia-genetic-optimizers 2.2.1
Librería para soluciones de algoritmos genéticos de optimización
Requires Python: <3.14,>=3.10

# AletheIA Genetic Optimizers (AGO)

## Equipo de desarrollo

| Nombre                  | Rol                         | Información de contacto  | Perfil de LinkedIn                                          |
|-------------------------|-----------------------------|--------------------------|-------------------------------------------------------------|
| Daniel Sarabia Torres   | Full Stack AI & Q Developer | dsarabiatorres@gmail.com | https://es.linkedin.com/in/danielsarabiatorres              |
| Luciano Ezequiel Bizin  | Full Stack AI & Q Developer | lucianobizin@gmail.com   | https://www.linkedin.com/in/luciano-ezequiel-bizin-81b85497 |

## Instalación

```pip install aletheia-genetic-optimizers```

## Introducción

Este proyecto implementa un algoritmo genético clásico diseñado para abordar problemas de optimización combinatoria. 

La arquitectura del algoritmo utiliza los principios clásicos de la evolución, buscando así, encontrar soluciones óptimas sin la necesidad de recorrer todo el espacio de datos.

El objetivo principal del AletheIA Genetic Optimizar es resolver dos clases generales de problemas:

- *`bounds_restricted`*: Problemas con límites o restricciones estructurales parciales, en los que se permite cierta flexibilidad pero dentro de márgenes definidos (por ejemplo, pensar en la búsqueda de hiperparámetros para un modelo de ML o DL).

- *`totally_restricted`*: Problemas con restricciones estrictas en los valores permitidos y en las configuraciones válidas, como el Traveling Salesman Problem (TSP), donde solo se aceptan permutaciones válidas sin repeticiones.

--------------------------------------------

## Explicación detallada de casos de uso 

Se explica de manera detallada ambos casos de uso: `bounds_restricted` (búsqueda de hiperparámetros para un modelo de ML) y `totally_restricted` (TSP)

## Caso de uso 1: 

En este apartado se describe el caso de uso para resolver el problema de encontrar los mejores hiperparámetros para un modelo de IA, en este caso, para un modelo básico de ML.

### Entendimiento del problema: El problema de encontrar los mejores hiperparámetros para un modelo de IA

El problema encontrar los mejores hiperparámetros para un modelo de IA puede ser abarcado sin ningún problema con los simuladores u ordenadores cuánticos de hoy en día. 

Imaginar que queremos entrenar una red neuronal o un modelo de ML, y para tal situación, necesitamos encontrar los mejores hiperparámetros para nuestro modelo.

El desafío radica en que, a medida que aumenta el número de hiperparámetros a optimizar (aunque también puede ser la estructura de la red neuronal o cantidad de nueronas si se ha categorizado estos valores), es necesario encontrar una muy buena combinación de hiperparámetros, lo que resulta muy costoso utilizando métodos tradicionales.

### Ejemplo de código

```
from sklearn.datasets import fetch_california_housing, fetch_openml, load_breast_cancer
from sklearn.metrics import accuracy_score, r2_score, mean_absolute_error
from aletheia_genetic_optimizers import GenethicOptimizer, BoundCreator
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from typing import Literal

import lightgbm as lgb
import pandas as pd
import numpy as np
import datetime



# -- Definimos la función objetivo
def example_1_bounds_no_predefinidos():

    def objective_function(individual, dataset_loader: Literal["fetch_california_housing", "glass", "breast_cancer"] = "breast_cancer", random_state=42):
        # Cargar dataset
        match dataset_loader:
            case "fetch_california_housing":
                data = fetch_california_housing()
                problem_type = 'regression'
                test_size = 0.2
            case "breast_cancer":
                data = load_breast_cancer()
                problem_type = 'binary'
                test_size = 0.7
            case "glass":
                data = fetch_openml(name="glass", version=1, as_frame=True)
                problem_type = 'multiclass'
                test_size = 0.2
            case _:
                data = fetch_california_housing()
                problem_type = 'regression'
                test_size = 0.2

        # print(f"PROBLEMA SKLEARN: {dataset_loader}")
        individual_dict = individual.get_individual_values()
        X, y = data.data, data.target

        # Dividir y normalizar
        X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=test_size, random_state=random_state)
        scaler = StandardScaler()
        X_train = pd.DataFrame(scaler.fit_transform(X_train), columns=data.feature_names)
        X_test = pd.DataFrame(scaler.transform(X_test), columns=data.feature_names)

        # Tiempo inicial
        start_time = datetime.datetime.now()

        # Entrenar y predecir
        if problem_type == 'regression':
            model = lgb.LGBMRegressor(
                n_estimators=int(individual_dict["n_estimators"]),
                max_depth=individual_dict["max_depth"],
                verbose=-1,
                random_state=random_state
            )
            model.fit(X_train, y_train)
            y_pred = model.predict(X_test)
            score = r2_score(y_test, y_pred)
            mae = mean_absolute_error(y_test, y_pred)

            # print(f"Mae: {mae} - R2: {score}")

        else:
            model = lgb.LGBMClassifier(
                n_estimators=int(individual_dict["n_estimators"]),
                max_depth=individual_dict["max_depth"],
                verbose=-1,
                random_state=42
            )
            model.fit(X_train, y_train)
            y_pred = model.predict(X_test)
            score = accuracy_score(y_test, y_pred)

            # print(f"Acc: {score}")

        # Calcular tiempo
        elapsed_time = (datetime.datetime.now() - start_time).total_seconds()

        # Penalización por tiempo
        reference_time = 0.5  # segundos
        max_penalty_ratio = 0.01  # 1%
        time_penalty = max_penalty_ratio * score * (elapsed_time / reference_time)
        time_penalty = min(time_penalty, max_penalty_ratio * score)

        penalized_score = score - time_penalty

        # print(f"[{problem_type.upper()}] Score: {score:.4f} | Penalized: {penalized_score:.4f} | Time: {elapsed_time:.2f}s")

        return score

    # -- Creamos el diccionario de bounds
    bounds = BoundCreator()
    bounds.add_interval_bound("n_estimators", 50, 1000, 10, 1500, "int")
    bounds.add_predefined_bound("max_depth", (1, 2, 3, 4, 5, 6, 7, 8, 9), "int")

    return GenethicOptimizer(bounds.get_bound(),
                             50,
                             20,
                             objective_function,
                             "bound_restricted",
                             "maximize",
                             "ea_simple",
                             3,
                             0.25,
                             0.35,
                             )

genetic_optimizer_object: GenethicOptimizer = example_1_bounds_no_predefinidos()
print(genetic_optimizer_object.get_best_individual().get_individual_values())

```

#### El enfoque genético

El código proporcionado implementa un algoritmo genético para abordar el problema de encotrar los mejores hiperparámetros de un modelo de IA. 

A continuación, se explica cómo funciona este algoritmo y sus potenciales ventajas:

1. *Introducción programática:* 

* El `individual` que recibe la función `objective_function` representa un conjunto de hiperparámetros (-por ejemplo: {max_depth: 2, n_estimators: 325-).
* El algoritmo genético se ejecuta al momento de generarse la primera generación, se obtienen los fitness de cada Individual y se pasa a la fase de torneo y reproducción.
* ¿Cómo sucede este proceso de selección? Partimos de los individuos de la generación previa, se seleccionan aleatoriamente un número 'podium_size' de individuos y se ponen a competir. Esta competición consiste en que el mejor de los 'podium_size' pasa a la siguiente fase y el resto es descartado.
* ¿Cómo sucede este proceso de reproducción? Dependiendo del tipo de problema, se mezclan los genes de los individuos de una forma u otra, obteniendo así los descendientes
* Finalmente, se lleva a cabo el proceso de mutación (en el que cada gen tiene una probabilidad x de mutar) para agregar variabilidad extra

2. *Función objetivo (`objective_function`):*

Esta función calcula el accuracy o el MAE dependiendo del problema, o cualquier otra métrica de interés.

* Toma un `individual` (un objeto que posee como valores la solución potencial) como entrada.
* Recupera los hiperparámetros del `individual` (reproducido y mutado en el algorítmo genético cuántico).
* Utiliza esos hiperparámetros para calcular el resultado de la función objetivo.
* Se devuelve el `score`, que el algoritmo genético busca minimizar/maximizar según el tipo de problema.

3. *Algoritmo genético (`GenethicOptimizer`):* Esta clase implementa la lógica central del algoritmo genético cuántico.

    * *Inicialización:* Comienza creando una población de soluciones potenciales (rutas). La naturaleza cuántica se utiliza para generar esta población inicial.
    * *Evaluación:* La función `objective_function` se utiliza para evaluar la "aptitud" de cada individuo en la población.
    * *Selección:* Los individuos con mejor aptitud tienen más probabilidades de ser seleccionados como "mejores padres" para crear la siguiente generación.
    * *Cruce:* El material genético (hiperparámetros) de dos individuos padres se combinan para crear nuevos individuos descendientes. 
    * *Mutación:* Se introducen pequeños cambios aleatorios en los valores de los hiperparámetros de los descendientes para mantener la diversidad en la población y evitar quedar atrapado en óptimos locales.
    * *Terminación:* El algoritmo continúa durante un número específico de generaciones (`num_generations`) o hasta que se cumple un criterio de detención (`early_stopping_generations`).

4*Límites (`BoundCreator`):* Los límites definen los posibles valores para cada "gen" en el individuo (en este caso, el índice de una ciudad).

### Posibles usos reales

El Aletheia Genetic Optimizers está diseñado para resolver varios tipos de problemas de optimización. Uno de los escenarios para los que se ha programado, es encontrar los mejores hiperparámetros de modelos de IA, o problemas de tipo similar. 

A nivel real -al momento- puede ser utilizado para resolver cuantiosos problemas de este tipo:

| Industria             | Tipo de datos                                   | Ejemplos de modelos a usar   | Beneficio del enfoque cuántico-genético                                      |
|-----------------------|--------------------------------------------------|------------------------------------|--------------------------------------------------------------------------------|
| Finanzas              | Transacciones bancarias                          | LGBMClassifier, XGBoost            | Detección más precisa de fraude, menos falsos positivos, búsqueda más rápida  |
| Energía               | Variables climáticas, consumo histórico          | LGBMRegressor, Redes neuronales    | Mejor predicción de demanda, optimización eficiente de recursos energéticos   |
| Salud                 | Datos clínicos, imágenes médicas                 | RandomForest, CNNs, LGBMClassifier | Diagnósticos automáticos más precisos y rápidos                               |
| Marketing y Ventas    | Historial de usuarios, CRM                       | XGBoost, CatBoost, Árboles         | Mejor segmentación y predicción de abandono (churn)                           |
| Ciberseguridad        | Logs de red, sesiones de conexión                | RandomForest, SVM, LSTM            | Mejora en la detección de tráfico malicioso, reducción de falsos negativos    |
| Agricultura           | Condiciones ambientales, suelo, clima            | LGBMRegressor, Redes neuronales    | Predicción precisa del rendimiento, decisiones agronómicas más acertadas      |
| Industria 4.0         | Datos sensoriales de maquinaria                  | LSTM, RandomForest, SVM            | Mantenimiento predictivo, detección temprana de fallas                        |
| Educación             | Comportamiento de estudiantes, resultados        | Sistemas de recomendación, Árboles | Personalización del aprendizaje, mayor engagement y retención de alumnos      |
| Transporte y Logística| Rutas, tiempos de entrega, demanda               | Regresión, KNN, RandomForest       | Optimización del reparto, predicción de demanda, mejora en eficiencia logística|
| Recursos Humanos      | CVs, entrevistas, datos de rendimiento laboral   | NLP + árboles, XGBoost             | Mejora del proceso de selección, predicción de rendimiento de empleados       |
| Medioambiente         | Datos satelitales, sensores ambientales          | LGBM, CNN, RandomForest            | Monitoreo ambiental en tiempo real, predicción de fenómenos climáticos        |
| Telecomunicaciones    | Datos de llamadas, uso de apps, geolocalización  | CatBoost, Redes neuronales         | Predicción de churn, optimización de red, segmentación de usuarios            |
| eCommerce             | Historial de compras, comportamiento de navegación| XGBoost, Recommender Systems       | Mejora en sistemas de recomendación y personalización                         |
| Sector Legal          | Documentos legales, jurisprudencia, contratos    | NLP + Transformers, Decision Trees | Análisis automatizado de textos legales, detección de cláusulas de riesgo     |
| Juegos y Entretenimiento | Comportamiento de jugadores, patrones de uso  | RandomForest, Deep Q-Learning      | Optimización de diseño de niveles, detección de comportamiento irregular      |


### Ventajas de usar el AletheIA Genetic Optimizers para este tipo de problemas

* *Exploración mejorada del espacio de soluciones:* En lugar de usar metodos de fuerza bruta como RandomSearch o GridSearch, el algoritmo genético converge más rápido y mejor por la búsqueda guiada.

#### Conclusiones luego de ver los resultados

Para este tipo de problemas, el AletheIA Genetic Optimizers ha demostrado ser una herramienta útil y versátil a la hora de encontrar soluciones a este tipo de problemas .

En general, en las pruebas realizadas para contrastar su potencialidad con respecto a otros algoritmos genético cuánticos, ha sido superado por el AletheIA Quantum Genetic Optimizers, en cantidad de generaciones requeridas para alcanzar la mejor métrica posible para el modelo y problema evaluado, tanto para problemas de clasificación como de regresión. Sin embargo, se debe tener en cuenta, que no converge a la manera que lo hace un optimizador clásico donde se puede notar que toda la población va acompañando la suba o baja de la métrica evaluada. En el caso del AletheIA Quantum Genetic Optimizers se aprecia un procedimiento más elitista, es decir, encuentra por capacidad explotaria máximos o mínimos globales muy buenos, pero lo hacen pocos individuos de cada población.

--------------------------------------------

## Caso de uso 2: 

En este apartado se describe el caso de uso para resolver el Problema del Viajero (TSP, por sus siglas en inglés) en sus variantes de ciclo cerrado y abierto.

### Entendimiento del problema: El problema del viajante de comercio (TSP)

El problema del viajante de comercio es un problema clásico de optimización combinatoria. 

Imaginar un viajante que necesita visitar un conjunto de ciudades, cuyo objetivo es encontrar la ruta más corta posible que visite cada ciudad exactamente una vez y, dependiendo de la variante:

* *Ciclo cerrado (con retorno al origen):* El viajante debe regresar a la ciudad de inicio después de visitar todas las demás ciudades, formando un tour o ciclo completo.
* *Ciclo abierto (sin retorno al origen):* El viajante visita cada ciudad exactamente una vez, pero no necesita regresar a la ciudad de inicio.

El desafío radica en que, a medida que aumenta el número de ciudades, la cantidad de posibles rutas crece factorialmente, lo que hace que encontrar la solución óptima sea computacionalmente muy costoso utilizando métodos de fuerza bruta. Por lo que el algoritmo genético se presenta como una solución eficiente que consigue muy buenos resultados en tiempos reducidos. 

### Ejemplo de código

```
from sklearn.datasets import fetch_california_housing, fetch_openml, load_breast_cancer
from aletheia_genetic_optimizers import GenethicOptimizer, BoundCreator, Individual
from sklearn.metrics import accuracy_score, r2_score, mean_absolute_error
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from typing import Literal

import lightgbm as lgb
import pandas as pd
import numpy as np
import datetime


def example_2_tsp():
    # Coordenadas de ciudades (ejemplo con 5 ciudades)

    def objective_function(individual):
        original_cities = {
            0: (4.178515558765522, 3.8658505110962347),
            1: (9.404615166221248, 8.398020682045034),
            2: (0.3782334121284714, 8.295288013706802),
            3: (9.669161753695562, 5.593501025856912),
            4: (9.870966532678576, 4.756484445482374),
            5: (3.5045826424785007, 1.1043994011149494),
            6: (5.548867108083866, 5.842473649079045),
            7: (1.11377627026643, 1.304647970128091),
            8: (5.133591646349645, 3.8238217557909038),
            9: (7.074655346940579, 3.6554091142752734),
            10: (9.640123872995837, 1.3285594561699254),
            11: (0.021205320973052277, 7.018385604153457),
            12: (2.048903069073358, 2.562383464533476),
            13: (2.289964825687684, 4.325937821712228),
            14: (6.315627335092245, 3.7506598107821656),
            15: (1.0589427543395036, 6.2520630725232),
            16: (9.218474645470067, 4.106769373018785),
            17: (4.62163288328154, 9.583091224200263),
            18: (7.477615269848112, 7.597659062497909),
            19: (0.25092704950321565, 6.699275814039302),
        }

        # Obtener valores únicos en route y ordenarlos
        route = list(individual.get_individual_values().values())
        unique_cities = sorted(set(route))  # Se asegura de tener un orden fijo

        # Crear nuevo índice de ciudades dinámico
        cities = {idx: original_cities[city] for idx, city in enumerate(unique_cities)}

        num_cities = len(cities)
        distance_matrix = np.zeros((num_cities, num_cities))

        # Calcular la matriz de distancias con los nuevos índices
        for i in range(num_cities):
            for j in range(num_cities):
                if i != j:
                    x1, y1 = cities[i]
                    x2, y2 = cities[j]
                    distance_matrix[i][j] = np.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2)

        # Convertir route a los nuevos índices
        route_mapped = [unique_cities.index(city) for city in route]

        # Calcular distancia total
        total_distance = sum(
            distance_matrix[route_mapped[i]][route_mapped[(i + 1) % len(route_mapped)]]
            for i in range(len(route_mapped))
        )

        return total_distance

    # -- Creamos el diccionario de bounds
    range_list: tuple = tuple([z for z in range(0, 20)])
    bounds = BoundCreator()
    bounds.add_predefined_bound("city_zero", range_list, "int")
    bounds.add_predefined_bound("city_one", range_list, "int")
    bounds.add_predefined_bound("city_two", range_list, "int")
    bounds.add_predefined_bound("city_three", range_list, "int")
    bounds.add_predefined_bound("city_four", range_list, "int")
    bounds.add_predefined_bound("city_five", range_list, "int")
    bounds.add_predefined_bound("city_six", range_list, "int")
    bounds.add_predefined_bound("city_seven", range_list, "int")
    bounds.add_predefined_bound("city_eight", range_list, "int")
    bounds.add_predefined_bound("city_nine", range_list, "int")
    bounds.add_predefined_bound("city_ten", range_list, "int")
    bounds.add_predefined_bound("city_eleven", range_list, "int")
    bounds.add_predefined_bound("city_twelve", range_list, "int")
    bounds.add_predefined_bound("city_trece", range_list, "int")
    bounds.add_predefined_bound("city_14", range_list, "int")
    bounds.add_predefined_bound("city_15", range_list, "int")
    bounds.add_predefined_bound("city_16", range_list, "int")
    bounds.add_predefined_bound("city_17", range_list, "int")
    bounds.add_predefined_bound("city_18", range_list, "int")
    bounds.add_predefined_bound("city_19", range_list, "int")

    return GenethicOptimizer(bounds.get_bound(),
                             500,
                             100,
                             objective_function,
                             "full_restricted",
                             "minimize",
                             "ea_simple",
                             3,
                             0.25,
                             0.1,
                             'normal',
                             True
                             )

genetic_optimizer_object: GenethicOptimizer = example_2_tsp()
print(genetic_optimizer_object.get_best_individual().get_individual_values())
```

#### El enfoque genético

El código proporcionado implementa un algoritmo genético cuántico para abordar el problema del Traveling Salesman Problem (TSP). 

A continuación, se explica cómo funciona este algoritmo y sus potenciales ventajas:

1. *Introducción programática:* 

* El `individual` que recibe la función `objective_function` representa una ruta potencial (una permutación específica de las ciudades -por ejemplo: [2, 9, 4, 5, 7, ... 1]).
* Se evalúa el fitness de cada individuo y se pasan al toneo para quedarnos con los mejores
* Se realiza la reproducción ox1 en la que se cruzan segmentos de los genes de dos padres para obtener los hijos
* Se realiza la mutación bit_flip en la que un gen puede cambiarse de posición con el otro
* Volvemos a evaluar los nuevos individuos en la función objetivo y repetimos lo anterior

2. *Función objetivo (`objective_function`):*

Esta función calcula la distancia total de una ruta dada.

* Toma un `individual` (un objeto que posee como valores la solución potencial) como entrada.
* Recupera el orden de las ciudades visitadas del `individual` (reproducido y mutado en el algorítmo genético cuántico).
* Utiliza las coordenadas de `original_cities` para calcular las distancias entre ciudades consecutivas en la ruta.
* El parámetro `return_to_origin` controla si se incluye la distancia desde la última ciudad de vuelta a la primera (para un ciclo cerrado).
* Devuelve la `total_distance`, que el algoritmo genético busca minimizar.

3. *Algoritmo genético (`GenethicOptimizer`):* Esta clase implementa la lógica central del algoritmo genético.

    * *Inicialización:* Comienza creando una población de soluciones potenciales (rutas).
    * *Evaluación:* La función `objective_function` se utiliza para evaluar la "aptitud" de cada individuo en la población (menor distancia = mayor aptitud, ya que se está minimizando).
    * *Selección:* Los individuos con mejor aptitud tienen más probabilidades de ser seleccionados como "mejores padres" para crear la siguiente generación.
    * *Cruce OX1 (recombinación):* El material genético (partes de las rutas) de dos individuos padres se combina para crear nuevos individuos descendientes.  
    * *Mutación:* Se introducen pequeños cambios aleatorios en las rutas de los descendientes para mantener la diversidad en la población y evitar quedar atrapado en óptimos locales.
    * *Terminación:* El algoritmo continúa durante un número específico de generaciones (`num_generations`) o hasta que se cumple un criterio de detención (`early_stopping_generations`).

4. *Restricciones del problema (`problem_restrictions="totally_restricted"`):* Este parámetro indica que el algoritmo debe encontrar una ruta válida donde cada ciudad se visite exactamente una vez (y regrese al origen si `return_to_origin` es `True`). En el problema del TSP solo se revisa que estén todas las ciudades.

5. *Límites (`BoundCreator`):* Los límites definen los posibles valores para cada "gen" en el individuo (en este caso, el índice de una ciudad).

### Posibles usos reales

A nivel real -al momento- puede ser utilizado para resolver problemas TSP del ámbito de:

* *Logística y servicios de entrega:*
    * *Optimización de rutas (en este caso solo euclidianas, por el momento):* Encontrar las rutas de entrega más eficientes para mensajeros, servicios postales o minoristas en línea para minimizar el tiempo de viaje, el consumo de combustible y los costos. Esto podría ser un ciclo cerrado si el conductor necesita regresar a un depósito o un ciclo abierto si termina en un destino final.

* *Fabricación y robótica:*
    * *Perforación de placas de circuito impreso:* Optimizar la trayectoria de una máquina perforadora para crear agujeros en una placa de circuito, minimizando el tiempo necesario para moverse entre los puntos de perforación.
    * *Soldadura/pintura robótica:* Encontrar la secuencia más eficiente de puntos para que un brazo robótico suelde o pinte componentes.
    * *Corte láser o fresado CNC:* Determinar la mejor secuencia de cortes en una pieza de material para reducir los movimientos innecesarios de la herramienta.

* *Transporte y viajes:*
    * *Planificación de tours:* Crear el itinerario más corto o eficiente para un turista que visita múltiples atracciones.
    * *Mantenimiento de infraestructuras:* Definir la ruta óptima para un equipo de mantenimiento que debe inspeccionar una serie de ubicaciones (por ejemplo, torres de telecomunicaciones, estaciones eléctricas o pozos petroleros).

* *Ciencias biológicas y medicina:* 
  * Reconstruir el orden de los fragmentos de ADN encontrando el camino más corto que visita cada fragmento basándose en la información de superposición.
  * Automatización en laboratorios: Optimizar el recorrido de brazos robóticos que deben dispensar líquidos o recolectar muestras en distintos puntos.

* *Agricultura de precisión*:
  * Monitoreo de cultivos: Determinar la mejor ruta para drones o tractores autónomos que deben visitar múltiples puntos de monitoreo o tratamiento dentro de un campo.

* *Astronomía:* 
  * Optimizar la secuencia de observaciones para telescopios para minimizar el tiempo dedicado a moverse entre objetos celestes.
  
* Mantenimiento de flotas aéreas o ferroviarias:*
  * Inspección de unidades: Encontrar la secuencia más eficiente de revisiones técnicas o inspecciones a realizar en una base o red distribuida de vehículos.

#### Ciclo cerrado vs. ciclo abierto

La variable `return_to_origin: bool = True` dentro de su función `objective_function` es clave para manejar ambas variantes del TSP:

* *Ciclo cerrado:* Cuando `return_to_origin` es `True` (como en su `objective_function`), la distancia desde la última ciudad de la ruta de vuelta a la primera ciudad se suma a la distancia total, cerrando efectivamente el ciclo.
* *Ciclo abierto:* Para resolver un TSP de ciclo abierto, simplemente se necesita establecer `return_to_origin` en `False`. En este caso, el algoritmo intentaría encontrar el camino más corto que visita todas las ciudades, partiendo del primer elemento (ciudad 0) sin requerir un retorno al punto de partida.

--------------------------------------------

## 1. Clase principal: GeneticOptimizer

La clase `GenethicOptimizer` implementa un algoritmo genético cuántico para resolver problemas de optimización combinatoria con restricciones específicas. 

Esta clase utiliza tanto computación cuántica o simuladores cuánticos para generar individuos y optimizar el espacio de soluciones mediante la evolución de una población.

### 1.1. Atributos

- **bounds_dict**: Diccionario de parámetros a optimizar y sus valores. Ejemplo: `{'learning_rate': (0.0001, 0.1)}`
- **num_generations**: Número de generaciones a ejecutar.
- **num_individuals**: Número de individuos iniciales a generar.
- **max_qubits**: Número máximo de qubits a emplear para reproducir individuos.
- **objective_function**: Función objetivo que se utiliza para puntuar a cada individuo. Debe retornar un valor numérico (float).
- **metric_to_optimize**: Métrica a optimizar (e.g., 'accuracy', 'recall', 'f1', 'mae').
- **problem_restrictions**: Restricciones aplicadas al problema ('bound_restricted' o 'totally_restricted').
- **return_to_origin**: Indica si el problema debe terminar en el origen (solo para problemas 'totally_restricted').
- **problem_type**: Tipo de optimización ('minimize' o 'maximize').
- **tournament_method**: Método de selección de individuos para reproducción (e.g., 'ea_simple').
- **podium_size**: Tamaño del grupo de individuos que competirá en el torneo para selección.
- **mutate_probability**: Probabilidad de mutación en cada gen.
- **mutate_gen_probability**: Probabilidad de mutar un gen.
- **mutation_policy**: Política de mutación ('soft', 'normal', 'hard').
- **verbose**: Indica si se deben mostrar mensajes detallados sobre la evolución.
- **early_stopping_generations**: Número de generaciones para considerar el "early stopping".
- **variability_explossion_mode**: Modo de explosión de variabilidad ('crazy').
- **variability_round_decimals**: Número de decimales para redondear estadísticas de la variabilidad.

### 1.2. Métodos

#### 1.2.2. `__init__()`
Este es el constructor de la clase `GenethicOptimizer`. 

Se encarga de inicializar todos los parámetros del algoritmo, como el tipo de optimización, las restricciones, las configuraciones de la mutación, entre otros. 

Además, se prepara el entorno de computación cuántica, si es necesario.


```
        :param bounds_dict: Diccionario en el que se definen los parámetros a optimizar y sus valores, ej. '{learning_rate: (0.0001, 0.1)}'
        :param num_generations: Numero de generaciones que se van a ejecutar
        :param num_individuals: Numero de Individuos iniciales que se van a generar
        :param max_qubits: Numero máximo de qubits a emplear para reproducir individuos (define el numero entero y la parte decimal de los numeros enteros y flotantes que se quieren generar)
        :param objective_function: Función objetivo que se va a emplear para puntuar a cada individuo (debe retornar un float)
        :param metric_to_optimize: Metrica que se quiere optimizar ['accuracy', 'recall', 'specificity', 'f1',
        'aur_roc', 'precision', 'negative_precision', 'mae', 'mse', 'other'] -> other significa cualquier otra genérica.
        Por ejemplo, se puede utilizar other para un problema de optimización de tipo viajante de comercio.
        :param problem_restrictions: ['bound_restricted', 'totally_restricted'] Restricciones que se van a aplicar a la hora de crear individuos, reprocirlos y mutarlos
        :param return_to_origin: [Literal['return_to_origin', 'no_return'] | None] En caso de problemas totally_restricted es necesario saber si el problema termina en el origen o no es necesario que suceda esto
        :param problem_type: [minimize, maximize] Seleccionar si se quiere minimizar o maximizar el resultado de la función objetivo. Por ejemplo si usamos un MAE es minimizar,
         un Accuracy sería maximizar.
        :param tournament_method: [easimple, .....] Elegir el tipo de torneo para seleccionar los individuos que se van a reproducir.
        :param podium_size: Cantidad de individuos de la muestra que van a competir para elegir al mejor. Por ejemplo, si el valor es 3, se escogen iterativamente 3 individuos
        al azar y se selecciona al mejor. Este proceso finaliza cuando ya no quedan más individuos y todos han sido seleccionados o deshechados.
        :param mutate_probability:Tambien conocido como indpb ∈[0, 1]. Probabilidad de mutar que tiene cada gen. Una probabilidad de 0, implica que nunca hay mutación,
        una probabilidad de 1 implica que siempre hay mutacion.
        :param mutate_gen_probability: [float] Probabilidad de mute un gen
        :param mutation_policy: Literal['soft', 'normal', 'hard'] Política de mutación (liviana, estandar y agresiva),
        :param verbose: Variable que define si se pinta información extra en consola y si se generan los graficos de los circuitos cuánticos.
        :param early_stopping_generations: Cantidad de generaciones que van a transcurrir para que en caso de repetirse la moda del fitness, se active el modo variability_explosion
        :param variability_explossion_mode: Modo de explosion de variabilidad, es decir, que se va a hacer para intentar salir de un minimo local establecido
        :param variability_round_decimals: Decimales a los que redondear las estadisticas de cálculo de moda necesarias para la explosion de variabilidad. Por ejemplo,
        en un caso de uso que busque accuracy, podría ser con 2 o 3 decimales. para casos de uso que contengan números muy bajos, habría que agregar más.
 
```

#### 1.2.1. Proceso de evolución

##### Creación y evaluación de la población inicial

1. *Inicialización de la población*: Se crea la población inicial de individuos, asignando valores aleatorios dentro de los límites definidos por `bounds_dict`.
   
2. *Evaluación de la función objetivo*: Cada individuo en la población inicial pasa por la función objetivo para obtener su fitness.

##### Evolución por generaciones

1. *Selección por torneo*: Cada generación selecciona a los mejores individuos utilizando un torneo. Los individuos ganadores serán los padres de la siguiente generación.

2. *Reproducción*: Los padres seleccionados se reproducen para crear nuevos individuos.

3. *Mutación*: 
    - *Mutación de los hijos*: Los nuevos individuos generados pasan por un proceso de mutación. Dependiendo de la probabilidad y política de mutación (`mutate_probability`, `mutate_gen_probability`, `mutation_policy`), se modifican algunos de los genes de los hijos.
    - *Actualización de la población*: Los individuos mutados se agregan a la población de la generación actual.

4. *Evaluación de la función objetivo*: Los nuevos individuos generados son evaluados nuevamente utilizando la función objetivo para obtener sus valores de fitness.

##### Estadísticas y seguimiento

1. *Estadísticas de la generación*: 
    - Después de cada generación, se muestra la información relevante sobre los mejores individuos y su fitness.
    - Se imprime la mejor solución obtenida de cada generación.
    - Si se activa el modo de "explosión de variabilidad", se muestra si se ha superado un mínimo local.

2. *Early Stopping y variabilidad*: 
    - Si se detecta que el modelo está atrapado en un mínimo local, se aplica una "explosión de variabilidad" para tratar de escapar de este mínimo, modificando las probabilidades de mutación y ajustando la política de mutación.

3. *Condición de parada*: 
    - El algoritmo puede detenerse antes de llegar al número máximo de generaciones si se cumplen ciertas condiciones de parada temprana (por ejemplo, si no se mejora el fitness en un número determinado de generaciones).

##### Resultado final

1. *Impresión de resultados finales*: 
    - Al finalizar el proceso, se muestran los resultados finales de cada generación y el mejor individuo encontrado, incluyendo su fitness y valores.

---

### 1.3. Flujo de trabajo

1. Inicialización de la población con parámetros aleatorios dentro de los límites de `bounds_dict`.
2. Evaluación de la función objetivo para cada individuo.
3. Selección de los mejores individuos mediante un torneo.
4. Reproducción y mutación de los individuos seleccionados.
5. Evaluación de la función objetivo nuevamente para los nuevos individuos.
6. Impresión de estadísticas de cada generación y el mejor individuo.
7. Aplicación de "explosión de variabilidad" si el algoritmo detecta un mínimo local.
8. Continuación hasta completar el número máximo de generaciones o alcanzar el criterio de parada.

## 2. Clase Population

### 2.1. Descripción general
La clase `Population` gestiona una población de individuos para algoritmos genéticos cuánticos de optimización. 

Permite la creación y evolución de poblaciones utilizando tecnología cuántica para la generación de parámetros y propiedades de los individuos.

### 2.1.1 Constructor

```
def __init__(self,
             bounds_dict: Dict[str, Tuple[Union[int, float]]],
             num_individuals: int,
             problem_restrictions: Literal['bound_restricted', 'totally_restricted'],
             round_decimals: int = 3):
```

#### 2.1.2. Parámetros
- *bounds_dict*: Diccionario que define las propiedades y límites de cada individuo
- *num_individuals*: Número de individuos en la población
- *problem_restrictions*: Tipo de restricciones del problema ('bound_restricted' o 'totally_restricted')
- *round_decimals*: Número de decimales para redondeo en comparaciones de similitud (predeterminado: 3)

#### 2.1.3. Atributos principales
- *IT*: Instancia de InfoTools para mostrar información
- *bounds_dict*: Diccionario con los límites de cada propiedad
- *num_individuals*: Cantidad de individuos en la población
- *problem_restrictions*: Tipo de restricción del problema
- *populuation_dict*: Diccionario que almacena los individuos por generación
- *hyperparameters*: Diccionario con los hiperparámetros extraídos de bounds_dict

### 2.2.1 create_population()

```
def create_population(self)
```

Crea la población inicial

#### 2.2.2. Parámetros
- ** None

### 2.3.1. add_generation_population()
```
def add_generation_population(self, children_list: List[Individual], generation: int) -> None
```
Añade una nueva generación de individuos a la población.

### 2.4.1. get_generation_fitness_statistics()
```
def get_generation_fitness_statistics(self, generation: int)
```
Calcula y devuelve estadísticas de fitness para una generación específica.

### 2.5.1. plot_generation_stats()
```
def plot_generation_stats(self, variability_explosion_starts_in_generation: int | None)
```
Genera un gráfico interactivo con la evolución de las estadísticas por generación.

### 2.6.1. plot_evolution_animated()
```
def plot_evolution_animated(self, problem_type: Literal['minimize', 'maximize'] = "maximize", transition_duration_ms: int = 50) -> None
```
Crea una visualización animada de la evolución de la población a lo largo de las generaciones.

### 2.7.1. plot_evolution()
```
def plot_evolution(self) -> None
```
Genera un gráfico estático de la evolución de la población.


## 3. Clase Reproduction

### 3.1. Descripción General
La clase `Reproduction` gestiona el proceso de reproducción en algoritmos genéticos cuánticos de optimización. Es responsable de crear una nueva generación de individuos (hijos) a partir de individuos seleccionados (ganadores) mediante técnicas de optimización cuántica