Introducción
En un post anterior (Complejidad Algorítmica) he hablado sobre la problemática a la hora de ofrecer una solución computacional para determinados problemas, al necesitar un tiempo de procesamiento muy elevado debido al espacio de estados que se necesitan explorar para buscar la solución. Pues bien, una técnica muy utilizada para problemas de búsqueda de soluciones en este tipo de problemas, son los algoritmos evolutivos [1] que se apoyan en la teoría de la evolución humana, para que partiendo de un conjunto de soluciones aleatorias (primera generación) irlas mejorando generación tras generación hasta alcanzar una solución aceptable en un espacio de tiempo válido.
En este post, mostraré aplicación de los algoritmos genéticos para el cálculo de trayectorias en un plano bidimensional. La idea es que dado un escenario con diferentes obstáculos queremos calcular una trayectoria lo más optimizada posible para desplazarnos de un punto origen a un destino.
Para comenzar debemos saber que para abordar cualquier problema con algoritmos genéticos, necesitaremos tener en cuenta los siguientes puntos:
- Representación: como codificar una posible solución (o individuo) del problema, de manera que resulta sencillo y eficiente aplicar las operaciones de evaluación, selección, cruce y mutación.
- evaluación: valoración de la calidad de cada solución o individuo.
- Selección: una vez generada una población y evaluado cada individuo de la misma, deberemos seleccionar aquellos que nos interesen para generar la siguiente generación.
- Cruce: mezclar los genes de dos progenitores para generar un descendiente que pasará a formar parte de la siguiente generación.
- Mutación: al igual que en la naturaleza, podemos implementar el concepto de mutación, que significa hacer algún cambio aleatorio en los genes de algún individuo.
Teniendo esto en cuenta, el procedimiento genérico de un algoritmo genético sería el siguiente:
- Generar primera generación aleatoria
- Evaluar la calidad de cada individuo de la generación
- Seleccionar aquellos individuos que utilizaremos para generar la siguiente generación
- Aplicar el operador de cruce para generar la siguiente generación a partir de los individuos obtenidos en el paso 3.
- Aplicar el operador de mutación en algún individuo al azar. (Este paso tan solo se ejecutará con un porcentaje de probabilidad pequeño, no en todas las generaciones).
- Volver al paso 2
A continuación comentaremos la implementación de los diferentes pasos del algoritmo genético para el problema propuesto.
Representación
El primer paso que tenemos que abordar a la hora de aplicar los algoritmos genéticos a cualquier problema, se trata de la codificación de una posible solución al problema en un cromosoma o individuo. En este caso, una posible solución al problema planteado, es cualquier ruta (válida o no) entre la posición origen y el destino.
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | |
| 1 | * | * | * | ||||||
| 2 | * | * | |||||||
| 3 | * | * | |||||||
| 4 | * | ||||||||
| 5 | * | ||||||||
| 6 | * | * | * | * | |||||
| 7 |
Tabla 1: posible solución para ir desde una posición origen (casilla verde) a otra destino (casilla roja)
En la tabla 1, se muestra gráficamente una posible trayectoria, así pues, el objetivo es codificar en un cromosoma la misma trayectoria de manera que nos facilite las operaciones, de cruce, evaluación y mutación. Una características a tener en cuenta, es el tipo de movimientos permitidos, ya que en este caso para facilitar el problema, tan solo están permitidos movimientos en ángulos rectos, es decir, que podemos movernos hacía arriba, abajo, izquierda y derecha, pero nunca en diagonal.
Según, esto, una posible codificación podría ser un vector con las coordenadas de las casillas por las que pasa dicha trayectoria, de manera que la solución de la figura 1, quedaría de la siguiente manera:
[(1,1), (1,2), (1,3)(2,3), (2,4),(3,4),(3,5),(4,5),(5,5),(6,5),(5,5),(6,7),(6,8)]
Esta podría ser una codificación válida, pero debido a que la optimización en la codificación del cromosoma es un factor crítico vamos a intentar optimizarlo un poco más. Hay que tener en cuenta que el escenario real sobre el que se aplique el algoritmo puede ser mucho más grande que el de la figura 1. Una posible optimización puede ser, codificar tan solo el número de columna para cada fila entre la posición origen y destino.
[1,3,4,5,5,5]
Como se puede ver, el tamaño del cromosoma se reduce notablemente, pero además, otro factor importante es que con esta codificación, el cromosoma tendrá una longitud fija que será igual al número de filas existentes entre la posición origen y destino, característica que nos puede simplificar las operaciones de cruce, evaluación y mutación.
También vamos añadir un dato más al cromosoma, que representará la dirección utilizada para avanzar hacía la siguiente posición (vertical u horizontal). El valor horizontal, significa que se avanzará horizontalmente hasta situarse justo encima de la columna en la que deberemos estar en la siguiente fila, y vertical lo contrario, avanzamos verticalmente hasta la siguiente fila y a continuación horizontalmente hasta situarnos en la columna indicada.
[1,H,3,H,4,H,5,H,8]
Evaluación
Para la evaluación de cada cromosoma, hemos utilizado una función de fitness que tiene en cuenta tanto el número total de movimientos necesarios para ejecutar la trayectoria, como las colisiones que dicha trayectoria puede provocar contra los obstáculos existentes en el escenario.
F = a movimientos + ß colisiones
De esta manera dispondremos de dos factores (a y ß) que podremos ajustar para establecer al peso tanto del tamaño de la trayectoria o de las colisiones en el valor del fitness. Por supuesto, lo que queremos es que una simple colisión afecte muy negativamente al valor de fitness, mucho más que una ruta más larga, ya que una ruta larga sin colisiones seguirá siendo una ruta correcta, mientras que una simple colisión no será una ruta correcta, y lo que queremos es que ese individuo tenga pocas probabilidades de reproducirse para una siguiente población.
Selección
Para este problema, hemos probado dos métodos de selección, el de la ruleta y el de selección por torneo [1] [2], obteniendo mejores resultados con el segundo.
Selección de la ruleta
La selección de la ruleta, consiste en un tipo de selección probabilístico, que a la hora de seleccionar los individuos de la población que se utilizarán para generar la siguiente generación, da mayores probabilidades de ser seleccionados a aquellos cromosomas que han tenido un mejor valor de fitness. El problema de este tipo de selección, es que como hemos dicho, asigna mayores probabilidades de selección a los mejores individuos, pero nada impide que se puedan seleccionar los peores, incluso varias veces.
Básicamente, el funcionamiento de este método, consiste en dividir un determinado intervalo (por ejemplo [0, 1]), en tantas partes como el número de individuos que tiene la población, asociando cada una de estas partes a un individuo, y estableciendo el tamaño de la parte asignada a un individuo proporcional a su valor de fitness. A continuación se comienzan a obtener números aleatorios (se gira la ruleta), y se selecciona aquel individuo perteneciente al intervalo donde ha caído el valor obtenido. Por supuesto aquellos individuos con un fitness mejor, como se le ha asignado un intervalo mayor, tienen más posibilidades de ser seleccionados.
Los pasos de implementación para dicho método serían los siguientes:
- Normalizar el fitness de cada individuo, sumando el valor de fitness de todos los individuos y diviendo el valor del individuo actual por el sumatorio recién calculado.
- Calcular para cada individuo el valor de fitness acumulativo, que consiste en la suma de todos los fitness de individuos situados en posiciones anteriores.
- Seleccionar los individuos que servirán para generar la siguiente población, generando números aleatores, y seleccionando el individuo cuyo valor de fitness acumulativo sea el que más se aproxime al valor aleatorio sin sobrepasarlo.
Para entenderlo un poco mejor, en las siguientes dos tablas se muestra un ejemplo con datos reales. En la primera tabla se lista el valor de fitness para 10 individuos junto con sus valores de fitness normalizados y acumulativos, y en la segunda tabla, una serie de posibles valores obtenidos aleatoriamente, y el individuo que se seleccionaría de la tabla anterior.
| Individuo | Fitness | Fitness Normalizado | Fitness Acumulativo | Tamaño intervalo |
| 1 | 165.5 | 0.07306843267108168 | 0.07306843267108168 | 0.070 |
| 2 | 226.0 | 0.09977924944812362 | 0.17284768211920531 | 0.099 |
| 3 | 174.0 | 0.07682119205298013 | 0.24966887417218545 | 0.077 |
| 4 | 297.0 | 0.13112582781456952 | 0.38079470198675497 | 0.131 |
| 5 | 289.5 | 0.12781456953642384 | 0.5086092715231788 | 0.128 |
| 6 | 250.5 | 0.11059602649006622 | 0.619205298013245 | 0.111 |
| 7 | 186.5 | 0.08233995584988962 | 0.7015452538631346 | 0.082 |
| 8 | 168.0 | 0.07417218543046358 | 0.7757174392935982 | 0.074 |
| 9 | 317.0 | 0.13995584988962473 | 0.9156732891832229 | 0.140 |
| 10 | 191.0 | 0.08432671081677705 | 0.9999999999999999 | 0.084 |
Tabla 2: valores de fitness para cada individuo
| Valor aleatorio | Individuo seleccionado |
| 0.41598817048488446 | 4 |
| 0.5833559626242413 | 5 |
| 0.7970389715363804 | 8 |
| 0.20626833490455576 | 2 |
| 0.9738636658234414 | 9 |
| 0.809138404321271 | 8 |
Tabla 3: resultado del algoritmo de la ruleta
A continuación, se muestra una implementación de este algoritmo en java.
- private int [] rouletteSelection(){
- double sumFitness = 0;
- double partialSum = 0;
- double[] normFitness;
- double[] cumulativeFitness;
- int[] selectedIndividuals;
- Random random;
- double rValue;
- //calculate sumFitness
- for(int i=this.fitnessValues.length;–i>=0;){
- sumFitness += this.fitnessValues[i];
- }
- //normalize all fitness
- normFitness = new double[this.fitnessValues.length];
- for(int i=this.fitnessValues.length;–i>=0;){
- normFitness[i] = fitnessValues[i] / sumFitness;
- }
- //cumulative fitness
- cumulativeFitness = new double[fitnessValues.length];
- for(int i=-1;++i<normFitness.length;){
- partialSum += normFitness[i];
- cumulativeFitness[i]= partialSum;
- }
- //select individuals
- random = new Random();
- selectedIndividuals = new int[this.population.size()-1];
- for(int i=this.population.size()-1;–i>=0;){
- rValue = random.nextDouble();
- for(int j=-1;++j<cumulativeFitness.length;){
- if(cumulativeFitness[j] >= rValue){
- selectedIndividuals[i] = j;
- }
- break;
- }
- }
- }
- return selectedIndividuals;
- }
Selección por torneo
Otra alternativa al método de la ruleta, es la selección por torneo, obteniendo resultados mucho mejores.
Este mecanismo de selección comienza seleccionando de manera aleatorio un grupo de individuos de un tamaño determinado (tamaño de rango), que normalmente suele ser igual a 2, y seleccionar para reproducirse al mejor individuo de dicho rango. Por supuesto como se puede apreciar el coste computacional de esta técnica es muchísimo menor que el de la ruleta.
Una posible implementación sería la mostrada a continuación, donde vamos seleccionando dos individuos aleatoriamente y añadiendo al mejor de estos dos al conjunto de individuos que servirán para formar la siguiente generación.
- private int[] tournamentSelection(){
- int[] selectedIndividuals = new int[this.population.size()-1];
- Random random;
- int rValue1,rValue2;
- //select individuals
- random = new Random();
- for(int i=this.population.size()-1;–i>=0;){
- rValue1 = random.nextInt(this.population.size());
- rValue2 = random.nextInt(this.population.size());
- selectedIndividuals[i] =
- (fitnessValues[rValue1] <= fitnessValues[rValue2])
- ? rValue1:rValue2;
- }
- return selectedIndividuals;
- }
Cruce
Una vez seleccionados los individuos padres para la siguiente generación, lo único que nos queda es aplicar algún operador de cruce que nos permita crear uno o dos hijos a partir de dos padres (dos individuos de la lista que hemos seleccionado en el apartado anterior). El operador de cruce más común es el que realiza una simple mezcla de los genes de ambos padres partiendo de uno o varios puntos de cruce. En la figura siguiente se muestra un ejemplo gráfico.
Progenitor 1
| 1 | 0 | 3 | 1 | 4 | 1 | 5 | 1 | 6 | 1 | 7 | 1 | 7 | 1 | 7 | 1 | 8 | 0 | 9 | 1 | 11 |
Progenitor 2
| 1 | 0 | 5 | 1 | 8 | 0 | 2 | 0 | 8 | 0 | 3 | 1 | 9 | 1 | 2 | 1 | 9 | 1 | 6 | 0 | 8 |
Descendiente 1
| 1 | 0 | 3 | 1 | 4 | 1 | 5 | 1 | 6 | 0 | 3 | 1 | 9 | 1 | 2 | 1 | 9 | 1 | 6 | 0 | 8 |
Descendiente 2
| 1 | 0 | 5 | 1 | 8 | 0 | 2 | 0 | 8 | 3 | 1 | 9 | 1 | 2 | 1 | 9 | 1 | 6 | 0 | 8 | 3 |
Mutación
Como operador de mutación podemos utilizar la mutación por desplazamiento que consiste simplemente en seleccionar dos genes al azar e intercambiarlos. Para este problema, debemos seleccionar dos pares de posiciones al azar, el primer par correspondientes a posiciones pares y el segundo correspondientes posiciones impares, ya que como hemos dicho cuando hablamos de cómo representar los individuos, en las posiciones impares del cromosoma representamos la información del número de columna y en las posiciones pares la dirección del movimiento, de manera que no podemos mezclar los contenidos de posiciones pares e impares.
Individuo original
| 1 | 0 | 3 | 1 | 4 | 1 | 5 | 1 | 6 | 1 | 7 | 1 | 7 | 1 | 7 | 1 | 8 | 0 | 9 | 1 | 11 |
Individuo mutado
| 1 | 0 | 7 | 1 | 4 | 1 | 5 | 1 | 6 | 0 | 3 | 1 | 7 | 1 | 7 | 1 | 8 | 1 | 9 | 1 | 11 |
Resultados
Aquí muestro un par de resultados, de trayectorias calculadas desde la posición superiorior a un destino determinado.




