MaterialTech Labs
Blog · Diseno Generativo

NSGA-II desde cero: el algoritmo que optimiza sin elegir

Cuando optimizas resistencia y peso a la vez, no hay una unica solucion — hay todo un frente de Pareto. NSGA-II lo encuentra con O(MN2), y este articulo te explica como, desde las matematicas hasta la implementacion.

Jul 2026 · 22 min de lectura · Diseno Generativo

1. De donde viene NSGA-II: una breve historia

La optimizacion multi-objetivo no es nueva. Vilfredo Pareto introdujo el concepto de optimalidad que lleva su nombre en 1896, pero tuvieron que pasar casi 90 anos hasta que los algoritmos evolutivos pudieran explotarlo. El primer intento fue VEGA (Vector Evaluated Genetic Algorithm) de Schaffer en 1984, que simplemente dividia la poblacion en subgrupos, cada uno seleccionando segun un objetivo distinto. Funcionaba, pero tendia a converger a los extremos del frente sin explorar los compromisos intermedios.

Goldberg (1989) propuso la idea clave: ordenar las soluciones por dominancia de Pareto. Sobre esta idea, Srinivas y Deb publicaron el NSGA original en 1994. Funcionaba bien, pero el non-dominated sorting tenia complejidad O(MN3), lo que lo hacia inviable para poblaciones grandes. Ademas, usaba fitness sharing para mantener diversidad, un parametro que requeria ajuste manual.

En 2002, Deb, Pratap, Agarwal y Meyarivan publicaron NSGA-II en IEEE Transactions on Evolutionary Computation, resolviendo ambos problemas: redujeron el sorting a O(MN2) con el algoritmo fast non-dominated sort, y sustituyeron el fitness sharing por crowding distance, eliminando todos los parametros de nicho. El paper acumula mas de 40.000 citas y NSGA-II sigue siendo, dos decadas despues, el algoritmo genetico multi-objetivo mas usado en ingenieria.

2. Formalizacion del problema multi-objetivo

Un problema de optimizacion multi-objetivo (MOP) se define como:

min F(x) = [f1(x), f2(x), …, fM(x)]T
sujeto a gj(x) ≤ 0,   j = 1, 2, …, J
hk(x) = 0,   k = 1, 2, …, K
xiL ≤ xi ≤ xiU,   i = 1, 2, …, n

Donde x es el vector de variables de decision en el espacio de diseno Rn, F(x) es el vector de funciones objetivo en el espacio de objetivos RM, y las restricciones g y h definen la region factible. A diferencia de la optimizacion clasica, no buscamos un unico punto optimo, sino el conjunto de Pareto: todas las soluciones donde no se puede mejorar un objetivo sin empeorar al menos otro.

Conceptos clave (asumiendo minimizacion en todos los objetivos):

  • Dominancia de Pareto: x1 domina a x2 si fi(x1) ≤ fi(x2) para todo i Y existe al menos un j tal que fj(x1) < fj(x2).
  • Solucion no-dominada: Nadie en la poblacion la domina. Es candidata a pertenecer al frente de Pareto.
  • Conjunto Pareto-optimo: Todas las soluciones no-dominadas del espacio factible completo (ideal inalcanzable).
  • Frente de Pareto: Imagen del conjunto Pareto-optimo en el espacio de objetivos. Es lo que NSGA-II aproxima.

Ejemplo concreto: disenamos un ala con 3 variables (cuerda, espesor, angulo) y 2 objetivos (minimizar Cd, maximizar -Cl). El diseno A (Cd=0.012, Cl=0.80) domina al diseno B (Cd=0.014, Cl=0.80) porque tiene menos arrastre con la misma sustentacion. Pero A (0.012, 0.80) no domina a C (0.010, 0.75): A tiene peor Cd pero mejor Cl, C al reves. Ambos son Pareto-optimos y pertenecen al frente.

3. Fast non-dominated sort: ordenar sin dominar

Es el corazon computacional de NSGA-II. Para cada solucion p calculamos dos estructuras:

  • np: contador de dominacion: cuantas soluciones dominan a p.
  • Sp: conjunto de soluciones que p domina.

Algoritmo:

  1. Para cada par (p, q) con p ≠ q, si p domina a q, anadir q a Sp. Si q domina a p, incrementar np.
  2. Soluciones con np = 0 forman el Frente 1 (son las no-dominadas).
  3. Para cada solucion p en el Frente actual, recorrer Sp: decrementar nq de cada q dominada. Si nq llega a 0, q pasa al siguiente frente.
  4. Repetir hasta clasificar todas las soluciones.

Complejidad: O(MN2). El bucle doble de comparacion (paso 1) cuesta O(MN2), y la propagacion por frentes es O(N2). La version original de NSGA (1994) hacia un sorting completo en cada frente, resultando en O(MN3). Para N=200, la diferencia es 200x mas rapido: de ahi que NSGA-II hiciera viables las poblaciones grandes.

4. Crowding distance: diversidad sin parametros

El frente de Pareto tiene infinitas soluciones, pero nuestra poblacion es finita: necesitamos un criterio para quedarnos con las mas "representativas". NSGA-II usa crowding distance, una estimacion de la densidad local alrededor de cada solucion basada en el perimetro del hipercubo formado por sus vecinas mas cercanas en cada objetivo.

Calculo para un frente no-dominado:

  1. Inicializar distancia di = 0 para todas las soluciones del frente.
  2. Para cada objetivo m:
    • Ordenar las soluciones del frente segun fm (ascendente).
    • Asignar d[primera] = d[ultima] = ∞. Esto garantiza que los extremos del frente siempre se preserven.
    • Para las intermedias: di += (fmi+1 - fmi-1) / (fmmax - fmmin).

La normalizacion por (fmax - fmin) hace que la metrica sea invariante a la escala de cada objetivo. Una distancia grande indica que la solucion esta en una zona poco explorada y es valiosa para la diversidad del frente.

El costo total de NSGA-II por generacion: O(MN2) del non-dominated sort + O(MN log N) del crowding distance = O(MN2). Para N=200 y M=3, son ~120K operaciones por generacion. Con 500 generaciones y cada evaluacion de objetivos costando 5 minutos en CFD, el tiempo total viene dominado por las 100.000 evaluaciones (= 200 × 500), no por el algoritmo en si.

5. El operador de comparacion: crowded-comparison

NSGA-II usa un operador de torneo binario especial, denotado ≺n:

i ≺n j si (rangoi < rangoj) o (rangoi = rangoj y di > dj)

Donde rango es el indice del frente de Pareto (1 = mejor, 2 = siguiente, etc.) y d es la crowding distance. En español: primero preferimos soluciones de mejor frente, y en caso de empate, las que estén en zonas menos pobladas. Este operador guía toda la selección del algoritmo (torneos para cruzamiento y para la siguiente generación).

6. SBX: Simulated Binary Crossover

El cruzamiento binario simulado, propuesto por Deb y Agrawal en 1995, emula el comportamiento del cruzamiento de un punto en codificacion binaria pero trabajando directamente con valores reales. Se aplica variable por variable con probabilidad 0.5.

Dados dos padres x1 y x2 (con x1 < x2), los hijos se generan mediante un factor de dispersion β:

c1 = 0.5 [(1+β)x1 + (1-β)x2]
c2 = 0.5 [(1-β)x1 + (1+β)x2]

Donde β se muestrea de una distribucion controlada por el parametro ηc (usual: 15–20):

β = (2u)1/(ηc+1)   si u ≤ 0.5
β = [1/(2-2u)]1/(ηc+1)   si u > 0.5

Donde u ~ U(0,1). Valores altos de ηc producen hijos muy cercanos a los padres (explotacion), valores bajos permiten explorar lejos. Tras generar los hijos, se recortan al rango [xL, xU] de cada variable. La probabilidad tipica de cruzamiento es 0.9; el 10% restante de individuos se clonan directamente.

7. Polynomial mutation

Tras el cruzamiento, cada variable de cada hijo puede mutar con probabilidad pm = 1/n (donde n es el numero de variables de decision). Si una variable x muta:

x' = x + δ(xU - xL)

Donde δ se calcula con el parametro ηm (usual: 20):

δ = (2u)1/(ηm+1) - 1   si u ≤ 0.5
δ = 1 - [2(1-u)]1/(ηm+1)   si u > 0.5

ηm alto produce perturbaciones muy pequenas (refinamiento local), ηm bajo permite saltos grandes. Ambas formulas usan la misma transformacion de distribucion: arrancar de u ∈ U(0,1) y aplicar la inversa de la CDF para obtener δ con la cola polinomica deseada.

8. Elitismo: (μ + λ) con frentes de Pareto

NSGA-II es fuertemente elitista. En cada generacion, tras generar Qt (N hijos) desde Pt (N padres):

  1. Se combinan en Rt = Pt ∪ Qt (2N individuos).
  2. Se aplica fast non-dominated sort a Rt, produciendo frentes F1, F2, …
  3. Pt+1 se llena con frentes completos (F1 primero, luego F2, ...) mientras quepan.
  4. Al llegar al ultimo frente que no cabe entero, se seleccionan las mejores soluciones segun crowded-comparison ≺n.

Esto garantiza que la mejor solucion encontrada jamas se pierde entre generaciones. Es un esquema (μ + λ) donde tanto padres como hijos compiten por sobrevivir, a diferencia del esquema (μ, λ) donde los padres mueren. En problemas con restricciones, la comparacion se modifica para que cualquier solucion factible domine a cualquier infactible, y entre infactibles gane la que tenga menor violacion agregada de restricciones.

9. Implementacion en Python desde cero

A continuacion presento una implementacion completa de NSGA-II en Python puro, valida para cualquier numero de variables y objetivos. Solo requiere NumPy. El codigo esta estructurado como una clase con metodos independientes para cada componente del algoritmo, facilitando su comprension y extension.

import numpy as np
from dataclasses import dataclass

@dataclass
class Individual:
    variables: np.ndarray
    objectives: np.ndarray
    rank: int = 0
    crowding_distance: float = 0.0
    constraints_violation: float = 0.0

class NSGA2:
    def __init__(self, n_vars, n_obj, bounds, objectives_fn,
                 pop_size=200, max_gen=500, pc=0.9, pm=None,
                 eta_c=20, eta_m=20, n_constraints=0,
                 mutation_fn=None, crossover_fn=None):
        self.n_vars = n_vars
        self.n_obj = n_obj
        self.bounds = np.array(bounds)  # shape (n_vars, 2)
        self.objectives_fn = objectives_fn
        self.pop_size = pop_size
        self.max_gen = max_gen
        self.pc = pc
        self.pm = pm if pm is not None else 1.0 / n_vars
        self.eta_c = eta_c
        self.eta_m = eta_m
        self.n_constraints = n_constraints
        self.mutation_fn = mutation_fn
        self.crossover_fn = crossover_fn
        self.population = []
        self.fronts = []
        self.history = []  # (gen, population snapshot)

    def _initialize(self):
        """Inicializar poblacion aleatoria dentro de bounds."""
        self.population = []
        for _ in range(self.pop_size):
            vars_arr = np.random.uniform(
                self.bounds[:, 0], self.bounds[:, 1]
            )
            obj = self._evaluate(vars_arr)
            self.population.append(
                Individual(variables=vars_arr, objectives=obj)
            )

    def _evaluate(self, x):
        """Evaluar objetivos y restricciones. Penalizar infactibles."""
        result = self.objectives_fn(x)
        if self.n_constraints > 0:
            obj = np.array(result[:self.n_obj])
            cons = np.array(result[self.n_obj:])
        else:
            obj = np.array(result)
            cons = np.array([])
        return obj, cons

    def dominates(self, ind1, ind2):
        """ind1 domina a ind2? (minimizacion en todos los objetivos)."""
        cv1 = ind1.constraints_violation
        cv2 = ind2.constraints_violation
        if cv1 < cv2:
            return True
        if cv2 < cv1:
            return False
        # Ambos factibles o misma violacion: comparar objetivos
        o1, o2 = ind1.objectives, ind2.objectives
        better = False
        for i in range(self.n_obj):
            if o1[i] > o2[i]:
                return False
            if o1[i] < o2[i]:
                better = True
        return better

    def fast_non_dominated_sort(self, population):
        """Clasifica la poblacion en frentes de Pareto."""
        n = len(population)
        S = [[] for _ in range(n)]
        n_p = [0] * n
        fronts = [[]]

        for p_idx in range(n):
            for q_idx in range(p_idx + 1, n):
                p, q = population[p_idx], population[q_idx]
                if self.dominates(p, q):
                    S[p_idx].append(q_idx)
                    n_p[q_idx] += 1
                elif self.dominates(q, p):
                    S[q_idx].append(p_idx)
                    n_p[p_idx] += 1

        for i in range(n):
            if n_p[i] == 0:
                population[i].rank = 0
                fronts[0].append(i)

        front_idx = 0
        while front_idx < len(fronts) and fronts[front_idx]:
            next_front = []
            for p_idx in fronts[front_idx]:
                for q_idx in S[p_idx]:
                    n_p[q_idx] -= 1
                    if n_p[q_idx] == 0:
                        population[q_idx].rank = front_idx + 1
                        next_front.append(q_idx)
            front_idx += 1
            if next_front:
                fronts.append(next_front)

        if not fronts[-1]:
            fronts.pop()
        return fronts

    def crowding_distance(self, front_indices):
        """Calcula crowding distance para un frente (por indice)."""
        if len(front_indices) <= 2:
            for idx in front_indices:
                self.population[idx].crowding_distance = float('inf')
            return

        for idx in front_indices:
            self.population[idx].crowding_distance = 0.0

        for m in range(self.n_obj):
            sorted_front = sorted(
                front_indices,
                key=lambda idx: self.population[idx].objectives[m]
            )
            f_min = self.population[sorted_front[0]].objectives[m]
            f_max = self.population[sorted_front[-1]].objectives[m]
            if f_max == f_min:
                continue

            self.population[sorted_front[0]].crowding_distance = float('inf')
            self.population[sorted_front[-1]].crowding_distance = float('inf')

            for i in range(1, len(sorted_front) - 1):
                prev_obj = self.population[sorted_front[i-1]].objectives[m]
                next_obj = self.population[sorted_front[i+1]].objectives[m]
                self.population[sorted_front[i]].crowding_distance += \
                    (next_obj - prev_obj) / (f_max - f_min)
    def sbx_crossover(self, parent1, parent2):
        """Simulated Binary Crossover."""
        x1, x2 = parent1.variables.copy(), parent2.variables.copy()
        child1, child2 = x1.copy(), x2.copy()

        for i in range(self.n_vars):
            if np.random.random() < 0.5:
                if abs(x2[i] - x1[i]) <= 1e-14:
                    continue

                if x1[i] < x2[i]:
                    lo, hi = x1[i], x2[i]
                else:
                    lo, hi = x2[i], x1[i]

                xL, xU = self.bounds[i]
                u = np.random.random()

                if u <= 0.5:
                    beta = (2 * u) ** (1.0 / (self.eta_c + 1))
                else:
                    beta = (1.0 / (2 * (1 - u))) ** (1.0 / (self.eta_c + 1))

                c1 = 0.5 * ((1 + beta) * lo + (1 - beta) * hi)
                c2 = 0.5 * ((1 - beta) * lo + (1 + beta) * hi)

                c1 = np.clip(c1, xL, xU)
                c2 = np.clip(c2, xL, xU)

                if x1[i] < x2[i]:
                    child1[i], child2[i] = c1, c2
                else:
                    child2[i], child1[i] = c1, c2

        return child1, child2

    def polynomial_mutation(self, child):
        """Mutacion polinomica variable a variable."""
        mutant = child.copy()
        for i in range(self.n_vars):
            if np.random.random() < self.pm:
                xL, xU = self.bounds[i]
                u = np.random.random()

                if u <= 0.5:
                    delta = (2 * u) ** (1.0 / (self.eta_m + 1)) - 1
                else:
                    delta = 1 - (2 * (1 - u)) ** (1.0 / (self.eta_m + 1))

                mutant[i] += delta * (xU - xL)
                mutant[i] = np.clip(mutant[i], xL, xU)

        return mutant

    def crowded_comparison(self, ind1, ind2):
        """Operador de comparacion crowded."""
        if ind1.rank < ind2.rank:
            return -1
        if ind1.rank > ind2.rank:
            return 1
        if ind1.crowding_distance > ind2.crowding_distance:
            return -1
        if ind1.crowding_distance < ind2.crowding_distance:
            return 1
        return 0

    def tournament_selection(self, pool_indices):
        """Seleccion por torneo binario usando crowded-comparison."""
        best_idx = pool_indices[0]
        for idx in pool_indices[1:]:
            if self.crowded_comparison(
                self.population[best_idx], self.population[idx]
            ) > 0:
                best_idx = idx
        return best_idx

    def evolve(self):
        """Bucle principal de NSGA-II."""
        self._initialize()

        for gen in range(self.max_gen):
            # Step 1: Non-dominated sort de poblacion actual
            fronts = self.fast_non_dominated_sort(self.population)
            self.fronts = fronts

            # Step 2: Crowding distance para cada frente
            for front in fronts:
                self.crowding_distance(front)

            # Step 3: Guardar snapshot (solo frente 1)
            f1_individuals = [self.population[i].objectives.copy()
                              for i in fronts[0]]
            self.history.append((gen, f1_individuals))

            # Step 4: Generar hijos Q_t (torreo + SBX + mutacion)
            offspring = []
            pop_indices = list(range(self.pop_size))

            while len(offspring) < self.pop_size:
                i1 = self.tournament_selection(
                    np.random.choice(pop_indices, size=2, replace=False)
                )
                i2 = self.tournament_selection(
                    np.random.choice(pop_indices, size=2, replace=False)
                )

                if np.random.random() < self.pc:
                    if self.crossover_fn:
                        c1_vars, c2_vars = self.crossover_fn(
                            self.population[i1].variables,
                            self.population[i2].variables
                        )
                    else:
                        c1_vars, c2_vars = self.sbx_crossover(
                            self.population[i1], self.population[i2]
                        )
                else:
                    c1_vars = self.population[i1].variables.copy()
                    c2_vars = self.population[i2].variables.copy()

                if self.mutation_fn:
                    c1_vars = self.mutation_fn(c1_vars)
                    c2_vars = self.mutation_fn(c2_vars)
                else:
                    c1_vars = self.polynomial_mutation(c1_vars)
                    c2_vars = self.polynomial_mutation(c2_vars)

                obj1, cons1 = self._evaluate(c1_vars)
                obj2, cons2 = self._evaluate(c2_vars)

                ind1 = Individual(c1_vars, obj1)
                ind1.constraints_violation = np.sum(np.maximum(0, cons1))
                ind2 = Individual(c2_vars, obj2)
                ind2.constraints_violation = np.sum(np.maximum(0, cons2))

                offspring.append(ind1)
                if len(offspring) < self.pop_size:
                    offspring.append(ind2)

            # Step 5: Elitismo: P_t U Q_t -> mejores N sobreviven
            combined = self.population + offspring
            all_fronts = self.fast_non_dominated_sort(combined)

            self.population = []
            for front in all_fronts:
                if len(self.population) + len(front) <= self.pop_size:
                    self.population.extend([combined[i] for i in front])
                else:
                    remaining = self.pop_size - len(self.population)
                    self.crowding_distance(front)
                    front_sorted = sorted(
                        front,
                        key=lambda i: combined[i].crowding_distance,
                        reverse=True
                    )
                    self.population.extend(
                        [combined[i] for i in front_sorted[:remaining]]
                    )
                    break

        # Devolver frente de Pareto final
        final_fronts = self.fast_non_dominated_sort(self.population)
        return [self.population[i] for i in final_fronts[0]]

Ejemplo de uso: problema ZDT1 (2 objetivos, 30 variables)

def zdt1(vars):
    n = len(vars)
    g = 1 + 9 * np.sum(vars[1:]) / (n - 1)
    f1 = vars[0]
    f2 = g * (1 - np.sqrt(f1 / g))
    return np.array([f1, f2])

# bounds: todas las variables en [0, 1]
bounds = [(0.0, 1.0)] * 30
nsga = NSGA2(
    n_vars=30, n_obj=2, bounds=bounds,
    objectives_fn=zdt1, pop_size=200, max_gen=250
)
pareto = nsga.evolve()
print(f"Frente de Pareto: {len(pareto)} soluciones")
print(f"Mejor f1: {min(ind.objectives[0] for ind in pareto):.4f}")
print(f"Mejor f2: {min(ind.objectives[1] for ind in pareto):.4f}")

10. Manejo de restricciones

NSGA-II maneja restricciones mediante una modificacion simple pero efectiva del operador de dominancia. La regla es: una solucion factible siempre domina a una infactible. Entre dos soluciones infactibles, domina la que tenga menor violacion agregada de restricciones. Entre dos factibles, se aplica la dominancia de Pareto normal.

En la implementacion, constraints_violation se calcula como ∑ max(0, gj(x)) + ∑ |hk(x)|. La distincion factible/infactible evita que soluciones con violaciones sean seleccionadas, pero sin descartarlas completamente del pool genetico: pueden sobrevivir si la poblacion aun no ha encontrado la region factible, actuando como puente hacia ella.

Para restricciones de igualdad hk(x) = 0, en la practica se relajan a |hk(x)| ≤ ε con ε = 10-6 o 10-4, ya que la igualdad exacta es numericamente imposible de satisfacer con variables continuas.

11. Metricas de calidad del frente de Pareto

Obtener un frente de Pareto no basta: hay que medir su calidad. Las metricas estandar son:

  • Hipervolumen (HV): volumen del espacio de objetivos dominado por el frente, acotado por un punto de referencia (usualmente el vector Nadir hinchado un 10%). Mide convergencia Y diversidad en una sola cifra. Es la metrica mas usada en benchmarks (Zitzler et al., 2000).
  • IGD (Inverted Generational Distance): distancia promedio desde puntos del verdadero frente de Pareto a la aproximacion obtenida. Requiere conocer el frente real (solo posible en benchmarks sinteticos). Penaliza tanto puntos perdidos como falsos descubrimientos.
  • Spacing (S): mide la uniformidad de la distribucion de soluciones a lo largo del frente. Valores bajos indican distribucion regular. No mide convergencia ni extension.
  • Cobertura C(A, B): fraccion de soluciones en el frente B que son dominadas por alguna solucion en A. Util para comparar dos algoritmos.

12. NSGA-III y el futuro

Cuando M ≥ 4, NSGA-II sufre una degradacion conocida: la mayoria de la poblacion se vuelve no-dominada tras pocas generaciones y el crowding distance pierde capacidad discriminatoria. Es el problema de "many-objective optimization", donde el concepto mismo de dominancia se diluye.

Deb y Jain propusieron NSGA-III en 2014 como respuesta. Sustituye el crowding distance por puntos de referencia distribuidos uniformemente sobre un simplex en el espacio de objetivos, usando el enfoque de Das y Dennis (1998). Cada solucion se asocia al punto de referencia mas cercano, manteniendo diversidad forzada mediante nichos. Para M=3 con 4 divisiones por eje se generan C(3+4-1, 4) = 15 puntos de referencia; para M=10 con 5 divisiones son C(14,5) = 2.002 puntos, cubriendo uniformemente el espacio 10-dimensional.

Otras extensiones modernas incluyen MOEA/D (Zhang y Li, 2007), que descompone el MOP en N subproblemas escalares usando pesos y los resuelve simultaneamente con colaboracion entre vecinos, y SMS-EMOA (Beume et al., 2007), que usa el hipervolumen directamente como criterio de seleccion. En la practica, para M ≤ 3, NSGA-II sigue siendo el estandar de facto.

13. Integracion con OpenFOAM: optimizacion con CFD

Uno de los casos de uso mas potentes de NSGA-II en ingenieria es la optimizacion aerodinamica acoplada a CFD. OpenFOAM, el solver de codigo abierto mas usado en la industria, se integra con NSGA-II mediante un bucle externo donde cada evaluacion de la funcion objetivo dispara una simulacion completa.

Arquitectura tipica para optimizacion de un ala o perfil 2D:

  1. Variables de diseno: n parametros geometricos (ej. 5 puntos de control de una B-spline que define la forma del perfil, mas angulo de ataque).
  2. Generacion de geometria: un script de Python genera el archivo STL o la malla de volumen con blockMesh/snappyHexMesh a partir de las variables.
  3. Simulacion CFD: simpleFoam en regimen turbulento (RANS con modelo k-ω SST). Condiciones de contorno fijas para todas las geometrias.
  4. Post-proceso: extraer Cl y Cd de los archivos de fuerza (forceCoeffs).
  5. Objetivos: minimizar Cd, maximizar Cl (o minimizar -Cl). Restriccion opcional: espesor minimo del perfil para integridad estructural.

El tiempo de ejecucion viene dominado por las simulaciones CFD. Con NSGA-II, poblacion N=100 y 200 generaciones, se requieren 20.000 evaluaciones. A 5 minutos por simulacion, el tiempo total es ~70 dias en un solo nucleo. Por eso en la practica se usan estrategias de aceleracion:

  • Surrogate models (Kriging, GP): entrenar un metamodelo con las primeras ~200 simulaciones y usarlo para pre-filtrar candidatos.
  • Paralelizacion de evaluaciones: NSGA-II evalua toda la poblacion de hijos simultaneamente; con un cluster de 100 nodos, 20.000 simulaciones se completan en ~17 horas.
  • Criterios de parada temprana: detener CFD cuando los residuos bajan de 10-3 en lugar de 10-6 para la fase exploratoria, refinando solo las soluciones prometedoras del frente final.

14. Parametros recomendados y calibracion

ParametroSimboloRango tipicoRecomendacionNota
PoblacionN40–500200Mayor N da mejor cobertura del frente pero más evaluaciones
GeneracionesG100–2000500Monitorizar hipervolumen para convergencia
Prob. cruzamientopc0.6–1.00.9Valores < 0.8 solo en problemas muy multimodales
Prob. mutacionpm1/n_Vars1/nEl valor teorico optimo segun Deb
Indice SBXηc5–5020Mayor = hijos mas cercanos a los padres
Indice mutacionηm5–5020Mayor = mutaciones mas pequenas

Para calibrar ηc y ηm, la regla practica es medir el porcentaje de hijos factibles tras cruzamiento/mutacion. Si menos del ~70% de los hijos son factibles, aumentar ηc y reducir pm para ser menos agresivos con las perturbaciones.

15. Recomendaciones practicas finales

  • Escalado de variables y objetivos: siempre normalizar los objetivos antes de calcular crowding distance (el codigo de arriba lo hace implicitamente con la normalizacion por rango). Variables en [0,1] mejoran la estabilidad numerica del SBX.
  • Visualizacion en 2D/3D: usar pair plots para M>3. El frente de Pareto en 4+ dimensiones es dificil de visualizar; recurrir a proyecciones 2D por pares de objetivos y al parallel coordinates plot.
  • Toma de decision post-optimizacion: el frente de Pareto es el punto de partida, no el final. Metodos como TOPSIS o la distancia minima al punto ideal (utopia point) ayudan a seleccionar una solucion de compromiso. En la practica, presentar 3-5 soluciones representativas al ingeniero: mejor balance, mejor en objetivo 1, mejor en objetivo 2.
  • Semillas: NSGA-II es estocastico. Repetir la optimizacion con 5-10 semillas distintas y reportar el mejor frente por hipervolumen o la envolvente de todos los frentes combinados (non-dominated sorting sobre la union de todas las soluciones encontradas).
  • No reinventar la rueda: pymoo (Blank y Deb, 2020) ofrece implementaciones de NSGA-II, NSGA-III, MOEA/D y una docena mas de algoritmos con benchmarks incluidos. Usar pymoo para produccion y el codigo de este articulo para entender los fundamentos.

Lleva la optimizacion multi-objetivo a tus disenos

En MarteriaTech Labs integramos NSGA-II con simulacion CFD, calculo estructural y diseno parametrico para resolver problemas reales de ingenieria. Desde la optimizacion aerodinamica de un ala hasta la seleccion de materiales con criterios contrapuestos.

Habla con nosotros