Algoritmos Genéticos con Python Un enfoque práctico para resolver problemas de ingeniería Primera edición Algoritmos Genéticos con Python Un enfoque práctico para resolver problemas de ingeniería Primera edición Algoritmos Genéticos con Python Un enfoque práctico para resolver problemas de ingeniería © 2020 Daniel Gutiérrez Reina, Alejandro Tapia Córdoba y Alvaro Rodríguez del Nozal Primera edición, 2020 © 2020 MARCOMBO, S.L. www.marcombo.com Diseño de la cubierta: Alejandro Tapia Córdoba Diseño de interior: Daniel Gutiérrez Reina, Alejandro Tapia Córdoba y Alvaro Rodríguez del Nozal Correctora: Anna Alberola Directora de producción: M.a Rosa Castillo «Cualquier forma de reproducción, distribución, comunicación pública o transformación de esta obra solo puede ser realizada con la autorización de sus titulares, salvo excepción prevista por la ley. Diríjase a CEDRO (Centro Español de Derechos Reprográficos, www.cedro.org) si necesita fotocopiar o escanear algún fragmento de esta obra». ISBN: 978-84-267-3068-8 Producción del ebook: booqlab A Mónica, Laura, Sol y Lucía. Prefacio I Parte 1: Introducción a los algoritmos genéticos 1Introducción 1.1 Introducción a los algoritmos genéticos 1.2 Primeros pasos mediante un problema sencillo 1.3 Definición del problema y generación de la población inicial 1.3.1 Creación del problema 1.3.2 Creación de la plantilla del individuo 1.3.3 Crear individuos aleatorios y población inicial 1.4 Función objetivo y operadores genéticos 1.4.1 Función objetivo 1.5 Operadores genéticos 1.6 Últimos pasos: Algoritmo genético como caja negra 1.6.1 Configuración algoritmo genético 1.6.2 Resultados del algoritmo genético 1.7 ¿Cómo conseguir resultados consistentes? 1.8 Convergencia del algoritmo 1.9 Exploración versus explotación en algoritmos genéticos 1.10 Código completo y lecciones aprendidas 1.11 Para seguir aprendiendo 2El problema del viajero 2.1 Introducción al problema del viajero 2.2 Definición del problema y generación de la población inicial 2.2.1 Creación del problema y plantilla para el individuo 2.2.2 Crear individuos aleatorios y población inicial 2.3 Función objetivo y operadores genéticos 2.3.1 Función objetivo 2.3.2 Operadores genéticos 2.4 Selección del algoritmo genético 2.5 Últimos pasos 2.5.1 Configuración del algoritmo genético µ + λ 2.6 Comprobar la convergencia del algoritmo en problemas complejos 2.7 Ajuste de los hiperparámetros: Probabilidades de cruce y mutación 2.8 Acelerando la convergencia del algoritmo: El tamaño del torneo 2.9 Acelerando la convergencia del algoritmo: Aplicar elitismo 2.10 Complejidad del problema: P vs NP 2.11 Código completo y lecciones aprendidas 2.12 Para seguir aprendiendo 3Algoritmos genéticos y benchmarking 3.1 Introducción a las funciones de benchmark 3.2 Aprendiendo a usar las funciones de benchmark : Formulación del problema 78 3.2.1 Función h1 3.2.2 Función Ackley 3.2.3 Función Schwefel 3.3 Definición del problema y generación de la población inicial 3.4 Función objetivo y operadores genéticos 3.4.1 Función objetivo 3.4.2 Operadores genéticos 3.5 Código completo 3.6 Evaluación de algunas funciones de benchmark 3.6.1 Función h1 3.6.2 Función Ackley 3.6.3 Función Schwefel 3.7 Ajuste de los hiperparámetros de los operadores genéticos 3.8 Lecciones aprendidas 3.9 Para seguir aprendiendo 4Algoritmos genéticos con múltiples objetivos 4.1 Introducción a los problemas con múltiples objetivos 4.2 Introducción a la Pareto dominancia 4.3 Selección del algoritmo genético 4.4 El problema de la suma de subconjuntos con múltiples objetivos 4.4.1 Formulación del problema 4.4.2 Definición del problema y generación de la población inicial 4.4.3 Definición del problema y plantilla del individuo 4.4.4 Función objetivo y operadores genéticos 4.4.5 Últimos pasos: Ejecución del algoritmo multiobjetivo 4.4.6 Configuración del algoritmo genético multiobjetivo 4.4.7 Algunos apuntes sobre los algoritmos genéticos con múltiples objetivos 4.4.8 Código completo 4.5 Funciones de benchmark con múltiples objetivos 4.5.1 Definición del problema y población inicial 4.5.2 Función objetivo y operadores genéticos 4.5.3 Ejecución del algoritmo multiobjetivo 4.5.4 Representación del frente de Pareto 4.5.5 Ajuste de los hiperpámetros de los operadores genéticos 4.5.6 Código completo 4.6 Lecciones aprendidas 4.7 Para seguir aprendiendo II Parte 2: Algoritmos genéticos para ingeniería 5Funcionamiento óptimo de una microrred 5.1 Introducción 5.2 Formulación del problema 5.2.1 Recursos renovables 5.2.2 Unidades despachables 5.2.3 Sistema de almacenamiento de energía 5.2.4 Balance de potencia 5.3 Problema con un objetivo: Minimizar el coste de operación 5.3.1 Definición del problema y generación de la población inicial 5.3.2 Operadores genéticos 5.3.3 Función objetivo 5.3.4 Ejecución del algoritmo 5.3.5 Resultados obtenidos 5.4 Problema con múltiples objetivos: Minimizando el coste de operación y el ciclado de la batería 5.4.1 Definición del problema, población inicial y operadores genéticos 5.4.2 Función objetivo 5.4.3 Ejecución del algoritmo 5.4.4 Resultados obtenidos 5.5 Código completo y lecciones aprendidas 5.6 Para seguir aprendiendo 6Diseño de planta microhidráulica 6.1 Introducción 6.2 Formulación del problema 6.2.1 Modelado de la central micro-hidráulica 6.3 Problema con un objetivo: Minimizando el coste de instalación 6.3.1 Definición del problema y generación de la población inicial 6.3.2 Operadores genéticos 6.3.3 Función objetivo o de fitness 6.3.4 Ejecución del algoritmo 6.3.5 Resultados obtenidos 6.4 Problema con múltiples objetivos: Minimizando el coste de instalación y maximizando la potencia generada 6.4.1 Definición del problema, población inicial y operadores genéticos 6.4.2 Función objetivo o de fitness 6.4.3 Ejecución del algoritmo 6.4.4 Resultados obtenidos 6.5 Código completo y lecciones aprendidas 6.6 Para seguir aprendiendo 7Posicionamiento de sensores 7.1 Introducción 7.2 Formulación del problema 7.3 Problema con un objetivo: Maximizando el número de puntos cubiertos 189 7.3.1 Definición del problema y generación de la población inicial 7.3.2 Operadores genéticos 7.3.3 Función objetivo 7.3.4 Ejecución del algoritmo 7.3.5 Resultados obtenidos 7.4 Problema con múltiples objetivos: maximizando el número de puntos cubiertos y la redundancia 7.4.1 Definición del problema, población inicial y operadores genéticos 7.4.2 Función objetivo 7.4.3 Ejecución del algoritmo 7.4.4 Resultados obtenidos 7.5 Código completo y lecciones aprendidas 7.6 Para seguir aprendiendo Epílogo AHerencia de arrays de numpy A.1 Introducción a las secuencias en Python A.2 Slicing en secuencias y operadores genéticos de deap A.3 Operador de comparación en secuencias BProcesamiento paralelo B.1 Procesamiento paralelo con el módulo multiprocessing B.2 Procesamiento paralelo con el módulo Scoop Glosario Bibliografía ¿Por qué algoritmos genéticos en el ámbito de la ingeniería? Los ingenieros nos enfrentamos día a día a numerosos problemas. La complejidad de estos problemas crece de una forma exponencial debido a las herramientas informáticas que nos permiten desarrollar modelos más complejos. Hemos pasado, pues, de una ingeniería con papel, lápiz y calculadora, a otra con herramientas digitales, gran cantidad de datos (big data) y súper ordenadores. Todo ello ha sido posible gracias a los avances conseguidos en disciplinas como la tecnología electrónica, el desarrollo software e Internet. En la actualidad, los modelos con los que nos enfrentamos son más realistas, pero también son más difíciles de analizar mediante técnicas analíticas o exactas. En los últimos años, los algoritmos de inteligencia artificial se han convertido en una herramienta indispensable para resolver problemas de ingeniería de una manera aproximada. Estos algoritmos están soportados por la gran capacidad de cálculo que tienen nuestros ordenadores. En la actualidad, cualquier ordenador personal o portátil es capaz de realizar millones de operaciones por segundo. Por otro lado, lenguajes de programación de alto nivel, como Python, han generado una gran cantidad de usuarios, agrupados en comunidades, que trabajan conjuntamente para desarrollar un amplio abanico de recursos abiertos. Estos recursos se engloban en librerías, repositorios abiertos, congresos nacionales e internacionales, comunidades locales, seminarios, etc. El resultado es una democratización de la inteligencia artificial abierta y al alcance de todos. Es decir, las técnicas de inteligencia artificial están a disposición de cualquiera que tenga unos conocimientos medios en matemáticas y programación. Así, los ingenieros -como grandes artífices del desarrollo tecnológico- debemos estar en la cresta de la ola de la inteligencia artificial, para seguir aportando soluciones a los problemas que nos surgen día a día. En esta gran caja de herramientas compuesta por todos los algoritmos de inteligencia artificial -tales como algoritmos de machine learning, redes neuronales, etc.- los algoritmos genéticos son una herramienta indispensable, ya que nos permiten obtener soluciones adecuadas a problemas muy complejos que no se pueden abordar con métodos clásicos. Por lo tanto, cualquiera que se considere experto en la materia debe dominar la técnica. Siempre debemos tener presente que lo importante de cada método es saber en qué escenarios se debe utilizar, qué ventajas tiene y cuáles son las limitaciones de cada herramienta. En este libro se muestran, mediante una serie de ejemplos prácticos, las bondades y las limitaciones de los algoritmos genéticos para resolver problemas de ingeniería. Como introducción al contenido del libro, debemos anticipar que los algoritmos genéticos se basan en la naturaleza. Esto significa que muchos de los problemas de ingeniería se pueden resolver, simplemente, observando cómo funcionan los seres vivos. El mecanismo utilizado no es otro que la Teoría de la Evolución de Charles Darwin (Darwin, 1859), la cual nos dice que los individuos que mejor se adaptan al medio tienen más probabilidades de sobrevivir y en consecuencia, de dejar descendencia. Esta idea, aparentemente tan alejada del mundo de la ingeniería, ha dado lugar a una metodología de optimización de problemas: los algoritmos evolutivos o computación evolutiva, donde se encuentran enmarcados los algoritmos genéticos. A lo largo del libro se abordarán distintos problemas de optimización que se resolverán mediante algoritmos genéticos. El objetivo principal del libro es dejar patente la gran capacidad que tienen los algoritmos genéticos como técnica de resolución de problemas de ingeniería. Así, esperamos que este libro sirva para que muchos ingenieros se introduzcan dentro del mundo de la optimización metaheurística y la apliquen en sus problemas en el futuro. Daniel Gutiérrez Reina, Alejandro Tapia Córdoba, Álvaro Rodríguez del Nozal Sevilla, mayo de 2020 Objetivos y estructura del libro Este libro pretende ofrecer una visión general sobre el desarrollo y la programación de algoritmos genéticos desde un punto de vista práctico y con un enfoque orientado a la resolución de problemas de ingeniería. El libro se ha orientado a un aprendizaje mediante ejemplos (learning by doing). Esto significa que los conceptos se van describiendo conforme aparecen en el problema que se aborda. Por lo tanto, no existe un capítulo donde se encuentren todos los operadores genéticos, o las implementaciones de algoritmos genéticos, etc. No obstante, el glosario del libro permite identificar fácilmente la página donde se encuentra cada concepto. El libro se estructura en dos partes bien diferenciadas. En la primera parte, se cubren los conceptos básicos de los algoritmos genéticos mediante varios ejemplos clásicos. En el primero de los cuatro capítulos que constituyen esta primera parte, se resuelve un problema muy sencillo formulado con variables continuas, con el propósito de ilustrar los principales componentes de los algoritmos genéticos. Aunque el primer capítulo es largo, es necesario leerlo con detenimiento para poder comprender los principales mecanismos que hay detrás de un algoritmo genético. Por lo tanto, se recomienda no avanzar si no se tienen claros los conceptos descritos en este capítulo. En el segundo capítulo, se aborda el problema del viajero o Traveling Salesman Problem (TSP), sin duda uno de los problemas combinatorios clásicos con variables discretas más estudiados, y que constituye un ejemplo perfecto para demostrar la potencialidad de los algoritmos genéticos para resolver problemas complejos. Una vez conocida la estructura fundamental de los algoritmos genéticos, en el tercer capítulo, se profundiza en el uso de las funciones de benchmark para validar tanto sus capacidades como sus potenciales vulnerabilidades, lo cual la constituye una herramienta fundamental para su depuración. Las funciones de benchmark son funciones que la comunidad científica utiliza para la evaluación de algoritmos de optimización. Estas presentan diversas dificultades a los algoritmos de optimización. Por ejemplo, tener varios máximos o mínimos (funciones multimodales), o tener un máximo/mínimo local cerca del absoluto. Por último, en el cuarto capítulo se introduce el enfoque multiobjetivo de los algoritmos genéticos, lo cual constituye una de las capacidades más interesantes y versátiles de este tipo de algoritmos. Desde el punto de vista de los problemas de ingeniería, el enfoque multiobjetivo es muy importante, ya que los ingenieros siempre debemos tener en cuenta una relación de compromiso entre el coste y la adecuación de las soluciones al problema. Los problemas multiobjetivo se abordarán mediante dos ejemplos. En primer lugar, se resolverá un problema clásico como es la suma de subconjuntos. Y en segundo lugar, se usarán funciones de benchmark con múltiples objetivos. Al finalizar la primera parte, el lector habrá adquirido suficiente destreza como para poder abordar problemas de optimización mediante algoritmos genéticos. En la segunda parte, se introducirán una serie de problemas ingenieriles, cuya resolución se abordará mediante el desarrollo de algoritmos genéticos. Todos los problemas se tratan tanto desde el punto de vista de un único objetivo (problemas unimodales) como desde el punto de vista de un multiobjetivo (problemas multimodales). En el primer capítulo, se estudia el problema del despacho económico de una microrred eléctrica. Este problema, formulado en variables continuas, constituye uno de los problemas más complejos y de más relevancia en el área de sistemas eléctricos de potencia, y persigue la programación de la potencia suministrada por un conjunto de generadores, para abastecer una demanda durante un periodo determinado y de forma óptima. En el segundo capítulo, se aborda un problema de optimización relativo al diseño de una planta micro-hidráulica. Este problema, formulado con variables binarias, persigue determinar el trazado óptimo de la planta, y constituye un problema de especial interés dado el alto número de combinaciones posibles, lo que hace inabordable su resolución mediante estrategias analíticas o exactas. Por último, en el tercer capítulo se aborda el problema del posicionamiento óptimo de sensores, en el cual se persigue determinar las posiciones más adecuadas para instalar una serie de sensores de manera que la mayor parte posible de puntos de interés queden cubiertos. En todos los capítulos se incluye una sección de código completo, lecciones aprendidas y ejercicios propuestos. Las lecciones aprendidas hacen referencias a los aspecto más relevantes que se deben adquirir en dicho capítulo. Los ejercicios sirven para afianzar conceptos y coger destreza en la aplicación de algoritmos genéticos. Por último, para finalizar cada capítulo se incluye una sección con bibliografía adicional para seguir profundizando en los temas abordados en el capítulo. Prerrequisitos para seguir el libro Para seguir correctamente el libro, se presupone unos conocimientos medios del lenguaje de programación Python. Este libro no cubre los conceptos básicos de este lenguaje, y da por hecho que el lector parte con conocimientos básicos de programación orientada a objetos. En cuanto a los algoritmos genéticos, el libro cubre desde cero, y paso a paso, los conceptos básicos de dichos algoritmos -tanto de aquellos con un único objetivo, como de aquellos con múltiples objetivos-. El contenido matemático del libro es mínimo, y únicamente es de relevancia en la segunda parte, donde algunas ecuaciones son necesarias para plantear los problemas de ingeniería propuestos. Se recomienda una lectura en profundidad de los primeros capítulos antes de pasar a la segunda parte del libro. En la segunda parte se pasa más rápidamente por los componentes de los algoritmos genéticos que se han detallado en la primera parte. Código Descripción del código La presentación del código en todos los capítulos se hace mediante el siguiente procedimiento: ■En primer lugar, se describen por separado cada una de las partes del código utilizadas para resolver los problemas planteados en cada capítulo. De esta forma, se describen paso a paso los principales componentes del algoritmo. ■En segundo lugar, todos los capítulos tienen una sección que incluye el código completo necesario para resolver el problema. Así, el lector puede ver de manera conjunta todas las líneas de código, junto a una breve descripción del mismo. Para desarrollar el código se ha utilizado el paquete Anaconda con Python 3. Este paquete incluye tanto el intérprete de Python como librerías básicas de este lenguaje de programación, como pueden ser numpy o matplotlib. Además, nos provee de entornos de desarrollo para generar nuestro código, como pueden ser Spyder o Jupyter. Repositorio Todos los scripts utilizados en cada una de las secciones, así como diverso material complementario del libro, se pueden encontrar en el siguiente repositorio de Github: https://github.com/Dany503/Algoritmos-Geneticos-enPython-Un-Enfoque-Practico. Descripción del código Todos los fragmentos de código desarrollados en este libro se clasifican en cuatro categorías: archivos de texto, resultados, scripts y códigos completos. Para facilitar su identificación, cada una de estas categorías se corresponde con un color: Algoritmos y operadores de referencia Aunque las herramientas fundamentales de los algoritmos genéticos se introducirán en el primer capítulo, a lo largo del libro se irán presentando diferentes propuestas para su implementación, en base a las necesidades del problema en estudio. Junto con cada nueva propuesta se presentará una descripción detallada en un entorno como el siguiente: Así, utilizaremos este entorno para describir operadores genéticos (mutación, cruce y selección) y algoritmos genéticos. Para localizar dónde se describe una función en particular, podemos consultar el glosario. Librerías necesarias Todos los scripts de Python se han desarrollado en Anaconda1. La versión de Python utilizada es 3.6 para Windows. No obstante, no debe haber problemas con otras versiones de Python y otros sistemas operativos. A continuación, se listan las librerías utilizadas: ■deap : versión 1.3. ■matplotlib : versión 3.1.3 ■numpy : versión 1.16.3 ■scipy : versión 1.2.1 ■scoop : versión 0.7 ■Módulos nativos de Python como random , arrays , multiprocessing , JSON , math , etc. Para instalar la librería deap con pip2: Si realiza la instalación desde Spyder o Google colab: Para instalar con conda3: Agradecimientos Los autores quieren transmitir sus agradecimientos a la Universidad de Sevilla y a la Universidad Loyola Andalucía, instituciones donde actualmente trabajan. Los autores agradecen a todos los desarrolladores de la librería deap (Fortin et al., 2012) por la documentación disponible y el esfuerzo desarrollado en los últimos años. Por último, agradecer a compañeros de trabajo, familiares y amigos, por su apoyo. Sobre los autores y datos de contacto Daniel Gutiérrez Reina es Doctor Ingeniero en Electrónica por la Universidad de Sevilla (2015). Trabaja actualmente como investigador postdoctoral en el Departamento de Ingeniería Electrónica de la Universidad de Sevilla. Ha sido investigador visitante en la John Moores University (Reino Unido), en Freie Universität Berlin (Alemania), en Colorado School of Mines (Estados Unidos) y en Leeds Beckett University (Reino Unido). También trabajó en la Universidad Loyola Andalucía en el Departamento de Ingeniería durante año y medio. Su investigación se centra en la optimización de problemas de ingeniería utilizando técnicas de optimización metaheurísticas y machine learning. Es docente de un gran número de cursos de Python, optimización y machine learning en la Universidad de Sevilla, en la Universidad de Málaga y en la Universidad de Córdoba. Para contactar con el autor: dgutierrezreina@us.es. Alejandro Tapia Córdoba es Ingeniero Industrial especializado en Materiales (2014) por la Universidad de Sevilla. En 2019 recibió su título de Doctor en Ciencia de los Datos por la Universidad Loyola Andalucía, donde actualmente trabaja como profesor asistente en el Departamento de Ingeniería. Ha sido investigador visitante en la Universidad de Greenwich (UK). Su investigación se enmarca en el desarrollo de estrategias de optimización para aplicaciones en diferentes áreas de la ingeniería. Para contactar con el autor: atapia@uloyola.es. Álvaro Rodríguez del Nozal es Ingeniero Industrial especializado en Automática Industrial (2013) y Máster en Sistemas de Energía Eléctrica (2016) por la Universidad de Sevilla. Recibió su título de Doctor en Ingeniería de Control por la Universidad Loyola Andalucía en el año 2019. Actualmente trabaja como investigador postdoctoral en el Departamento de Ingeniería Eléctrica de la Universidad de Sevilla. Ha sido investigador visitante en el Laboratoire d’analyse et d’architecture des systèmes (Francia) y en el Politecnico di Milano (Italia). Su investigación se centra en el control y estimación distribuida de sistemas dinámicos, así como en la integración de energías renovables en la red eléctrica. Para contactar con el autor: arnozal@us.es. _________________ 1Se puede descargar de forma gratuita en https://www.anaconda.com/distribution/ 2https://pypi.org/project/deap/ 3https://anaconda.org/conda-forge/deap IParte 1: Introducción a los algoritmos genéticos 1Introducción 1.1 Introducción a los algoritmos genéticos 1.2 Primeros pasos mediante un problema sencillo 1.3 Definición del problema y generación de la población inicial 1.4 Función objetivo y operadores genéticos 1.5 Operadores genéticos 1.6 Últimos pasos: Algoritmo genético como caja negra 1.7 ¿Cómo conseguir resultados consistentes? 1.8 Convergencia del algoritmo 1.9 Exploración versus explotación en algoritmos genéticos 1.10 Código completo y lecciones aprendidas 1.11 Para seguir aprendiendo 2El problema del viajero 2.1 Introducción al problema del viajero 2.2 Definición del problema y generación de la población inicial 2.3 Función objetivo y operadores genéticos 2.4 Selección del algoritmo genético 2.5 Últimos pasos 2.6 Comprobar la convergencia del algoritmo en problemas complejos 2.7 Ajuste de los hiperparámetros: Probabilidades de cruce y mutación 2.8 Acelerando la convergencia del algoritmo: El tamaño del torneo 2.9 Acelerando la convergencia del algoritmo: Aplicar elitismo 2.10 Complejidad del problema: P vs NP 2.11 Código completo y lecciones aprendidas 2.12 Para seguir aprendiendo 3Algoritmos genéticos y benchmarking 3.1 Introducción a las funciones de benchmark 3.2 Aprendiendo a usar las funciones de benchmark : Formulación del problema 3.3 Definición del problema y generación de la población inicial 3.4 Función objetivo y operadores genéticos 3.5 Código completo 3.6 Evaluación de algunas funciones de benchmark 3.7 Ajuste de los hiperparámetros de los operadores genéticos 3.8 Lecciones aprendidas 3.9 Para seguir aprendiendo 4Algoritmos genéticos con múltiples objetivos 4.1 Introducción a los problemas con múltiples objetivos 4.2 Introducción a la Pareto dominancia 4.3 Selección del algoritmo genético 4.4 El problema de la suma de subconjuntos con múltiples objetivos 4.5 Funciones de benchmark con múltiples objetivos 4.6 Lecciones aprendidas 4.7 Para seguir aprendiendo 1.1 Introducción a los algoritmos genéticos Los algoritmos genéticos son técnicas de optimización metaheurísticas, también llamadas estocásticas o probabilísticas (Holland et al., 1992) (Goldberg, 2006). Aunque fueron propuestos en la década de los 60s por Jonh Holland (Holland, 1962) (Holland, 1965) (Holland et al., 1992), no ha sido posible su aplicación en problemas de ingeniería reales hasta hace un par de décadas, debido principalmente a que son computacionalmente intensivos y que, por lo tanto, necesitan una capacidad computacional elevada para llevar a cabo multitud de operaciones en poco tiempo. La idea principal de un algoritmo genético es realizar una búsqueda guiada a partir de un conjunto inicial de posibles soluciones, denominado población inicial, el cual va evolucionando a mejor en cada iteración del algoritmo (Lones, 2011). Dichas iteraciones se conocen como generaciones y, normalmente, la última generación incluye la mejor o las mejores soluciones a nuestro problema de optimización. Cada posible solución a nuestro problema se conoce como individuo, y cada individuo codifica las variables independientes del problema de optimización. Estas variables representan los genes de la cadena cromosómica que representa a un individuo. Los algoritmos genéticos están basados en la Teoría Evolucionista de Charles Darwin (Darwin, 1859). Dicha teoría, explicado de forma muy simple, indica que los individuos que mejor se adaptan al medio son aquellos que tienen más probabilidades de dejar descendencia, y cuyos genes pasarán a las siguientes generaciones. La teoría de Darwin también describe que aquellas modificaciones genéticas que hacen que los individuos se adapten mejor al medio, tienen mayor probabilidad de perdurar en el tiempo. Estas ideas son las que utilizan los algoritmos genéticos para realizar una búsqueda estocástica guiada de forma eficiente. En los problemas de optimización numéricos, los individuos son potenciales soluciones al problema y la adaptación al medio se mide mediante la función que queremos optimizar, también llamada función objetivo, fitness function o función de evaluación1. Un individuo se adaptará bien al medio si produce un desempeño o fitness2 alto, en caso de que se quiera maximizar la función objetivo, o si produce un desempeño bajo en caso de que estemos ante un problema de minimización. Ambos problemas son siempre duales3, por lo que pasar de un problema de maximización a un problema de minimización es tan sencillo como multiplicar por –1 el resultado de la función objetivo. En cada iteración del algoritmo, nuevos individuos (descendientes o, en inglés, offsprings) son creados mediante operaciones genéticas, dando lugar a nuevas poblaciones. Dichas operaciones genéticas, que se pueden resumir en tres bloques -selección, cruce y mutación- son el motor de búsqueda de un algoritmo genético. Cada vez que se crea un nuevo conjunto de individuos, se crea una nueva generación, y dicho proceso termina con la generación final, la cual debe incluir los mejores individuos encontrados a lo largo de las generaciones. Así, la Figura 1.1 representa el funcionamiento general de un algoritmo genético. Como se puede observar, se parte de una población inicial aleatoria y, a través de las operaciones genéticas, se van obteniendo nuevas generaciones hasta que se alcanza la población final. En este primer capítulo del libro, vamos a entrar en detalle en cada uno de los pasos y mecanismos que conforman un algoritmo genético; para ello, utilizaremos la librería de Python deap4. Esta librería nos facilita el diseño e implementación de distintos algoritmos genéticos, ya que incluye muchas funciones de librería que desarrollan los principales componentes de un algoritmo genético. Figura 1.1. Esquema del funcionamiento de un algoritmo genético. ¿Por qué recurrimos a la optimización metaheurística? A lo largo de nuestra vida académica y profesional como ingenieros, con frecuencia nos encontramos con problemas de optimización de gran complejidad. A veces, esta radica en la gran cantidad de variables que hay que manejar; otras, en la complejidad de las ecuaciones que las gobiernan. A veces, incluso nos planteamos si la solución a nuestro problema existe. Pero, en general, solemos decir que estos problemas son difíciles de resolver. Pero ¿qué significa que un problema sea difícil de resolver? Aunque pueda parecer una pregunta trivial, es la primera que debemos formular cuando nos planteamos el uso de optimización metaheurística. Por supuesto, no es una pregunta fácil de responder. Los métodos de optimización se pueden clasificar en dos grandes grupos bien diferenciados: métodos exactos y métodos metaheurísticos o aproximados. La diferencia fundamental entre ellos está clara: los métodos exactos garantizan la obtención de la solución óptima, mientras que los metaheurísticos no. Llegados a este punto, uno podría preguntarse qué sentido tiene inclinarse por la segunda opción pudiendo utilizar un método exacto. Pues bien, la realidad es que no siempre podemos encontrar un método exacto que permita resolver nuestro problema. Es más: si lo hay, es muy posible que su aplicación no sea viable para un problema de cierta complejidad; por ejemplo, por el tiempo de resolución (una búsqueda extensiva para un problema combinatorio de algunos cientos de variables puede tardar meses o años5), o por las simplificaciones que pueden requerir para su aplicación (por ejemplo puede ser necesario linealizar las restricciones del problema). Además, las estrategias de resolución analíticas, como los métodos basados en gradiente, pueden converger a óptimos locales y no alcanzar el óptimo global del problema. Así, para decidir qué estrategia utilizar para abordar un problema difícil de resolver deberíamos plantearnos, al menos, las siguientes cuestiones: ■¿Cómo de grande es mi problema? Evaluar el tamaño del espacio de búsqueda (esto es, el número de soluciones posibles) es un buen indicador de la complejidad del problema. Si conocemos el tiempo necesario para evaluar una solución, podemos hacer una estimación del tiempo que sería necesario para realizar una búsqueda extensiva. ■¿Necesito resolverlo rápido? Está claro que es preferible disponer de la solución lo antes posible, pero debemos pensar si realmente necesitamos que nuestro problema se resuelva en cuestión de segundos o si, por el contrario, varias horas (o días) son un plazo aceptable. ■¿Hay muchas restricciones? ¿Cómo son? Un elevado número de restricciones, y, -sobretodo, una gran cantidad de no linealidades en las mismaspuede constituir un obstáculo insalvable para abordar analíticamente nuestro problema de optimización. Resolver de forma analítica una versión suficientemente simplificada (por ejemplo, relajando ciertas restricciones) de nuestro problema puede ser una buena idea, y nos puede ayudar en el desarrollo de estrategias metaheurísticas para el problema completo. ■¿Qué precisión necesito en los resultados? Cuanto más precisa sea nuestra solución mejor, por supuesto. Pero ¿cuánto es suficiente? ¿Considerarías adecuada una solución un 1% peor que el óptimo global? ¿Y un 5%? Es posible que con un 10% tu solución sea más que útil para cumplir su propósito, y te permitiría ahorrar una gran parte de tiempo o de recursos. Limitaciones de los métodos tradicionales basados en gradiente Tradicionalmente, en los cursos de cálculo, tanto en enseñanza secundaria como en niveles superiores, los métodos de optimización estudiados son los métodos basados en gradiente. De forma genérica, para una función f (x) el procedimiento consiste en: 1. Calculamos las derivadas de la función f ' ( x ). 2. Obtenemos los puntos para los que el gradiente se hace cero f ' ( x i ) = 0. 3. Calculamos la segunda deriva f '' ( x ) y evaluamos los puntos anteriores para saber si es un máximo f '' ( x i < 0) o un mínimo f '' ( x i > 0). Cuando tenemos problemas con varias variables, debemos trabajar con derivadas parciales y obtener las matrices Hessianas. Como podemos observar, los métodos basados en gradientes se basan en el cálculo de las derivadas de la función (o derivadas parciales). Sin embargo, existen muchos casos en los que el cálculo de las derivadas es muy complejo o incluso imposible. Imaginemos que queremos optimizar el funcionamiento una planta industrial de la que no tenemos el modelo, pero sí tenemos un software de simulación de la planta. En esta situación, no podemos aplicar los métodos basados en gradiente, pero sí podremos aplicar los métodos metaheurísticos, como veremos más adelante. Otro problema de los métodos basados en gradiente es que se pueden quedar atrapados en óptimos locales, ya que pueden existir varios puntos de la función en los que el gradiente se haga cero. Por último, también es importante indicar que en los métodos numéricos basados en gradiente, se debe indicar un punto inicial. Por lo tanto, el funcionamiento del algoritmo dependerá de la selección de dicho punto, y pueden aparecer problemas de convergencia en algunos casos. A continuación, veremos que los algoritmos metaheurísticos -y en concreto los algoritmos genéticos- nos permiten obtener soluciones realmente buenas a problemas en los que los métodos tradicionales basados en gradiente pueden presentar problemas. 1.2 Primeros pasos mediante un problema sencillo Como la mejor forma de aprender a programar es simplemente programando, vamos a resolver un problema sencillo paso a paso, con el fin de poder describir los distintos componentes de un algoritmo genético, así como su implementación en Python. En este simple ejemplo, las variables independientes son x e y, y la función objetivo o función de fitness es f (x,y). Un individuo, pues, debe codificar dichas variables independientes en una cadena cromosómica (información genética del individuo) en la que cada variable independiente corresponde a un gen. Así, en nuestro problema ejemplo, la cadena cromosómica estaría formada por dos genes que al confinarse en forma de lista, quedarían como [xi, yi], con i = 1,...,n (siendo n el número de individuos que componen la población). La Figura 1.2 muestra gráficamente la representación de un individuo y de una población de n individuos. Figura 1.2. Representación de un individuo y de una población. En principio, vamos a considerar que la población del algoritmo no cambia de tamaño a lo largo de las generaciones; por lo tanto, n será constante. En los algoritmos genéticos tradicionales, el tamaño de la población es siempre constante. Ad Es decir, queremos tener genes de muchos tipos. En caso contrario, si los individuos de la población inicial se parecieran mucho, estaríamos limitando el proceso de búsqueda del algoritmo genético. Por lo tanto, a la hora de abordar la resolución de un problema mediante algoritmos genéticos, uno de los primeros pasos que tenemos que dar es buscar un mecanismo para generar soluciones aleatorias a nuestro problema que difieran lo suficiente las unas de las otras. Imaginemos que la población inicial está compuesta por diez individuos (n = 10); en consecuencia, se deberán generar diez soluciones aleatorias. En la Tabla 1.1 se muestran las soluciones iniciales generadas, siendo esta solo una posible muestra de diez soluciones aleatorias. Tabla 1.1. Población inicial. Individuo x y 1 2 3 4 5 6 7 8 9 10 81.62 0.93 -43.63 51.16 23.67 -49.89 81.94 96.55 62.04 80.43 68.88 51.59 -15.88 -48.21 2.25 -19.01 56.75 -39.33 -4.68 16.67 Generar valores aleatorios en Python es muy sencillo y existen dos módulos que nos pueden ayudar mucho en esta tarea: i) el módulo nativo random6 y ii) el submódulo de numpy numpy.random7. Por citar las diferencias más importantes entre ambos: el módulo random es nativo, por lo que viene integrado con el intérprete de Python y genera números pseudoaleatorios. Los números totalmente aleatorios no existen en programación: hablaremos más adelante sobre este detalle. Por otro lado, el numpy.random es un submódulo que nos permite crear vectores pseudoaleatorios de distintos tamaños y dimensiones. En definitiva, un módulo me permite generar números aleatorios y el otro vectores aleatorios. Veamos dos formas de obtener poblaciones parecidas a las mostradas en la Tabla 1.1. En el siguiente script se utiliza el módulo random. Con el fin de obtener siempre los mismos números aleatorios, es posible fijar una semilla mediante el método seed: Un generador de números aleatorios, no es más que una función que nos devuelve un número pseudoaleatorio dependiendo de la semilla. Si la semilla es siempre distinta, la función nos devolverá un número distinto. En cambio, si utilizamos la misma semilla, dicha función siempre nos devolverá el mismo número. En el ejemplo mostrado, se utiliza el método uniform8 para generar números entre –100 y 100 (estos dos valores no están incluidos), y se utilizan dos list comprenhension9 para encapsular todos los datos en las listas x e y. Otra posibilidad es generar dos vectores de diez valores comprendidos entre – 100 y 100, con una forma (1,10) (1 fila y 10 columnas); esto implica que son dos vectores de tipo fila con diez valores. Se puede comprobar, que en este segundo caso también hemos fijado la semilla para obtener los mismos valores. Cuando utilicemos el módulo deap para definir nuestros algoritmos genéticos, siempre tendremos que utilizar funciones para generar soluciones aleatorias. Dichas soluciones aleatorias serán nuestra población inicial, es decir, el punto de partida de nuestro algoritmo genético. Además, dichas soluciones deben ser válidas. En nuestro ejemplo, una solución sería no válida si alguna de las variables independientes se saliera de los rangos establecidos, los cuales están comprendidos entre –100 y 100. Es muy común en los problemas de optimización tener restricciones en las variables, por lo que normalmente siempre tendremos que comprobar la validez de nuestras soluciones. Veremos cómo se hace eso más adelante. Volviendo a nuestro problema ejemplo, la idea es encontrar los valores que maximizan la función Tabla 1.2. Soluciones óptimas a nuestro problema ejemplo. Individuo x y 1 2 3 4 100 100 -100 -100 100 -100 -100 100 Es sencillo ver que nuestro problema tiene las cuatro posibles soluciones óptimas, mostradas en la Tabla 1.2. El objetivo en este primer capítulo introductorio, es que nuestro algoritmo genético encuentre alguna o algunas de las soluciones a nuestro problema de manera eficiente, es decir, en el menor número de iteraciones posibles. Antes de continuar, es importante decir que este problema de optimización se podría resolver sin necesidad de un algoritmo genético; cualquier algoritmo de optimización basado en gradiente de los que vienen incluidos en el módulo optimize de scipy10 nos valdría para obtener una solución a nuestro problema de manera sencilla, ya que la función de nuestro ejemplo es convexa11. No obstante, siempre es adecuado empezar con un problema de optimización sencillo, en el que sepamos la solución para saber que estamos haciendo las cosas bien. Aprovechamos este momento para señalar una idea muy importante en cuanto a la aplicación de los algoritmos genéticos: Los algoritmos genéticos se deben emplear en aquellos problemas de optimización en los que Este comentario puede desanimarnos, ya que si no tenemos la certeza de que vamos a obtener la solución óptima ¿qué utilidad tiene utilizar un algoritmo genético? Pues la utilidad es elevada, ya que utilizando un algoritmo genético tendremos una solución bastante buena y en un tiempo razonable o que al menos podremos acotar. Más adelante veremos que ambas características son importantes en problemas de optimización complejos. En definitiva, con un algoritmo genético siempre vamos a terminar con una solución al problema que será mejor que realizar una búsqueda totalmente aleatoria. Volvamos a nuestro ejemplo. Antes de entrar en la programación del algoritmo, vamos a visualizar el espacio de búsqueda en el que tendrá que trabajar el algoritmo genético. El espacio de búsqueda o landscape es el conjunto de valores que pueden tomar las variables independientes y se conoce como el dominio de la función. En nuestro ejemplo, el espacio de búsqueda es infinito ya que estamos trabajando con variables continuas. La Figura 1.3 representa la función de optimización y, por lo tanto, el espacio de búsqueda. Las cuatro soluciones óptimas al problema (ver Tabla 1.2) corresponden a los cuatro picos de la superficie. Figura 1.3. Representación de la función de optimización. A continuación, mostramos el código que se ha utilizado para obtener la Figura 1.3: En este script, la función de optimización se ha definido como funcion_prueba(x) y la variable de entrada corresponde a la lista o vector de variables independientes x e y. Así, la variable x corresponde a x[0] y la variable y corresponde a x[1]. Llegados a este punto, podemos empezar a codificar nuestro algoritmo genético utilizando el módulo deap. El procedimiento va a ser tipo receta, de forma que hay una serie de pasos que siempre tenemos que dar y que solo se cambiarán dependiendo de las características de nuestro problema de optimización; por ejemplo, dependiendo del tipo de variables independientes que tengamos (continuas, discretas, reales, binarias, etc.). Para utilizar el módulo deap es importante tener ciertas nociones de programación orientada a objetos, ya que se utiliza la propiedad de herencia entre clases. El diseño de algoritmos genéticos con deap puede parecer un poco complejo al principio, pero veremos cómo al final el procedimiento es bastante repetitivo. A continuación, vamos a dividir el proceso en varias partes, para poder explicar cada uno de los pasos con el mayor detalle posible. Finalmente, se mostrará el código completo que solo incluye lo estrictamente necesario para ejecutar el algoritmo genético. 1.3 Definición del problema y generación de la población inicial En esta sección se definen aspectos muy relevantes del algoritmo genético, como son: (i) el tipo de problema de optimización (maximizar o minimizar), (ii) el tipo de objeto de Python o plantilla que va a contener el individuo (lista, vector, etc.) y sus atributos, y (iii) el objeto caja de herramientas o toolbox que contendrá, mediante registro, un conjunto de funciones utilizadas por el algoritmo durante su ejecución. Entre los tipos de funciones que se registran en la caja de herramientas, destacan las siguientes: a) las funciones para crear los individuos de forma aleatoria, b) la función para crear la población, c) los operadores genéticos (selección, cruce y mutación) y d) la función objetivo. En esta sección, trataremos el registro de las funciones para a) y b), dejando para la siguiente sección las funciones de c) y d). A continuación, mostramos un script que incluye las sentencias para realizar las operaciones i), ii) y iii) mencionadas anteriormente. A lo largo de la sección, iremos explicando cada una de las sentencias de manera individual. En conveniente aclarar que, aunque en este script se han importado las librerías que hacen falta, en el resto de los fragmentos de código del capítulo no se incluirán dichas líneas de código (salvo la sección de código completo), aunque sean también necesarias. 1.3.1 Creación del problema Comenzamos por la creación del problema y, para ello, nos apoyamos en el método create de la clase creator. En la siguiente línea se crea el tipo de problema: El método create crea una nueva clase llamada FitnessMax (podemos darle el nombre que queramos), que hereda de base.Fitness y que tiene un atributo que se denomina weigths. Esta línea de código merece más detalles para poder entender todo lo que se realiza en una sola sentencia. El método create tiene los siguientes argumentos: ■name : Nombre la clase que se crea. ■base : Clase de la que hereda. ■attribute : Uno o más atributos que se quieran añadir a la clase cuando se cree. Este parámetro es opcional. Por lo tanto, en esa línea de código estamos creando una nueva clase que se denomina FitnessMax12. Ese nombre no ha sido elegido al azar, ya que nos indica que estamos ante un problema de maximización. Aunque podríamos haber elegido cualquier otro nombre, es conveniente elegir nombres que reflejen el tipo de problema al que nos estamos enfrentando (si lo llamamos «Problema», no sabremos distinguir a simple vista el tipo de problema). Esta clase hereda las propiedades de la clase base del módulo deap. La última operación que realiza el método create, es crear un atributo denominado weigths; este atributo es importante ya que indica el tipo de problema de optimización que estamos definiendo. De forma genérica, weights contendrá una tupla con tantas componentes como objetivos tenga el problema, y con un valor que indicará si estos objetivos son de maximización o minimización. En nuestro ejemplo, el problema es de maximización de un solo objetivo. Esto es así porque la tupla solo tiene un elemento con valor de (1,0,). Si el problema fuese de minimización con un solo objetivo, la tupla weights contendría el valor de (–1,0,). Por el contrario, si el problema fuera multiobjetivo, el atributo weights tendría tantos unos o menos unos como objetivos se quieran definir para maximizar o minimizar, como veremos más adelante (Capítulo 4). El objeto base (base.Fitness) contiene los atributos encargados de almacenar el fitness o desempeño de un individuo. En concreto, el objeto base.Fitness contiene los siguientes atributos13: ■values : Es una tupla que contiene los valores de fitness de cada uno de los objetivos de nuestro problema. En este primer capítulo, vamos a empezar con problemas de un solo objetivo, pero este es solo un caso particular del problema más general, que será multiobjetivo. Así pues, values contendrá la calidad de cada individuo en cada uno de los objetivos de nuestro problema de optimización. ■dominates : Devuelve verdadero ( True ) si una solución es estrictamente peor que otra. Este atributo se utilizará en los algoritmos genéticos con múltiples objetivos. ■valid : Indica si el fitness de un individuo es válido. Este atributo se utiliza para saber el número de individuos que se tienen que evaluar en cada iteración del algoritmo genético. En general, si un individuo tiene el atributo values vacío, el atributo valid será False . Por lo tanto, la clase que estamos creando también tendrá disponibles estos tres atributos gracias a la propiedad de herencia de clases de Python14. Aunque las operaciones del método create pueden parecer muy complejas, a continuación se muestra un código equivalente en Python al método create. Simplemente se define una nueva clase MaxFitness que hereda de otra clase base.Fitness y que tiene un atributo en su declaración15: Como resumen de creator.create, nos debemos quedar con que en dicha línea de código debemos definir dos cosas: 1. El tipo de problema (maximizar 1 , 0 o minimizar –1 , 0). 2. El número de objetivos que tiene nuestro problema (uno o varios, según unos o menos unos contenga la tupla del atributo weights ). 1.3.2 Creación de la plantilla del individuo El método create se vuelve a utilizar, en este caso para definir la clase que encapsula al individuo: En esta línea de código, estamos creando una clase que se denomina Individual, que hereda de la clase lista (por lo tanto, tiene todos los métodos de una lista16) y que contiene el atributo fitness, el cual ha sido inicializado con el objeto FitnessMax creado en la anterior línea. Es decir, el individuo será una lista que tiene un atributo fitness que almacenará la calidad o desempeño de este. Veamos, a continuación, un código equivalente realizado estrictamente en Python sin utilizar el método creator17: Se puede observar que la operación que realiza creator es, simplemente, crear una nueva clase que hereda de otra y que tiene unos atributos que podemos indicar. En definitiva, en esta sentencia lo que se está haciendo es crear la plantilla que contendrá la información del individuo. Definir los individuos como una lista nos permite poder acceder a cada uno de los genes mediante la posición que ocupa. Cada posición de la secuencia es una variable distinta. Así, volviendo a nuestro ejemplo, la primera posición será la variable x y la segunda será la variable y. Ya hemos definido el tipo de problema y el tipo de individuo que vamos a utilizar. Estos dos pasos se van a dar siempre y, en la mayoría de los casos, ambas líneas de código se repetirán con pequeñas modificaciones -dependiendo del número de objetivos y del tipo de objeto que almacene los individuos-. Definir los individuos como una lista de variables es un procedimiento muy eficiente y flexible, ya que cada variable independiente será una posición de la lista. El tamaño de la lista se define cuando se crean los individuos de la población inicial, como veremos más adelante. 1.3.3 Crear individuos aleatorios y población inicial A continuación, debemos definir funciones que nos permitan crear individuos aleatorios y, en consecuencia, la población inicial. La siguiente línea define un objeto toolbox de tipo base.Toolbox o caja de herramientas18: Este objeto permite registrar funciones que se utilizarán durante la operación del algoritmo genético. El registro de funciones se realiza mediante el método register de la clase base.Toolbox. El método register tiene los siguientes atributos: ■alias : El nombre con el que registramos la función en la caja de herramientas. ■function : La función que estamos registrando en la caja de herramientas. ■argument : Los argumentos (uno o varios) que se pasan a la función que se está registrando. En primer lugar, vamos a registrar las funciones que nos permiten crear individuos aleatorios. Para ello, necesitamos desarrollar una función que nos permita generar un valor aleatorio para cada variable independiente (cada gen del cromosoma), esto es, cada una de las posiciones de la lista. Además, conviene que dicho valor esté comprendido entre los valores límites de nuestras variables, con el fin de obtener una solución factible al problema. La siguiente sentencia realiza dicha operación: El método register registra una función en el objeto toolbox con el nombre attr_uniform. Este Es decir, el método register, nos permite registrar una función, que será un método del objeto toolbox, en la caja de herramientas mediante un alias. Después del alias, se debe indicar la función a la que se llamará cuando se utilice el método y, a continuación, los parámetros que se le pasan a la función (si existe alguno). Una vez que se registra la función, es posible acceder a esta desde el objeto toolbox como un método, por ejemplo toolbox.attr_uniform(). Cada vez que se llame a dicho método, se generará un número aleatorio comprendido entre –100 y 100. Registrar funciones es una funcionalidad que nos permite cambiarles el nombre y tenerlas disp Para más información sobre la implementación del método register se recomienda echar un vistazo al funcionamiento del método partial del módulo nativo de Python functools19. El siguiente script muestra el código equivalente en Python que realiza el registro de una función como un atributo del objeto toolbox20. A continuación, y con el fin de crear el individuo completo, necesitamos llamar a la función que genera cada uno de los genes tantas veces como variables independientes tengamos. Para ello, se registra una función que se denomina individual. A su vez, esta función llama a la función tools.initRepeat de la siguiente forma: La función tools.initRepeat tiene como parámetros: ■container : El tipo de dato donde se almacenará el resultado del argumento func . ■func : Función a la que se llamará n veces. ■n : Número de veces que se llamará a la función func . En nuestro caso, el container es la clase creator.individual, creada anteriormente. La función func es la que utilizamos para crear cada gen (toolbox.attr_uniform) y n será el número de genes que hay que crear, que en el caso de nuestro problema con dos variables será n = 2. Por lo tanto, el método initRepeat nos permite ejecutar varias veces la función registrada attr_uniform y almacenar el resultado en el individuo que queremos crear. Como resultado se crea un individuo aleatorio. Por ilustrar el funcionamiento con un ejemplo, se puede crear un individuo aleatorio mediante la sentencia toolbox.individual(), que proporciona el siguiente resultado21: Así, cada vez que se ejecute toolbox.inidivual() se creará un individuo aleatorio. Es importante recordar que individuo es una lista que tiene un atributo fitness donde se almacena la calidad del mismo. Dicho atributo debe estar creado junto con el individuo y, además, debe estar vacío, ya que el individuo todavía no ha sido evaluado. Así, si accedemos al atributo fitness de un individuo recién creado, obtendremos el siguiente resultado22: Una vez detallado el procedimiento para crear un individuo de forma aleatoria, el procedimiento para crear la población inicial es análogo. La línea de código que registra el método para crear la población inicial es: En esta sentencia la función que se registra se llama population, la cual utiliza initRepeat para llamar diez veces a la función individual (se llama una vez por cada individuo que formará la población inicial). El resultado se guarda en una lista que contiene la población inicial generada. Es decir, con respecto a los argumentos de initRepeat, el container es una lista, la función func es toolbox.individual y n = 10 (tamaño de la población). Aunque se ha definido un tamaño de diez para la población inicial, este valor se puede cambiar al tamaño que queramos. Se recomienda elegir números divisibles entre cuatro, ya que algunas operaciones genéticas del módulo deap pueden dar problemas si no se cumple este requisito. Como ejemplo de creación de una población inicial de prueba, el resultado de toolbox.population sería el siguiente23: Se puede ver que se ha creado una lista de diez listas (una por cada individuo) con dos componentes. Si queremos acceder a alguno de los individuos, podemos hacerlo a través del índice. Por ejemplo, para acceder al segundo individuo de la población inicial podemos hacer: Es conveniente hacer un pequeño paréntesis para hablar del tamaño de las poblaciones en los algoritmos genéticos. En principio no existe un tamaño óptimo de población para los problemas de optimización, pero sí debe estar en proporción al número de variables independientes que tengamos. En nuestro problema tenemos dos variables independientes x e y, y se ha definido un tamaño de diez, que puede resultar válido ya que el problema que vamos a resolver es bastante sencillo. De todas formas, más adelante haremos pruebas con distintos tamaños. Cuanto mayor sea el número de variables independientes mayor debe ser el tamaño de la pobl No obstante, si observamos que no obtenemos resultados satisfactorios con un tamaño determinado, podemos aumentar el tamaño de la población. Además, debemos tener en cuenta que un tamaño mayor implica un número mayor de evaluaciones de la función objetivo y, por lo tanto, más tiempo de computación. Así pues, hay casos en los que el tamaño de la población viene limitado por el tiempo que estamos dispuestos a esperar para obtener una solución para nuestro problema. Como resumen de esta importante sección, hasta este punto lo único que hemos hecho ha sido definir el procedimiento para generar la población inicial. El procedimiento puede parecer complejo, pero una vez que entendamos su estructura veremos que la mayoría de pasos eran similares cuando resolvamos diferentes problemas. Esto se debe a los siguientes motivos: ■Los problemas solo pueden ser de dos tipos (maximizar o minimizar); por lo tanto, cuando se cree el problema lo único que variará será si en la tupla weights ponemos 1.0 o -1.0 (los problemas multiobjetivo se verán más adelante). ■Los individuos serán listas en la mayoría de los casos. Por lo tanto, la siguiente línea nos valdrá en la mayoría de casos: ■Necesitamos una función para generar cada uno de los genes de nuestro individuo. Esto sí será diferente para cada problema. Aunque en la mayoría de problemas con variables continuas la función random.uniform nos puede valer, en el resto de casos simplemente tendremos que cambiar los límites. ■Una vez que tenemos la función para generar los genes de nuestro individuo, el registro de funciones para crear individuos aleatorios y la población inicial serán casi siempre los mismos. Lo único que podemos cambiar es el tamaño de los individuos y la población. 1.4 Función objetivo y operadores genéticos Continuamos con los pasos que debemos realizar para codificar nuestro algoritmo genético. En esta sección, trataremos el registro de la función objetivo y de los operadores genéticos en el objeto toolbox. El siguiente script muestra el código que se describirá paso a paso en esta sección: 1.4.1 Función objetivo La función objetivo de un algoritmo genético es, sin duda, la parte más particular del problema de optimización. Podemos dividir las funciones objetivo en dos tipos: (i) funciones objetivo que están codificadas en Python y (ii) funciones objetivo que son el resultado de un programa o software externo. En el primer caso, debemos codificar la función objetivo de nuestro problema como una función de Python. El módulo nativo math24 y las librerías numpy y scipy25 pueden ser útiles, ya que contienen una gran cantidad de funciones matemáticas disponibles. En el segundo caso, nuestro script de Python llamará a un programa externo para obtener el desempeño del individuo. Este segundo caso es muy interesante, ya que nos permite utilizar modelos más complejos incluidos en software específicos. Sin embargo, este caso queda fuera del objetivo de este libro y no será abordado en el mismo. Únicamente destacaremos que existen funciones en Python para ejecutar otros programas externos. Solo por poner un ejemplo, el módulo nativo os26, incluye la función system que permite hacer llamadas al sistema. Otro ejemplo sería el módulo subprocess27. Siguiendo con nuestro ejemplo, a continuación, vamos a definir nuestra función objetivo como una función en Python, la cual se puede ver en el siguiente script. En ella, hemos utilizado el módulo nativo math28 para calcular la raíz cuadrada: Antes de detallar cómo registrar esta función, es importante destacar el hecho de que si una solución no cumple las restricciones, debe ser descartada. Así, se puede observar en el código anterior que si una de las dos variables independientes toma valores fuera del dominio de la función, la función objetivo devolverá un –1. Esto se conoce como aplicar la pena de muerte. Observe cómo, al tratarse de una función de maximización, las soluciones válidas solo aportarán valores de la función objetivo positivos y, por tanto, un –1 será un valor que penaliza totalmente el resultado. La pena de muerte hace que un individuo no participe en las operaciones genéticas de cruce y mutación; por lo tanto, sus genes no se utilizarán para generar las siguientes generaciones. La pena de muerte es un mecanismo por el cual se inhabilita a un individuo de una determinad Para registrar la función de fitness, debemos proceder de la siguiente forma: Podemos evaluar a un individuo generado con toolbox.individual() de la siguiente forma: Obtendremos el mismo resultado si evaluamos al individuo mediante toolbox.evaluate(individuo). Un detalle importante que no debemos pasar por alto -y que está relacionado con el módulo deap- es que la función de fitness devuelve una tupla, con independencia del número de objetivos del problema. Esto será así siempre, debido a que: El caso con un único objetivo no es más que un caso particular del problema genérico multiob Por lo tanto, no hay que olvidar que la función objetivo siempre debe devolver una tupla, aunque una de las componentes esté vacía. Antes de terminar con este apartado, es importante destacar la relevancia de codificar de manera eficiente la función objetivo. Dicha función se ejecutará una gran cantidad de veces. En consecuencia, cualquier ahorro en tiempo de computación que podamos aplicar en la función objetivo supondrá una gran ventaja (en el Apéndice B se aborda la paralelización de los algoritmos genéticos en deap). Siempre que podamos, deberemos evitar bucles o condiciones que puedan dejar colgado el algoritmo. 1.5 Operadores genéticos A continuación, vamos a pasar a definir las operaciones genéticas. Las operaciones genéticas son aquellos mecanismos que nos permiten generar nuevos individ Las operaciones genéticas son de tres tipos: (i) selección (selection), (ii) cruce (mate) y (iii) mutación (mutation). La selección es el procedimiento por cual se seleccionan los individuos que participarán en las operaciones de cruce y mutación. La selección es un procedimiento siempre elitista, de forma que un individuo tendrá mayor probabilidad de dejar descendencia si su fitness es más adecuado al problema de optimización. En el caso de problemas de maximización, cuanto mayor sea el fitness, mayor será la probabilidad de participar en las operaciones de cruce y mutación. Notemos que este razonamiento está en línea con la teoría evolutiva de Darwin, la cual indica que las posibilidades de dejar descendencia en las futuras generaciones crecen cuando crece la adaptación del individuo al medio. La operación de cruce es una operación probabilística que permite que dos individuos seleccionados crucen o intercambien su información genética para crear dos nuevos individuos. Es importante indicar de nuevo, que la operación de cruce es probabilística; esto quiere decir que, aunque dos individuos sean seleccionados, puede que no sean modificados. La probabilidad de cruce es un hiperparámetro de los algoritmos genéticos que tendremos que definir. No existe un valor óptimo universal para la probabilidad de cruce (óptimo para todos los problemas), por lo que habrá que probar con distintos valores. Por otro lado, la operación de mutación es una operación probabilística que permite que un individuo seleccionado modifique su información genética para crear un nuevo individuo. Al igual que el cruce, la mutación es una operación probabilística cuyo resultado depende de la probabilidad de mutación, la cual también debemos definir nosotros como otro hiperparámetro; de nuevo, no existe un valor óptimo que sirva para todos los problemas. Por lo tanto, tendremos que ajustarlo en cada problema. En un algoritmo genético clásico o canónico, primero se realiza la selección de individuos. Estos individuos seleccionados se cruzan, en caso de que la probabilidad sea favorable, y después se mutan, de nuevo en caso de que la probabilidad sea favorable. Como ambas operaciones son probabilísticas, se puede dar el caso de que un individuo que se ha seleccionado no sea modificado debido a que ninguna de las probabilidades le sea favorable. Es decir, puede suceder que el individuo ni se cruce ni se mute. Por lo tanto, pasaría a la siguiente generación sin ningún tipo de modificación. El ajuste de las probabilidades de cruce y mutación es sumamente importante para el funcionamiento adecuado de un algoritmo genético. La Figura 1.4 muestra el flujo de creación de la descendencia u offspring de una población. Hay que destacar, que ambos mecanismos son el motor para explorar y explotar zonas del espacio de búsqueda. Se puede observar que un individuo seleccionado se puede cruzar con otro individuo y/o puede sufrir mutación. Es decir, ambas operaciones son independientes. Figura 1.4. Flujo de creación de la descendencia u offspring de una población. Volvamos a nuestro ejemplo para definir todas estas operaciones. En este punto del diseño del algoritmo es donde vamos a sacar partido al módulo deap, ya que este contiene una gran variedad de algoritmos de selección, cruce y mutación, que nos permiten definir algoritmos genéticos de una manera sencilla29. La Tabla 1.3 muestra todas las operaciones genéticas que están implementadas en el módulo deap30. La aplicación de cada una de ellas dependerá del problema al que nos enfrentemos, ya que algunas operaciones son adecuadas para problemas con variables continuas y otras son adecuadas para problemas con variables discretas. Se debe utilizar la documentación oficial para saber qué operación realiza cada uno de los métodos31. No obstante, a lo largo del libro se irán describiendo muchos de los operadores según se vayan utilizando en los problemas. Además, el Glosario incluye información sobre dónde encontrar la descripción de cada uno de los operadores. Tabla 1.3. Listado de operaciones implementadas en deap. Veamos, a continuación, el registro de los operadores genéticos que se utilizarán en las iteraciones del algoritmo. En primer lugar, definimos el mecanismo que utilizaremos para realizar el cruce (mate) entre individuos. En este caso, utilizamos el operador cxOnePoint, o cruce de un punto. Es importante recordar que esta operación es transparente para nosotros, ya que el operador será utilizado internamente por el algoritmo genético utilizado como caja negra (black box optimization32). En este caso, al ser la longitud de los individuos dos, solo existe un posible punto de cruce. Por lo tanto, en nuestro ejemplo el cruce es simplemente intercambiar los valores de x e y. Para la mutación se ha utilizado el operador mutGaussian (mutación Gaussiana) con una media de cero y una desviación típica de 5. Estos valores son solo de ejemplo y no garantizan ser los más adecuados. Es por ello que se deben probar distintos valores para ver el funcionamiento del algoritmo genético en función de dichos valores. Es importante elegir adecuadamente el parámetro indpb, que define la probabilidad de mutación de cada gen (no olvidemos que tanto la operación de cruce como la de mutación son operaciones probabilísticas). Las probabilidades de cruce y mutación que utilizará el algoritmo genético se definirán más adelante. En el caso de la mutación, se deben definir dos probabilidades: la probabilidad de mutar un individuo y la probabilidad de mutar cada uno de los genes del individuo (indpb). En nuestro ejemplo, hemos definido una probabilidad indpb de 0.1. Este valor, en general, debe ser bajo para que la mutación no modifique en exceso al individuo. Cabe destacar que valores muy altos de esta probabilidad pueden provocar que el algoritmo no converja correctamente, o que no se intensifiquen ciertas zonas del espacio de búsqueda. En resumen, el método tools.mutGaussian recibe como parámetros de entrada un individuo seleccionado y los parámetros mu, sigma e indpb. Es importante indicar que, al igual que ocurre con el cruce, la operación de mutación se aplicará de forma transparente a nosotros como usuarios. Será el algoritmo como caja negra quien se encargue de realizar todas las operaciones genéticas. Para el proceso de selección, se ha utilizado el operador selTournament, que nos permitirá realizar una selección mediante torneo. En este caso fijaremos el tamaño igual a tres. Se ha demostrado que este tamaño funciona relativamente bien para la mayoría de los casos (Lones, 2011). El algoritmo realiza tantos torneos como individuos tiene la población, ya que tal y como se mostró en la Figura 1.4-, los individuos seleccionados primero se cruzan y después se mutan. De nuevo, el proceso de selección es transparente para nosotros, ya que lo realiza internamente el algoritmo genético. Aunque se ha demostrado que un tamaño de torneo de tres es válido para la mayoría de los casos, cuando la población crece mucho se deben utilizar tamaños más altos para hacer más rápida la convergencia del algoritmo (se hablará de ello en el Capítulo 2). La selección con torneo es muy elitista y hace que el algoritmo converja a mayor velocidad si lo comparamos con otros algoritmos de selección como, por ejemplo, la selección mediante ruleta (se abordará en el Capítulo 3). Se debe observar que todas las funciones han sido registradas en el objeto toolbox utilizando el método register mediante un alias (primer parámetro del método register). Dichos alias no deben ser modificados, ya que son utilizados por la función del submódulo algorithms de deap que implementa el algoritmo genético como una caja negra o black box. Es decir, esos nombres no son elegidos al azar y deben ser respetados. Los alias del registro de los operadores genéticos deben ser: mate para el cruce, mutate para la Lo que sí podemos cambiar son las funciones del submódulo tools que se utilizan para realizar las operaciones de selección, cruce y mutación (ver Tabla 1.3). Incluso podemos definir nuevos operados genéticos que se ajusten a nuestro problema de optimización (este paso lo haremos más adelante). Antes de continuar con los siguientes pasos del algoritmo genético, es importante volver a destacar la importancia de los operadores genéticos y la función que tiene cada uno. En el caso del cruce, el objetivo es encontrar bloques dentro de la cadena cromosómica que den origen a buenos resultados de la función de evaluación. Estos bloques serán intercambiados con mayor probabilidad a otros individuos. Por lo tanto, mediante las operaciones de cruce los individuos tenderán a parecerse los unos a los otros a lo largo de las generaciones. Sin embargo, mediante operaciones de cruce el poder exploratorio del algoritmo está limitado por los valores máximos y mínimos de los genes en la población inicial. Veamos dicha limitación con un ejemplo. Imaginemos que los valores máximos y mínimos de las variables x e y de la población inicial de nuestro problema los representamos en un plano x vs y, tal y como muestra la Figura 1.5. Se puede observar que los valores máximos y mínimos determinan un área de confinamiento de todas las soluciones que podemos obtener según la población inicial generada. Es decir, simplemente con operaciones de cruce no podremos salirnos de dicha área, ya que las operaciones de cruce lo único que hacen es intercambiar información genética33. Así, si dicha zona es amplia, las operaciones de cruce permitirán explorar una gran cantidad de soluciones dentro de la misma. Pero si el óptimo global está fuera de dicha región, nunca lo podremos encontrar simplemente aplicando operaciones de cruce. No hay que confundir la zona de confinamiento de posibles soluciones dada por los valores máximos y mínimos de cada variable en una generación, con los valores máximos y mínimos de las variables, que en nuestro caso siempre serán [– 100,100]. Es importante indicar, que en problemas con más dimensiones, no tendremos un área de confinamiento, sino un hiperplano de n dimensiones, siendo n el número de variables independientes del problema. Figura 1.5. Limitaciones de exploración de los operadores de cruce. El operador de mutación, nos permite ampliar el área de las posibles soluciones de la población inicial (y cualquier otra generación), incrementando los valores máximos y mínimos de las variables independientes. Con respecto a la Figura 1.5, las operaciones de mutación nos permiten ampliar el recuadro rojo. La Figura 1.6 muestra tres hijos creados con la mutación Gaussiana para distintos valores de σ. Se puede observar que conforme aumenta el valor de σ, aumenta la distancia de los hijos con respecto a los padres. Podemos ver, en la Figura 1.6, que los genes pueden desplazarse en cualquier dirección. Por lo tanto, mediante la operación de mutación, se puede modificar la zona de confinamiento presentada en la Figura 1.5. El valor de σ es sumamente importante, ya que podemos observar, que para valores bajos (σ = 1) prácticamente no modificamos el gen. Figura 1.6. Generación de progenitores mediante mutación Gaussiana. Por último, la operación de selección nos permite aplicar una componente elitista al algoritmo, de manera que aquellos individuos que mejor se adapten serán los que con mayor probabilidad intercambien sus genes o los muten. 1.6 Últimos pasos: Algoritmo genético como caja negra Ya tenemos casi todo listo para lanzar nuestro algoritmo genético. En esta sección describiremos, por un lado, la función main que configura el algoritmo genético y, por otro lado, la representación de los resultados del algoritmo. El siguiente script muestra el código que se analizará en esta sección: 1.6.1 Configuración algoritmo genético La primera sentencia de la función main define la semilla del generador de números aleatorios34. Este paso se suele dar para tener resultados reproducibles; hablaremos de este aspecto en la siguiente sección. A continuación, se definen tres parámetros muy importantes del funcionamiento del algoritmo, como son la probabilidad de cruce CXPB, la probabilidad de mutación MUTPB, y el número de generaciones NGEN. En cuanto a las probabilidades de los operadores genéticos, en nuestro caso se ha definido una probabilidad de cruce de 0.5 (50%), una probabilidad de mutación de 0.2 (20%) y un número de generaciones igual a 20. Estos valores no son mágicos -ni siquiera tienen por qué ser los óptimos para nuestro problema-, solo son unos valores de prueba para obtener unos resultados preliminares35. A continuación, se genera la población inicial mediante el método population del objeto toolbox. Veremos, en otros capítulos, que el tamaño de la población se puede definir también en este punto. Después, se define un objeto hof de tipo HallOfFame que, como indica la documentación36, almacena el mejor individuo encontrado a lo largo de las generaciones del algoritmo genético. El método HallOfFame recibe dos parámetros: ■maxsize : Número de individuos a almacenar. ■similar : Una función para comparar si dos individuos son iguales. Si no se pone nada, utilizará por defecto el método operator.eq del módulo operator 37. En nuestro caso, se ha definido maxsize como 1. Hay que destacar, que este es el mecanismo que implementa deap para no perder al mejor individuo a lo largo de la evolución del algoritmo. Es decir, con el algoritmo eaSimple que veremos a continuación, se puede dar el caso de que la mejor solución se pierda debido a los operadores genéticos. Es por ello que el objeto hof es importante para no perder nunca esta solución. La clase HallOfFame se encuentra definida en el submódulo tools y se debe indicar el número de individuos que debe almacenar. En nuestro caso solo almacena uno, ya que solo estamos interesados en almacenar el mejor individuo creado a lo largo de las generaciones. En cada generación del algoritmo genético, el objeto hof es actualizado mediante el método update de manera transparente para nosotros. El método update recibe como entrada la población actual y actualiza el contenido del objeto hof. Cabe destacar que, en este ejemplo, en ningún momento se ha utilizado elitismo para hacer que los mejores individuos avancen directamente (se hablará más adelante del elitismo). El siguiente paso es definir un objeto para generar las estadísticas de la población a lo largo de las generaciones del algoritmo. Este objeto es de tipo Statistics y se encuentra definido en el submódulo tools. Al crear el objeto se debe indicar sobre qué atributo de los individuos se van a generar las estadísticas 38. En nuestro caso, las estadísticas se van a generar sobre el fitness de los individuos. A continuación, se deben registrar en el objeto stats las funciones estadísticas que se van a utilizar. Para registrar funciones se debe utilizar el método register, que recibe los mismos parámetros de entrada que el método register del objeto toolbox. Por lo tanto, el procedimiento es análogo al que se realizó para registrar las funciones en la caja de herramientas. En primer lugar, se incluye un alias y, como segundo parámetro, se indica la función a la que se llamará. En este caso se han utilizado funciones de librería de numpy. Las funciones que se registran calculan la media (np.mean39), la desviación típica (np.std40), el mínimo (np.min41) y el máximo (np.max42) para cada generación del algoritmo. Dichos cálculos se realizarán de manera trasparente para nosotros. El objeto stats tiene un método, denominado compile43, que recibe como entrada la población que permite generar las estadísticas. A dicho método se lo llama internamente en cada generación del algoritmo. Llegado este momento, estamos en disposición de poder ejecutar el algoritmo genético. En este ejemplo, vamos a utilizar un algoritmo genético de librería. La librería deap dispone de varias implementaciones de algoritmos genéticos listos para ser utilizados de manera sencilla como cajas negras o black boxes. Las distintas versiones de algoritmos genéticos que están disponibles en deap se encuentran en el submódulo algorithms44. En este primer ejercicio, vamos a utilizar el algoritmo eaSimple. Una vez configurado, podemos lanzar la función main para ver los resultados. 1.6.2 Resultados del algoritmo genético Para ejecutar la función main, nos faltaría incluir la siguientes líneas45:. Se puede observar que la función main devuelve el mejor individuo almacenado en el objeto hof, así como la población final. El Resultado 1.1 muestra la solución obtenida por el algoritmo genético. Resultado 1.1. Resultados del algoritmo genético. Durante la ejecución del algoritmo se han ido generando los datos referentes al número de generaciones (gen), número de evaluaciones (nevals), desempeño medio de la población (avg), desviación típica (std), valor mínimo de la población (min) y valor máximo de la población (max). Estos datos corresponden a las funciones registradas en el objeto stats. Finalmente se indica el mejor fitness obtenido (136.77) y el mejor individuo encontrado [–94.69,– 98.70]. Es decir, la solución es x = –94.69 e y = –98.70. Podemos observar que el valor obtenido está muy cerca del valor óptimo real, pero no es exactamente el mismo. Con respecto al código, hay que indicar que aunque el objeto hof se ha definido de forma que solo almacene un individuo, este objeto es una secuencia (funciona como una lista en Python), por lo que para obtener tanto el fitness como el individuo debemos utilizar el índice cero. Debemos recordar que para acceder al fitness del individuo tenemos que invocar el atributo fitness.values. Es importante volver a indicar que en este problema estamos «haciendo trampa», ya que estamos optimizando una función cuyo valor máximo sabemos cuál es. Saber el resultado óptimo puede llevarnos a pensar que el algoritmo genético no está funcionando bien ya que no ha sido capaz de encontrar dicho valor. Nada más lejos de la realidad, ya que el algoritmo genético nos ha proporcionado en muy pocos pasos una solución que es bastante buena -no es la mejor, pero está muy cerca-. Además, como veremos a continuación, todavía podemos mejorar los resultados. Analizando los datos de funcionamiento mostrados en el Resultado 1.1, podemos hacer algunos comentarios generales: ■A lo largo de las generaciones, el valor medio avg de la población se va incrementando. Esto es positivo ya que significa que los individuos que forman la población son, en media, mejores. ■La desviación típica std va disminuyendo, en general. Esto indica que los individuos cada vez son más parecidos. Esto es esperable ya que el algoritmo genético es elitista por el proceso de selección, por lo que los individuos tenderán a parecerse. ■El valor máximo max va aumentando a lo largo de las generaciones. Esto es tremendamente positivo ya que indica que el algoritmo está funcionando correctamente. La columna nevals en el Resultado 1.1 muestra el número de individuos que han sido evaluados. Este número no se corresponde con el número total de individuos de la población; esto es así porque las operaciones de cruce y mutación son probabilísticas. Por ello, un individuo seleccionado no tiene por qué ser modificado. Todo individuo que, aun siendo seleccionado, no haya participado en ninguna operación genética, no será evaluando, ahorrando así operaciones redundantes. Si aumentamos la probabilidad de mutación, aumentaremos el número de individuos que serán evaluados en cada generación. Ocurrirá lo mismo si aumentamos la probabilidad de cruce. Un aspecto que no está optimizado en el módulo deap es que si dos individuos son iguales no se evalúen dos veces. Esto es así porque el parámetro que hace que un individuo sea evaluado o no es la validez de su fitness, atributo valid del fitness. Cuando un individuo es modificado debido a una operación genética (cruce o mutación), su fitness se invalida, esto es, se pone a False. En cada generación, todos los individuos que tienen un fitness inválido deben ser evaluados. Esta comprobación se realiza internamente en el algoritmo eaSimple. Así, aunque dos individuos sean exactamente iguales, ambos tendrán fitness válidos, por lo que serán evaluados de nuevo. Por lo tanto, aunque a lo largo de las distintas generaciones se generen individuos iguales, estos serán evaluados de nuevo46. 1.7 ¿Cómo conseguir resultados consistentes? La cuestión ahora es la siguiente: ¿corremos una sola vez el algoritmo genético y aceptamos el resultado? La respuesta es: por supuesto, no. Como ya hemos repetido en varias ocasiones, un algoritmo genético es un algoritmo estocástico y, además, tiene muchos parámetros de ajuste que deben ser modificados para ver cómo afectan a los resultados. Así, para mostrar un resultado consistente debemos obtener y mostrar ciertas estadísticas sobre el comportamiento de nuestro algoritmo genético. A continuación, se detallan algunas buenas prácticas, las cuales llevaremos a cabo en este y sucesivos ejemplos: ■Aumentar la población hasta que no veamos mejoras significativas. Podemos probar con pocas generaciones e ir aumentando el número de individuos. ■Aumentar el número de generaciones y comprobar que el algoritmo converja. Para ello, lo ideal es mostrar una gráfica de convergencia del algoritmo. Lo veremos en la siguiente sección. ■Hacer un barrido de valores de probabilidades de cruce y mutación, y mostrar algunas estadísticas para ver de qué manera afectan dichas probabilidades. Lo veremos en los siguientes capítulos. Por ejemplo, si probamos con una población de 30 individuos, los resultados que obtenemos son bastante mejores, como muestra el Resultado 1.2. Resultado 1.2. Resultados del algoritmo genético con una población de 30 individuos. El mejor fitness ha cambiado de 136.77 a 140.10, lo que significa una mejora considerable. Pero, en este punto, debemos preguntarnos algo: ¿dicha mejora es debida a que hemos aumentado el número de individuos o es simplemente azar? Para comprobar qué es lo que está ocurriendo debemos lanzar el algoritmo genético con ambas configuraciones y obtener algunas métricas. El siguiente script muestra el código necesario para lanzar el algoritmo genético 20 veces. Se ha utilizado la lista lista_mejores para almacenar el fitness del mejor individuo de cada intento del algoritmo genético. Cambiando el valor de range, podemos cambiar el número de veces que se lanza el algoritmo. Al terminar el bucle, se calcula la media y el mejor resultado de todos los intentos. Es importante recordar que en la sección anterior se ajustó la semilla mediante random.seed(42) en el main; mediante esta sentencia hacemos que siempre se genere la misma población inicial y que los resultados probabilísticos sean los mismos. En este caso, la semilla se debe ajustar fuera del main; en caso contrario, obtendríamos los mismos resultados para las 20 iteraciones del algoritmo. Utilizando el script anterior, se debe ejecutar el algoritmo genético para una población de 10, 30 y 50 individuos. Se destaca que, al igual que en casos anteriores, el algoritmo genético se ejecuta dentro de la función main; por lo tanto, al estar dentro de una función, permite que se ejecute varias veces de una manera sencilla. En capítulos posteriores, veremos cómo podemos pasar a la función main argumentos de configuración del algoritmo como, por ejemplo, las probabilidades de mutación y cruce. La Tabla 1.4 muestra los resultados obtenidos para los tres casos. Aunque el valor máximo en los tres casos es parecido, en media se puede ver que los resultados del algoritmo con una población de 30 o 50 individuos son significativamente mejores. Por otro lado, los resultados entre 30 y 50 individuos no son muy diferentes con respecto al valor medio. En vista de los resultados, parece lógico pensar que aumentar más la población no nos aporta mucho, mientras que sí estamos aumentando la carga computacional del algoritmo. En la Tabla 1.4 podemos observar que, para una población de 50 individuos y 20 generaciones, el número de veces que debemos evaluar la función objetivo es 1000. En este ejemplo sencillo, la función de evaluación se ejecuta rápidamente, por lo que este aspecto no es importante; pero cuando tengamos funciones objetivo mucho más complejas, el número de evaluaciones sí será importante. Tabla 1.4. Comparación de resultados para distintos tamaños de población. No Individuos Valor medio Máximo No Evaluaciones 10 30 50 132.8 139.0 145.9 140.5 141.2 141.3 200 600 1000 Aunque en esta sección se ha estudiado el impacto del tamaño de la población, no se ha abordado el análisis de otros parámetros importantes como son las probabilidades de cruce y mutación. El estudio de dichos parámetros se deja para los siguientes capítulos, donde se abordarán problemas más complejos. 1.8 Convergencia del algoritmo El siguiente paso que vamos a dar es comprobar la convergencia del algoritmo. Para ello, vamos a utilizar un objeto de tipo Logbook47, el cual nos permite almacenar todos los datos de evolución del algoritmo genético en un registro. Hay que recordar que en la función main se declaró el objeto logbook de la siguiente forma: Como se indicó anteriormente, el objeto logbook guarda un registro de la evolución del algoritmo genético48. La información se almacena mediante diccionarios de Python. La clase Logbook tiene diversos métodos, entre los que destacan los siguientes: ■record : Generar una nueva entrada en el registro. Los registros tienen asociados un nombre o key como los diccionarios de Python . Normalmente, como keys se utilizan los alias de las funciones registradas en el objeto stats . ■select : Permite obtener la información asociada a una key . El siguiente script muestra la función que nos permite representar la evolución del algoritmo genético. Hay que destacar que se ha utilizado el módulo matplotlib.pyplot para generar dicha gráfica. La función plot_converge recibe como parámetro de entrada el objeto logbook, que se actualiza en cada generación en el algoritmo genético eaSimple. El objeto logbook permite mantener un registro de las métricas calculadas en cada generación. Para obtener los datos del registro se debe utilizar el método select, pasando como parámetro de entrada, el alias utilizado para registrar las funciones en el objeto stats. La función plot_evolucion se debe ejecutar una vez termine el algoritmo genético, como se expone a continuación: La Figura 1.7 muestra la evolución del algoritmo genético representando tres curvas: en azul se muestra el valor mínimo de la función objetivo en cada generación, en color negro con línea discontinua se muestra el valor medio en cada generación y, por último, en rojo se muestra el valor máximo en cada generación. Se puede observar que el algoritmo converge a partir de la generación número 10. Al ser un problema sumamente sencillo, prácticamente desde la primera generación aparecen soluciones cercanas al mejor individuo. No obstante, se puede observar que el valor medio de la generación va en aumento durante las primeras generaciones. Esto es así hasta que la mayoría de los individuos de la población son parecidos, por lo que el valor medio de la generación se estabiliza. Los picos -tanto en el valor mínimo como en el valor medio- se deben a que, a causa de las operaciones genéticas, pueden aparecer individuos que no se ajusten bien a la función objetivo, por lo que generen un fitness muy bajo que afectará tanto a la media como al valor mínimo. Debemos recordar que se ha aplicado la pena de muerte, por lo que se penaliza con un fitness de -1 a aquellos individuos que no cumplen las restricciones. Figura 1.7. Evolución del algoritmo genético. En general, a lo largo del libro se va a utilizar la pena de muerte en aquellos casos en los que no se cumplan las restricciones del problema. No obstante, la librería deap permite realizar penalizaciones menos drásticas que la pena de muerte49. 1.9 Exploración versus explotación en algoritmos genéticos Esta sección aborda un aspecto de tremendo debate en el ámbito de la optimización metaheurística como es el dilema «exploración versus explotación». Imaginemos un problema de optimización de una sola dimensión, tal y como se muestra en la Figura 1.8, en el que queremos maximizar y = f (x). Por un lado, la exploración del algoritmo debe permitir que todas las zonas del espacio de búsqueda de f (x) sean exploradas. Si alguna zona no es explorada, podemos perder la oportunidad de alcanzar el óptimo global, y quedarnos en un óptimo local o relativo. Por ejemplo, en la Figura 1.8 podríamos quedarnos en un máximo relativo y no alcanzar nunca el máximo absoluto. Por otro lado, el mecanismo de explotación de un algoritmo debe permitir intensificar ciertas zonas del espacio de búsqueda. Volviendo a la Figura 1.8, cuando el algoritmo genético es capaz de encontrar una solución cerca del máximo absoluto, debe tener la capacidad de seguir intensificando la búsqueda por dicha zona hasta alcanzar dicho máximo. Una vez que una solución encuentra una montaña en la Figura 1.8, esta debe ser capaz de generar nuevas soluciones que suban por la ladera de la montaña hasta alcanzar el pico (máximo absoluto); así podría verse el proceso de intensificación. Figura 1.8. Exploración versus explotación. La cuestión ahora es saber qué mecanismo u operadores genético (selección, cruce y/o mutación) favorece la exploración y cuál la explotación. La respuesta a esa cuestión no está clara y es un tema de discusión en la literatura especializada. Es evidente que la combinación de selección, cruce y mutación es lo que permite que el algoritmo genético explore y explote distintas zonas del espacio de búsqueda. Aunque la aportación de cada operación genética es un tema abierto a debate, permitan que aportemos nuestro punto de vista al respecto. Durante las primeras generaciones, en las que los individuos de la población son distintos (siempre y cuando se garantice una buena diversidad en la población inicial), la operación de cruce permite explorar -mediante el intercambio de información genética entre individuos- la zona de confinamiento que determinan los valores máximos y mínimos de cada una de las variables de los individuos (ver Figura 1.5). En cuanto a la mutación, permite explotar ciertas regiones del espacio de búsqueda próximas a los individuales actuales. Es decir, al principio la zona de confinamiento es grande, por lo que hay mucho que explorar dentro de ella. Por el contrario, cuando avanzamos en las generaciones, los individuos son cada vez más parecidos, ya que tienden a converger, estrechando la zona de confinamiento. Así, la mutación es el único procedimiento que nos permite obtener nuevas soluciones fuera de la información que ya tienen los individuos. En este punto, la mutación es el único mecanismo para explorar nuevas zonas y, a su vez, explotar las soluciones encontradas. En cuanto a la selección, las principales técnicas de selección (torneo y ruleta) son técnicas elitistas y, en consecuencia, promueven la explotación de buenos individuos. Por último, debemos tener en cuenta que, debido a la naturaleza estocástica del algoritmo genético, una buena solución podría perderse a lo largo de la evolución del mismo. De todas formas, más adelante veremos que el módulo deap implementa mecanismos para que esto no suceda. 1.10 Código completo y lecciones aprendidas Para finalizar esta sección introductoria, el Código 1.3 contiene todas las líneas necesarias para implementar un algoritmo genético sencillo que nos permita resolver el problema planteado en este capítulo, es decir, resolver la ecuación bajo las restricciones {x,y} [–100,100]. Como resumen del código: ■Las líneas 1-8 incluyen los módulos y submódulos necesarios para implementar el algoritmo genético con deap , librerías básicas de Python como random y math , y librerías científicas de Python como numpy y matplotlib . ■La línea 11 define la clase FitnessMax de los individuos, que establece el tipo de problema (maximización) y el número de objetivos del problema (uno en este caso). ■La línea 12 define la clase del individuo, que hereda de una lista, y crea un atributo en dicha clase para el fitness del tipo FitnessMax , justo el que se ha creado en la línea 11. Estamos creando así la plantilla de los individuos. ■La definición de la función objetivo funcion_objetivo) se presenta en las líneas 14-22. La función debe recibir un individuo de entrada (se ha denominado x en estado caso) y debe devolver la calidad del individuo return res . Debemos recordar que siempre hay que devolver una tupla porque el problema con un único objetivo en deap es un caso particular del problema con múltiples objetivos. ■La línea 24 crea el objeto caja de herramientas ( toolbox ) donde se registrarán las funciones para generar los genes o variables (línea 27) de los individuos aleatorios (línea 30), la población inicial (línea 32), la función objetivo (línea 36) y los operadores genéticos (líneas 37-40). Es importante volver a destacar que el último parámetro de la línea 30 representa el número variables del problema (2 en este caso) y que el último parámetro de la línea 32 define el número de individuos de la población. ■La operación de cruce que se realiza es un cruce de un punto (línea 37). En cuanto a la mutación, se realiza una mutación Gaussiana de media ( mu ) 0, y desviación típica ( sigma ) 5. La probabilidad de mutar cada uno de los genes es de 0.1 (parámetro indpb en la línea 38). ■La selección de los individuos es mediante torneo (línea 40). El tamaño del torneo es de 3. ■No hay que olvidar que los nombres con los que se registran las operaciones genéticas y la función objetivo se deben respetar ( selection , mate , mutation y evaluate ). ■La función plot_convergencia nos permite visualizar la evolución de los individuos (líneas 42-62). A su vez, nos permite almacenar una gráfica que nos muestra el mejor individuo en cada generación, el peor y el valor medio. Además, la figura se guarda en el directorio de trabajo. ■La función main() ejecuta el algoritmo genético. En primer lugar, se ajusta la semilla del generador de números aleatorios (línea 65). Seguidamente, en la línea 66, se definen las probabilidades de cruce CXPB , mutación MUTPB , y el número de generaciones del algoritmo NGEN . En la siguiente sentencia, se define la población inicial (línea 67). A continuación, se define el objeto hof que contendrá el mejor individuo a lo largo de la evolución (línea 68). El objeto estadística ( stats ) se define en la línea 69 y, seguidamente, se registran las funciones estadísticas que se aplicarán (líneas 70-73). El registro de evolución se define en la línea 74. Finalmente, el algoritmo se inicia en la línea 75, mediante la llamada del método eaSimple con todos los parámetros que necesita el algoritmo genético. Una vez finalizado, se devuelven el mejor individuo y el registro de evolución (línea 78). ■En la línea 81 se llama a la función main() y, a continuación, se imprime el fitness del mejor individuo junto al individuo en sí (líneas 82 y 83). Por último, en la línea 84, se llama a la función plot_evolution , a la que se le debe pasar como parámetro de entrada el registro log , y que permite visualizar y almacenar la convergencia y evolución del algoritmo. Código 1.3. Código completo: Problema sencillo con variables continuas. En cuanto a las lecciones aprendidas en este primer capítulo introductorio, son muchas y nos acompañarán a los largo del resto del libro. Por lo tanto, no debemos olvidar los siguientes aspectos: ■Los algoritmos genéticos son algoritmos estocásticos, es decir, están basados en operaciones genéticas probabilísticas: selección, cruce y mutación. El funcionamiento del algoritmo depende de las probabilidades de cruce y mutación (hiperparámetros). La selección también tiene una componente estocástica, ya que los individuos que participan en el torneo se seleccionan de manera aleatoria. ■Los algoritmos genéticos son elitistas, de forma que un individuo que tiene un fitness alto tendrá más posibilidades de participar en las operaciones genéticas. Por ejemplo, en el caso de la selección mediante torneo, un individuo con fitness alto tendrá más posibilidades de ganar torneos. ■Los algoritmos genéticos no garantizan encontrar el óptimo absoluto, pero sí podemos obtener soluciones que satisfagan nuestros requisitos en un tiempo moderado. ■Los algoritmos genéticos son computacionalmente intensivos, ya que se basan en evaluar la función objetivo muchas veces. Si definimos N como el número de generaciones, P como el número de individuos de la población y T como el tiempo que tarda en ejecutarse la función objetivo, el tiempo total para obtener la solución al problema es T t = N × P × T . Si, por ejemplo, en el ejercicio anterior consideramos una población de 100 individuos, con 100 generaciones y cuya función objetivo tarda 1 segundo en ejecutarse, tendremos un T t = 10 , 000 s . Es decir, más de 2 horas y 42 minutos 50. Si suponemos que la función objetivo tarda 5 veces más, tendremos que esperar más de 10 horas para tener el resultado. Es por ello, que siempre debemos estimar el tiempo que tardará el algoritmo en ejecutarse completamente. Es importante indicar que existen mecanismos para evaluar los individuos de una población en paralelo (consultar Apéndice B). ■Si queremos obtener resultados consistentes, debemos evaluar el funcionamiento genético utilizando diferentes configuraciones en términos de tamaño de población y probabilidades de los operadores genéticos. En cuanto al tamaño de la población, es buena idea empezar por un orden de magnitud mayor al número de variables que tiene nuestro problema. No obstante, si la función de evaluación se evalúa en poco tiempo, se puede y se debe probar con poblaciones mayores. En cuanto a las probabilidades de los operadores genéticos, se debe hacer un barrido de probabilidades hasta encontrar la mejor configuración. Dicho barrido se debe realizar ejecutando varias veces el algoritmo genético para una misma configuración y obteniendo métricas estadísticas de los resultados obtenidos. Por último, se debe comprobar la evolución y convergencia del algoritmo para visualizar que el algoritmo está evolucionando de manera correcta y que converge en un valor. 1.11 Para seguir aprendiendo Para aquellos que deseen seguir profundizando en los temas relacionados con este primer capítulo, se recomiendan las siguientes referencias: ■Uno de los trabajos pioneros del creador de la computación evolutiva John Holland es (Holland et al., 1992). ■Además, se recomiendan los trabajos de David E. Goldberg (realizó la tesis doctoral con John Holland), entre ellos (Goldberg, 2006) (Zames et al., 1981). ■Más información sobre algoritmos genéticos y técnicas metaheurísticas de una forma práctica, se puede encontrar en (Smith, 2012). ■En (Ser et al., 2019) se encuentra una de las referencias más actualizadas y completas sobre algoritmos de optimización bioinspirados. Este artículo presenta una amplia fotográfica del espectro de técnicas que han surgido en los últimos años. Además, para cada tema los autores aportan una gran bibliografía. ■Para más información sobre el dilema «exploración versus explotación» en algoritmos evolutivos se recomienda la siguiente referencia: (Črepinšek et al., 2013). Como ejercicios, se plantean los siguientes: ■La función de Rastrigin 51 se define de la siguiente forma: con A = 10 y xi [–5,12,5,12]. Implemente dicha función en Python y minimícela para distintos valores de n. El mínimo global de la función se encuentra en x* = (0,...,0) y f (x*) = 0. ■Para la función anterior con n = 10, compare el funcionamiento del algoritmo genético para distintos métodos de cruce; por ejemplo, cruce de un punto, de dos puntos y uniforme. Realice un barrido para distintas probabilidades de cruce y mutación, y compare el valor mínimo, medio y la desviación típica. ■En el Apéndice B del libro se aborda un método para medir el tiempo que tarda el algoritmo genético en ejecutarse utilizando la librería time . Haga las modificaciones oportunas en el ejercicio anterior para medir el tiempo que tarda en ejecutarse el algoritmo. ■Busque información en Internet sobre la función de Bohachevsky . Impleméntela en Python y obtenga el mínimo global mediante un algoritmo genético. _________________ 1Los tres términos hacen referencia a la función que determina la calidad o desempeño de una solución. 2A lo largo del libro se utilizará el anglicismo fitness para hacer referencia a la calidad o desempeño de una solución. 3En la mayoría de los textos técnicos escritos sobre optimización se suele utilizar el problema de minimización como norma. 4https://deap.readthedocs.io/en/master/ 5En la sección 2.10 hablaremos más sobre la complejidad de los problemas. 6https://docs.python.org/3/library/random.html 7https://docs.scipy.org/doc/numpy-1.16.1/reference/routines.random.html 8https://docs.python.org/3.6/library/random.html 9La traducción sería «comprensión de lista» pero suena tan mal que preferimos dejarlo en inglés. 10https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html#scipy.op 11La solución que nos daría cualquier algoritmo basado en gradiente dependería del valor inicial que se le pasara al algoritmo. 12Para problemas de minimización se suele utilizar FitnessMin. 13https://deap.readthedocs.io/en/master/api/base.html#deap.base.Fitness 14https://docs.python.org/3.6/tutorial/classes.html 15Este código equivalente no se utiliza en el problema; solo se define para ilustrar el funcionamiento de creator.create. 16https://docs.python.org/3/tutorial/datastructures.html 17Este código equivalente no se utiliza en el problema; solo se define para ilustrar el funcionamiento de creator.create. 18https://deap.readthedocs.io/en/master/api/base.html#toolbox 19https://docs.python.org/3/library/functools.html 20Este código equivalente no se utiliza en el problema; solo se define para ilustrar el funcionamiento de toolbox.register. 21En cada caso el resultado puede ser distinto. 22Lo mismo se puede hacer para el resto de atributos de individual. 23El resultado puede variar en cada caso. 24https://docs.python.org/3.6/library/math.html 25https://www.scipy.org/scipylib/index.html 26https://docs.python.org/3/library/os.html 27https://docs.python.org/3.6/library/subprocess.html 28https://docs.python.org/3/library/math.html 29También podemos definir nosotros nuevos operadores. Pero este procedimiento es más complejo y se verá más adelante. 30Se recomienda consultar la documentación de manera periódica, ya que nuevas versiones del módulo suelen añadir nuevas funcionalidades. 31https://deap.readthedocs.io/en/master/api/tools.html?highlight=tools 32Este término se suele utilizar en la literatura para hacer referencia a que se utiliza el algoritmo de optimización como una caja negra, sin saber realmente cómo funciona por dentro. 33Esto es así según el operador de cruce de un punto que hemos definido. Existen algunos operadores de cruce que permiten que nos salgamos de la zona de confinamiento. 34El orden de pasos que se da en la función main puede variar, ya que hay pasos que son intercambiables. 35De hecho, con unos valores adecuados el algoritmo convergería muy rápido. Es por ello, que se han elegido esos valores para poder ver cierta evolución. 36https://deap.readthedocs.io/en/master/api/tools.html#hall-of-fame 37https://docs.python.org/3/library/operator.html#operator.eq 38https://deap.readthedocs.io/en/master/api/tools.html#statistics 39https://docs.scipy.org/doc/numpy/reference/generated/numpy.mean.html 40https://docs.scipy.org/doc/numpy/reference/generated/numpy.std.html? highlight=numpy%20std#numpy.std 41https://docs.scipy.org/doc/numpy/reference/generated/numpy.ndarray.min.html? highlight=numpy%20min#numpy.ndarray.min 42https://docs.scipy.org/doc/numpy/reference/generated/numpy.ndarray.max.html 43https://deap.readthedocs.io/en/master/tutorials/basic/part3.html 44https://deap.readthedocs.io/en/master/api/algo.html 45Para aquellos que no estén familiarizados con la sentencia if __name__ == "__main__": se recomienda consultar el siguiente enlace: https://stackoverflow.com/questions/419163/what-does-if-name-main-do 46Es ineficiente sí, pero no podemos hacer nada al respecto. 47https://deap.readthedocs.io/en/master/api/tools.html#logbook 48https://deap.readthedocs.io/en/master/api/tools.html#logbook 49https://deap.readthedocs.io/en/master/tutorials/advanced/constraints.html 50Sin tener en cuenta el tiempo que tarda el algoritmo genético en hacer otras operaciones, como las operaciones genéticas, por lo que el tiempo real será mayor. 51https://en.wikipedia.org/wiki/Rastrigin_function 2.1 Introducción al problema del viajero El problema del agente viajero, problema del viajero o Travelling Salesman Problem (TSP) es uno de los problemas de optimización combinatorios clásicos más populares con variables discretas que se pueden encontrar en la literatura. El problema consiste en encontrar el tour óptimo para un vendedor que visite un conjunto de ciudades, partiendo de una ciudad y finalizando en la misma ciudad (realmente si no acaba en la misma ciudad es igualmente complicado), recorriendo la distancia mínima y sin visitar ninguna ciudad más de una vez. Es decir, estamos ante un problema de minimización. Aunque el enunciado del problema es sencillo, a día de hoy no existe un algoritmo determinista que resuelva el problema de manera óptima para cualquier número de ciudades en un tiempo razonable1. El problema tiene variables discretas, ya que el conjunto de ciudades es un conjunto discreto. Por otro lado, es obvio que el problema se complica con el número de ciudades. De hecho, el número de posibles soluciones al problema es (N – 1)!/2, considerando que no importa la ciudad de origen entre todas las ciudades y que la distancia entre ciudades es simétrica. Es decir, las ciudades están unidas por una sola carretera de dos sentidos. En cuanto al número de soluciones, para diez ciudades tenemos 181,440 soluciones y para veinte ciudades tenemos 6.082e + 16. Si consideramos que en evaluar cada solución tardamos un 1 segundo, ¿cuánto tiempo tardaríamos en evaluar todas las posibles soluciones? Pues en el caso de diez ciudades tardaríamos 50.4 horas, y en el caso de veinte ciudades tardaríamos casi dos mil millones de años. Es evidente que con el paso del tiempo tendremos ordenadores más potentes2, pero difícilmente se puede abordar un problema como este intentando probar todas las posibles soluciones (fuerza bruta). Este tipo de problemas se denominan problemas intratables, debido a su complejidad. Por otro lado, también cabe indicar que para un número pequeño de ciudades existen algoritmos deterministas y eficientes que consiguen resolver el problema de manera óptima. Desde el punto de vista del ámbito de la ingeniería, el problema del viajero tiene aplicación en problemas de transporte, logística y planificación de rutas de vehículos autónomos, entre otras aplicaciones. Veamos cómo podemos plantear el problema del viajero mediante teoría de grafos. La Figura 2.1 muestra un grafo formado por cuatro ciudades: A, B, C y D. En el grafo, cada uno de los vértices se corresponde con una ciudad y las aristas del grafo son las conexiones entre las ciudades. En la definición clásica, todas las ciudades están conectadas con el resto. Es por ello que siempre va a existir una arista entre dos ciudades cualesquiera. El objetivo del viajero es recorrer todas las ciudades partiendo de una de ellas. Por ejemplo, una solución al problema del viajero con respecto al grafo de la Figura 2.1 sería el tour formado por [A,B,C,D]. Esa solución implica que el viajero parte del nodo A, y después visita los nodos B, C y D, respectivamente. Otra posible solución sería el tour formado por [A,D,C,B], y así podríamos seguir indicando distintas soluciones. Ya se puede apreciar que las soluciones del problema del viajero vienen dadas por una lista de ciudades que el viajero visita en dicho orden. Una característica importante de este problema es que no puede haber ciudades repetidas en la solución, y eso, como veremos más adelante, determina las posibles operaciones genéticas que podamos realizar sobre los individuos. Es decir, no podemos intercambiar la información genética de dos individuos de cualquier forma. Desde el punto de vista de la teoría de grafos, el problema que se quiere resolver es similar a encontrar el ciclo Hamiltoniano del grafo compuesto por las ciudades. Un ciclo Hamiltoniano es aquel que solo pasa una vez por cada uno de los vértices del grafo. Sin embargo, el problema del viajero es aun más complejo, porque se quiere encontrar el ciclo Hamiltoniano de distancia mínima. Figura 2.1. Grafo ejemplo del problema del viajero con cuatro ciudades. Una vez definido el problema del viajero, lo siguiente que necesitamos es una matriz de distancias que nos permita determinar la distancia entre cualquier par de ciudades. Esta matriz será simétrica ya que, como hemos dicho, la distancia entre A y B será la misma que entre B y A. Asimismo, la diagonal de dicha matriz estará compuesta por ceros, ya que la distancia de una ciudad con respecto a ella misma es nula. A partir de la matriz de distancia y la ruta, podemos calcular la distancia que recorre el viajero. Así, por ejemplo, para la solución [A,D,C,B], la distancia que recorre el viajero es d = dAD + dDC + dCB + dBA. Estamos, por tanto, en posición de definir formalmente el problema: Observe que a partir del valor de las variables xij es posible conocer la ruta que ha seguido el Una vez descrito el problema, la manera de representar las soluciones y el cálculo de la distancia que recorre el viajero, esto es, la calidad del individuo, ya podemos ponernos manos a la obra con el código en deap. 2.2 Definición del problema y generación de la población inicial El problema que vamos a resolver es el problema del viajero para el caso de 17 ciudades. El siguiente script muestra información relevante sobre el problema que queremos resolver. Se trata de un archivo JSON que contiene: ■Toursize: Representa el número de ciudades del problema. ■OptTour: Solución óptima al problema. ■OptDistance: Distancia óptima que recorre el viajero para el tour óptimo. ■DistanceMatrix: Matriz de distancia. La ventaja de trabajar con archivos JSON es que se pueden cargar fácilmente como un diccionario en Python. Aunque para este conjunto de ciudades conocemos el tour óptimo, esto no es lo común, pero en este caso nos sirve como referencia. Es decir, estamos haciendo “un poco de trampa”, ya que sabemos el óptimo del problema. El siguiente script incluye los primeros pasos que necesitamos para resolver el problema del viajero mediante un algoritmo genético. A continuación, pasaremos a describir las distintas líneas de código, centrándonos sobre todo en aquellas que son nuevas o que son modificaciones con respecto al Capítulo 1. En primer lugar, se importan los módulos que van a ser necesarios: A continuación, se carga el fichero JSON: A partir de este punto, los datos del archivo están en el diccionario tsp. El objeto distance_map almacena la matriz de distancias, que se utilizará en la función objetivo. Además, el objeto IND_SIZE se usa para almacenar el número de ciudades del problema, que se utilizará para definir la longitud del individuo. En este caso será IND_SIZE = 17, ya que estamos resolviendo el problema para 17 ciudades: 2.2.1 Creación del problema y plantilla para el individuo A continuación, debemos definir el tipo de problema tal y como lo hicimos en el Capítulo 1. Para ello, se crea la clase FitnessMin, que hereda de la clase base.Fitness y que tiene el atributo weights = –1, por lo que estamos definiendo un problema de minimización. Se puede comprobar que el procedimiento es análogo al realizado en el capítulo anterior, con la diferencia de que en este caso queremos minimizar en vez de maximizar. La clase Individual se utilizará para almacenar el individuo. Al heredar de una lista, tendrá todos los métodos de este tipo de clase. A su vez, se crea el atributo fitness que almacenará la calidad del individuo. Al igual que en el problema del Capítulo 1, se está utilizando una lista para representar los individuos. Si usamos Si para indicar la i-ésima ciudad visitada por el viajero, nuestros individuos tendrán la estructura representada en la Figura 2.2. Figura 2.2. Representación de un individuo para el problema del viajero con 17 ciudades. 2.2.2 Crear individuos aleatorios y población inicial El siguiente paso es crear el objeto caja de herramientas, donde se registrarán todas las funciones necesarias para el algoritmo genético. Para generar tours aleatorios nos apoyaremos en la función indices. Dicha función nos permite generar una muestra de números aleatorios sin repetir entre 0 y el número de ciudades que tiene nuestro problema menos uno3 (IND_SIZE): Es decir, la función indices genera una lista que representa un individuo aleatorio. Esta función realmente está llamando al método random.sample, que nos permite generar una muestra aleatoria, sin repetición, de un conjunto de datos4. El Resultado 2.1 muestra el resultado de ejecutar random.sample para un conjunto de 17 valores comprendidos en el intervalo [0,17). Por lo tanto, ese es el mismo resultado que el que provocará el método indices, tal y como se puede observar en el Resultado 2.2. Resultado 2.1. Resultado random.sample. Resultado 2.2. Resultado toolbox.indices. Es importante destacar que en este problema, a diferencia del anterior, estamos registrando una función que nos proporciona un individuo aleatorio completo. Este hecho tiene algunas consecuencias a la hora de registrar la función individual en el objeto toolbox. En esta ocasión, se utiliza el método tools.initIterate en vez de utilizar el método tools.initRepeat empleado anteriormente. Como norma general, cuando tengamos una función que genere un gen o variable del individu Para entender mejor la diferencia entre tools.initRepeat y tools.initIterate podemos consultar la documenación de deap5. En primer lugar, debemos indicar que el método tools.initRepeat recibe los siguientes parámetros: ■container : El tipo de dato al que se convertirá el resultado de la función func . ■func : Función a la que se llamará n veces. ■n : Número de veces que se llamará a la función func . Al igual que en el ejemplo del Capítulo 1, el container que utilizaremos será creator.Individual. Sin embargo, los parámetros serán diferentes. Si recordamos, la func era la función que generaba cada uno de los genes (random.uniform(-100,100), la cual dependía del problema) y n era el número de genes (2 en el ejercicio del Capítulo 1). El valor de n puede ser distinto para cada problema, ya que dependerá del número de variables independientes. En el caso de tools.initIterate6, los parámetros de entrada son: ■container : El tipo de dato al que se convertirá el resultado de la función f unc . ■generator : Una función que devuelve un iterable (listas, tuplas, etc.). El contenido de este iterable rellenará el container . Para el problema del viajero, el container es el objeto creator.Individual y el generador es toolbox.indices que, como se ha dicho anteriormente, devuelve un objeto iterador. Ese iterador no es más que una muestra de ciudades. En resumen, la diferencia entre tools.initRepeat y tools.initIterate es que tools.initIterate llama en el segundo parámetro a una función que devuelve un objeto iterable que contiene una muestra completa del cromosoma del individuo, que se convertirá en individuo. Por otro lado, en tools.initRepeat la función func devuelve un solo gen del cromosoma, por lo que para completar el individuo completo se debe llamar n veces, siendo n el tamaño del individuo. Con respecto a la generación de la población inicial, siempre se utilizará el método tools.initRepeat: En tools.initRepeat, el container siempre será una lista, ya que la población siempre será una lista de individuos. La función que se llamará es justo la que se ha registrado en la anterior línea toolbox.individual, la cual genera un individuo aleatorio. El último parámetro, n, será el número de individuos que forman la población inicial. En este caso, la población inicial está compuesta de 100 individuos. 2.3 Función objetivo y operadores genéticos A continuación, definimos la función objetivo de nuestro problema. El siguiente script muestra el fragmento de código necesario para definir la función objetivo del problema y el registro de los operadores genéticos que se utilizarán: A continuación, se describirán por separado la función objetivo y cada uno de los operadores genéticos. 2.3.1 Función objetivo En primer lugar, la siguiente línea obtiene la distancia entre la primera y la ultima ciudad que visita el viajero. Se debe recordar que en Python el último elemento de la secuencia se obtiene con el índice –1. A continuación, el bucle calcula el resto de distancias. Para ello, mediante la función nativa de Python zip se obtiene una secuencia de tuplas compuestas por los elementos del primero al penúltimo y del segundo al último. Así, cada tupla de la secuencia contendrá dos ciudades consecutivas en el tour: Por último, cabe recordar de nuevo que se debe devolver una tupla como resultado, ya que en deap los problemas con un solo objetivo son un caso particular de los problemas multiobjetivo: 2.3.2 Operadores genéticos En primer lugar, se registra la operación de cruce ordenado tools.cxOrdered: En el problema del viajero no se puede utilizar cualquier tipo de operador de cruce, ya que podrían darse soluciones no válidas. Por ejemplo, si utilizamos el cruce de un punto (o de dos puntos) que vimos en el Capítulo 1, un mismo individuo podría tener una misma ciudad repetida. Aunque podríamos invalidar dichos individuos penalizándolos en la función objetivo (pena de muerte), siempre es preferible utilizar operadores genéticos que no produzcan individuos inválidos. En el operador de cruce elegido, es importante observar que el segundo punto aleatorio determina la posición por la que se continúa completando a la descendencia. En el caso de la izquierda, el descendiente debería haberse rellenado con la ciudad D, que es la que corresponde a dicha posición en el otro progenitor. Sin embargo, como dicha información ya la incluye el descendiente, se debe saltar dicho gen y continuar con la siguiente posición. Como regla, el orden se determina de izquierda a derecha pero, en este caso, como es la última posición del progenitor, se debe continuar de manera circular por la primera posición de este. Es por ello que el descendiente se rellena con la ciudad B. Para rellenar la siguiente posición del descendiente, debemos proceder igual. Así, debido a que la B ocupa la última posición, debemos continuar con la primera, rellenando con genes del padre en el mismo punto en que lo dejamos en la última operación. Por lo tanto, el descendiente se rellena con la ciudad A. Ya solo nos queda una última ciudad que, siguiendo con el procedimiento, corresponde a la ciudad E. En el caso de la derecha, el procedimiento es el mismo. En este caso, se empieza con la ciudad B, ya que la E no se puede incluir porque ya está en el progenitor; lo mismo ocurre con la A, por lo que se incluye la B. Continuamos por la D y, finalmente, se termina de rellenar el descendiente con la ciudad F. En la librería deap existe otro algoritmo de cruce que también se puede utilizar en este problema, que se denomina cxPartialyMatched 7, el cual se describirá más adelante en este capítulo. En cuanto a la mutación, en este problema utilizaremos el operador tools.mutShuffleIndexes para intercambiar dos ciudades: Podemos ver que hemos elegido un valor bajo de la probabilidad de mutación del gen (indpb = 0.05), o lo que es lo mismo, una probabilidad del 5% de intercambiar un gen por otro dentro del cromosoma. El valor de indpb se elige normalmente bajo, ya que de otra forma los individuos resultantes serían muy distintos a los individuos padres. Otra forma de ajustar dicho parámetro es hacerlo inversamente proporcional a la longitud del cromosoma. Es decir, cuanto mayor sea el número de variables del problema menor será dicha probabilidad. Es importante indicar que si hacemos muy grande el valor de indpb podremos tener problemas de convergencia, ya que estamos tendiendo a realizar una búsqueda aleatoria. Finalmente, el registro de la función de evaluación y el mecanismo de selección se realizan como en el ejemplo del capítulo anterior. Para la selección, se utiliza de nuevo la técnica de torneo con tamaño tres: 2.4 Selección del algoritmo genético En este problema se va a utilizar otra popular implementación de algoritmo genético denominado algoritmo mupluslambda (µ + λ), diferente del eaSimple que hemos estudiado en el Capítulo 1. Este algoritmo es más elitista y también requiere más tiempo de computación. Sin embargo, también arroja mejores resultados en una gran cantidad de posibles escenarios. Existe una tercera implementación de algoritmo genético en la librería deap: el algoritmo µ,λ, accesible a través del submódulo algorithms mediante algorithm.eaMuCommaLambda. En esta implementación, λ individuos son creados a partir de los µ padres, a través de los operadores genéticos registrados. Entre los λ descendientes se realiza el proceso de selección de µ individuos para formar la siguiente generación. Por lo tanto, se puede considerar una versión elitista de los descendientes del algoritmo eaSimple. En este libro no se utilizará esta implementación, pero se puede encontrar más información en la documentación oficial8. Por último, la librería también permite crear nuevas implementaciones de algoritmos genéticos desde cero, aunque nosotros no abordaremos este tema9. 2.5 Últimos pasos El siguiente script muestra el código necesario para lanzar el algoritmo genético. Como se ha explicado, en este caso se utilizará un algoritmo distinto al eaSimple estudiado en el Capítulo 1, el algoritmo µ + λ, cuya configuración es un poco diferente. A continuación, se describen los aspectos más importantes del código presentado. 2.5.1 Configuración del algoritmo genético µ + λ En primer lugar, se ajusta la semilla del generador de números aleatorios: A continuación, se definen las probabilidades de cruce y mutación, así como el número de generaciones. Se ha definido una probabilidad de cruce de 0.7, una probabilidad de mutación de 0.3 y 120 como el número de generaciones que correrá el algoritmo. Como pasó en el Capítulo 1, esa configuración puede no ser la óptima, por lo que tendremos que ajustarla: Después, se pasa a crear la población inicial. El tamaño de la población se definió al registrar la función population en el toolbox. Se consideró una población de 100 individuos. El siguiente paso es asignar valores a los parámetros µ y λ del algoritmo genético: Con dichos valores, se ha considerado una población extendida de cuyo tamaño es el doble de la población, ya que µ = 100 y λ = 100. Así, se crean 100 descendientes y se seleccionan entre la población extendida los µ individuos que pasarán a la siguiente generación. La siguiente generación está compuesta de λ individuos seleccionados. Por lo tanto, el tamaño de la población no varía a lo largo de las generaciones. Hay que tener en cuenta que, tanto para la creación de la descendencia como la creación de la siguiente generación, se ha utilizado la selección mediante torneo de tamaño tres, tal y como se registró en el objeto toolbox. El resto de parámetros de configuración han sido ya descritos en el Capítulo 1, por lo que solo los comentaremos rápidamente. Definimos el objeto hof para almacenar el mejor individuo a lo largo de las generaciones del algoritmo: Creamos el objeto para generar las estadísticas de evolución y registro de las funciones para calcular las métricas estadísticas: Declaramos el registro de evolución log: A continuación, se lanza el algoritmo genético µ + λ: Cuando finaliza la ejecución del algoritmo genético, este devuelve la población final y el registro de evolución del algoritmo. En la Figura 2.3 se muestra la evolución del algoritmo. Se puede observar que la convergencia es buena, ya que a partir de la generación 40 no se observa ninguna mejora. En las primeras generaciones se pueden apreciar las diferencias entre el valor máximo y mínimo del fitness de la población. Conforme avanzan las generaciones, los individuos de la población tienden a ser parecidos; es por ello que el fitness tiende a converger. Los picos que podemos observar en generaciones más tardías (60, 80 y 100) se deben a que en algunas generaciones las operaciones genéticas pueden dar lugar a individuos peores, afectando notablemente a los resultados máximo y medio de la población. Una buena convergencia del algoritmo no implica un resultado óptimo. Un algoritmo genético Figura 2.3. Evolución TSP. Para ver con más detalle la evolución de las primeras generaciones del algoritmo, el Resultado 2.3 muestra los valores estadísticos de la función objetivo para las primeras 15 generaciones del algoritmo. Se puede observar cómo en cada generación el algoritmo va mejorando los resultados, ya que se van reduciendo tanto el valor mínimo como el medio y el máximo. Resultado 2.3. Primeras generaciones del algoritmo genético para el problema TSP. Por el contrario, en el Resultado 2.4 se puede observar cómo para las últimas 15 generaciones del algoritmo no podemos observar ninguna diferencia entre los valores mínimo, medio y máximo de la función objetivo. Estos resultados son una muestra clara de que el algoritmo ha convergido. Resultado 2.4. Últimas generaciones del algoritmo genético para el problema TSP. En cuanto al mejor individuo, el Resultado 2.5 muestra el mejor individuo del algoritmo. Podemos observar que el resultado final es muy cercano al óptimo del problema, que es 2085, tal y como se muestra en el archivo JSON. Se puede ver que la mejor solución parte de la ciudad 15, continuando con la 11 y siguiendo la secuencia hasta llegar a la ciudad 0. Resultado 2.5. Resultado algoritmo genético µ + λ para el problema TSP. 2.6 Comprobar la convergencia del algoritmo en problemas complejos Aunque en el Capítulo 1 se cubrió el procedimiento para representar la convergencia del algoritmo, en un problema complejo como el del viajero debemos pararnos de nuevo en este aspecto. En la sección anterior se ha utilizado NGEN = 120 y se han obtenido unos resultados satisfactorios. Ese número puede parecer un poco mágico... ¿Por qué no 100 o, incluso, un valor mucho más bajo, como 40? En principio, si no tenemos problemas con el tiempo que tarde en ejecutarse el algoritmo, debemos comenzar con un número elevado de generaciones. No obstante, la representación de la evolución del algoritmo nos puede dar una idea de si debemos aumentar el número de generaciones. Así, imaginemos que para el código utilizado en la sección anterior fijamos NGEN = 20. Entonces, la evolución del algoritmo sería la mostrada en la Figura 2.4. Se puede observar que el algoritmo no ha convergido, ya que no se puede apreciar un codo en la tendencia del valor mínimo. Por lo tanto, parece evidente que debemos elevar el número de generaciones del algoritmo. Figura 2.4. Evolución TSP para NGEN = 20. El caso contrario sería considerar NGEN = 500; los resultados se pueden ver en la Figura 2.5. En este caso, el problema está en que estamos desperdiciando mucho tiempo de computación, ya que la convergencia del algoritmo se da mucho antes de llegar a la generación 500. Se puede observar que el valor mínimo no ha variado prácticamente desde la generación 40-50. Por lo tanto, estamos malgastando 450 generaciones. Ese tiempo de computación lo podemos utilizar para lanzar muchas veces el algoritmo o para probar distintas configuraciones con el fin de asegurarnos de que el algoritmo no se ha quedado bloqueado en un mínimo local. Figura 2.5. Evolución TSP para NGEN = 500. Por último, cabe indicar que estas comprobaciones se deben realizar más de una vez, para evitar que la convergencia o no del algoritmo sea debida al azar. 2.7 Ajuste de los hiperparámetros: Probabilidades de cruce y mutación En esta sección vamos a analizar los resultados del algoritmo genético en función de los hiperparámetros básicos de un algoritmo genético, como son las probabilidades de cruce y mutación. El objetivo de esta sección es seleccionar la combinación de probabilidades que nos proporcione mejores resultados. El siguiente script muestra las modificaciones de código necesarias en la función main para ejecutar el algoritmo genético varias veces con distintas probabilidades de cruce y mutación. El resto del código es exactamente igual que en anteriores secciones, y es por ello por lo que no se incluye. Para poder analizar con mayor profundidad los resultados, estos se van a almacenar en dos archivos de texto. Se han utilizado dos archivos para no mezclar los valores de fitness de los individuos que almacenan los tours con las ciudades. Así, facilitamos el posterior análisis de los resultados. A continuación, se detallan los cambios más importantes respecto al ejemplo del primer capítulo. La función main ahora recibe dos parámetros, c y m, que son las probabilidades de cruce y mutación. Otro aspecto que debemos modificar es la posición del ajuste de la semilla de números aleatorios. En este caso, no se puede colocar dentro de la función main, debido a que obtendríamos los mismos resultados cada vez que ejecutáramos la función11. Por ello, el ajuste de la semilla se realiza desde fuera. El resto de la función main no cambia. Las listas prob_cruce y prob_mutación definen las secuencias de valores de probabilidad de cruce y mutación que se evaluarán. Esos valores cubren un espectro importante de valores de configuración del algoritmo genético. Probabilidades de cruce mayores de 0.8 son rara vez utilizadas, ya que se daría muy poco mar Para guardar los resultados se abren dos archivos en modo escritura. El archivo FitnessTSP.txt contendrá los resultados del mejor individuo obtenido en la evolución del algoritmo genético para los parámetros c y m (probabilidad de cruce y mutación, respectivamente). El archivo IndividuosTSP.txt contendrá los individuos que ha generado el fitness del archivo FitnessTSP.txt. Es decir, contendrá los mejores individuos. El siguiente paso es realizar un bucle doble. El primer bucle itera sobre los valores de la listas de probabilidades (c y m) gracias a la función nativa de Python zip13. El segundo bucle itera diez veces sobre dichos valores. La idea es ejecutar el algoritmo genético diez veces con la misma configuración para poder analizar los resultados. En cada iteración, el resultado del algoritmo se escribe en los archivos de texto. Cada resultado o individuo se escribe en una línea diferente del archivo de texto. Además, se ha incluido el número de iteración y las probabilidades de cruce y mutación. Una vez que se terminan los bucles, debemos cerrar ambos archivos para que los cambios tengan efecto: Ambos archivos de texto no tienen que estar creados previamente por nosotros; si no existen en el directorio de trabajo, Python los creará. Para poder visualizar las soluciones obtenidas, basta con abrir el archivo FitnessTSP.txt. Dicho archivo se muestra en el Resultado 2.6. Se puede observar que en las iteraciones id = 9, c = 0.8, m = 0.2 e id = 3, c = 0.7, m = 0.3 se han alcanzado los óptimos del problema con una distancia mínima de 2085. Texto 2.6. Resultados del algoritmo del archivo FitnessTSP.txt. La Tabla 2.1 muestra el análisis de los resultados contenidos en el archivo FitnessTSP.txt. En vista de estos, la mejor configuración del algoritmo genético es c = 0.7 y m = 0.3, ya que obtiene el valor mínimo; además, en media los resultados obtenidos son mejores que en las otras configuraciones. Métrica c m Fitness máximo 0.8 0.2 2238.0 mínimo 0.8 0.2 2085.0 media 0.8 0.2 2161.0 desviación 0.8 0.2 47.9 máximo 0.7 0.3 2184.0 mínimo 0.7 0.3 2085.0 media 0.7 0.3 2111.5 desviación 0.7 0.3 31.3 máximo 0.6 0.4 2194.0 mínimo 0.6 0.4 2090.0 media 0.6 0.4 2132.3 desviación 0.6 0.4 34.87 Tabla 2.1. Análisis de los resultados en el archivo FitnessTSP.txt. Para completar el análisis, el Resultado 2.7 muestra los mejores individuos obtenidos en cada intento del algoritmo genético. Se puede observar que los individuos que presentan mejores resultados (id = 9, c = 0.8 y m = 0.2) y (id = 3, c = 0.7 y m = 3) son prácticamente idénticos; simplemente ocurre que las ciudades de inicio y fin son distintas. Texto 2.7. Mejores individuos almacenados en el archivo IndividuosTSP.txt. En esta sección, solo se han analizado los efectos de las probabilidades de cruce y de mutación. Sin embargo, existen otros parámetros que también pueden afectar a los resultados y que pueden ser considerados para un análisis más profundo; por ejemplo, la probabilidad indpb puede aumentar el efecto del mecanismo de mutación. En este capítulo, al igual que en el Capítulo 1, hemos considerado un valor bajo, pero más adelante veremos que podemos aumentar dicho valor en caso de ser necesario. Otro parámetro que se podría modificar es el mecanismo de cruce. Por ejemplo, utilizando el cruce cxPartiallyMatched14. Para utilizar dicho operador de cruce, solo tendríamos que registrarlo de la siguiente forma: En definitiva, tenemos muchos parámetros de configuración que podemos variar para ver cómo afectan a los resultados. Pero, sin duda, las probabilidades de cruce y mutación tienen un gran impacto en los mismos. 2.8 Acelerando la convergencia del algoritmo: El tamaño del torneo Cuando registramos el operador de selección por torneo indicamos que un tamaño de tres es un valor adecuado para la mayoría de los casos. No obstante, en algunos casos este valor puede provocar una convergencia lenta del algoritmo, especialmente cuando el problema es sumamente complejo. En estos casos, podemos acelerar la convergencia del algoritmo incrementando el tamaño del torneo, de manera que los buenos individuos se utilicen más veces en las operaciones genéticas que generan la siguiente población. Nunca debemos perder de vista que queremos la mejor solución posible pero en un tiempo razonable. Aumentar el tamaño del torneo se traduce en aumentar el elitismo del proceso de selección, ya Podemos cuantificar la probabilidad de seleccionar el mejor individuo para un tamaño del torneo dado. En nuestro ejemplo, hemos considerado una población de 100 individuos, por lo que la probabilidad de seleccionar aleatoriamente al mejor individuo es p = 1/100 (un 1%). Cada selección es un experimento de Bernoulli, con dos posibles salidas: 1 (se selecciona el mejor individuo) y 0 (no se selecciona el mejor individuo). Por otro lado, la probabilidad de no seleccionar al mejor individuo es q = 1 – p = 0.99 (un 99%). Si realizamos 3 veces ese proceso, y teniendo en cuenta que cada selección es independiente, podemos calcular la probabilidad p de no seleccionar al mejor individuo en ninguno de los tres intentos como p = (99/100)3. Hay que tener en cuenta que el experimento completo sigue una distribución binomial. Esto nos da una probabilidad p = 0.97 (97%) de no seleccionar al mejor individuo, por lo que la probabilidad de seleccionar al mejor individuo es q = 1 – p = 0.03 (un 3%). En definitiva, con un torneo de tamaño 3, tenemos un 3% de probabilidad de que el mejor individuo de la población participe en las operaciones de cruce y mutación. Analicemos ahora qué ocurre si aumentamos el tamaño del torneo a 10. En este caso, la probabilidad de no seleccionar al mejor individuo en ninguno de los 10 intentos es p = (99/100)10 = 0.90 (un 90%); por lo tanto, la probabilidad de elegir al mejor en alguno de los intentos es q = 1 – p = 0.10 (un 10%). Podemos observar que hemos incrementado la probabilidad de escoger al mejor individuo del 3% al 10%, de forma que ahora es más probable que este participe en las operaciones genéticas. La Figura 2.6 muestra la evolución del mejor individuo para el problema del TSP con distintos tamaños de torneo. Se puede ver que al aumentar el tamaño del torneo aceleramos la convergencia del algoritmo. No obstante, en este caso particular, acelerar la convergencia del algoritmo tiene un efecto negativo, ya que el algoritmo empeora su resultado con respecto al mejor individuo, por lo que hay que tener especial cuidado con aumentar el tamaño del torneo. En líneas generales, solo se recomienda aumentar el tamaño del torneo en problemas donde el número de variables de diseño sea muy elevado y se detecten problemas de convergencia. Es decir, si el problema es sumamente complejo y no podemos esperar para obtener un resultado satisfactorio15, podemos acelerar la convergencia del algoritmo, penalizando en ocasiones el mejor resultado obtenido. Por otro lado, es posible aumentar la velocidad de convergencia del algoritmo paralelizando su funcionamiento, como se muestra en el Apéndice B. 2.9 Acelerando la convergencia del algoritmo: Aplicar elitismo En la sección anterior hemos visto que aumentando el tamaño del torneo podemos hacer que el algoritmo converja de manera más rápida. Aumentar el tamaño del torneo también se puede considerar un aumento de elitismo en la selección de los individuos que participarán en las operaciones genéticas. No obstante, existe una técnica aún más drástica, que consiste en que los mejores individuos pasen directamente a la siguiente generación; este mecanismo se conoce como “elitismo”. El elitismo se debe aplicar con cuidado ya que si nos pasamos haremos que el funcionamiento del algoritmo genético no sea adecuado. Figura 2.6. Evolución del fitness para el problema TSP utilizando selección mediante torneo con distintos tamaños. El porcentaje de individuos que se seleccionan mediante elitismo debe ser pequeño, normalme Si se considera un porcentaje muy alto en la aplicación del elitismo, corremos el riesgo de que el algoritmo genético sufra de una convergencia prematura. Se conoce como “convergencia prematura”, a la rápida convergencia del algoritmo genético e La librería deap dispone del método tools.selBest del módulo tools que permite realizar la operación de elitismo. Los parámetros de la función son: ■individuals : Individuos sobre los que seleccionar; normalmente será la población del algoritmo. ■k : Número de individuos que se seleccionarán. Es decir, se seleccionarán los k mejores individuos de la población. ■fit_attr : El atributo utilizado para la selección. Por defecto es el fitness de los individuos. Desgraciadamente, la librería deap no incluye el elitismo en ninguno de los algoritmos del módulo algorithms. Por lo tanto, si queremos aplicar elitismo tendremos que realizar nuestra propia implementación del algoritmo. La implementación desde cero de un algoritmo genético se escapa a los objetivos de este libro. No obstante, en la documentación oficial de la librería vienen algunos ejemplos sobre cómo realizar un algoritmo genéticos desde cero16. 2.10 Complejidad del problema: P vs NP El problema del viajero es de tipo NP-duro en cuanto a complejidad. A continuación, vamos a realizar una breve introducción sobre la complejidad de los problemas de optimización para poder entender qué significa este tipo de complejidad. En primer lugar, debemos hablar de los problemas de tipo NP (Nondeterministic Polynomial time), que son aquellos en los que se puede comprobar de manera sencilla, y en un tiempo razonable y determinista, que una solución es factible. Es decir, podemos verificar si la respuesta que nos ha dado un algoritmo al problema es correcta o no al problema. Ese tiempo razonable se conoce como “tiempo polinomial”. Por contra, en los problemas NP no existe un procedimiento o algoritmo determinista para resolver el problema en tiempo polinomial. El tiempo polinomial para resolver o comprobar la solución a un problema hace referencia a r Un ejemplo de problema NP es la resolución de sudokus. En general, podemos saber si una solución del sudoku es correcta de manera sencilla, únicamente utilizando sumas. Sin embargo, realizar un algoritmo que resuelva un sudoku no es sencillo. Existen soluciones para sudokus de dimensiones pequeñas (n pequeño), pero no existe un algoritmo determinista que resuelva el problema en tiempo polinomial para cualquier tamaño. Por lo tanto, siempre hay que tener en cuenta que: Verificar que una solución es correcta para un problema no garantiza que se pueda encontrar u Por otro lado, están los problemas de tipo P, que son aquellos para los que que sí se puede encontrar una solución en un tiempo razonable al problema. Es decir, en tiempo polinomial y de una manera determinista. Por lo tanto, para los problema de tipo P podemos hacer las dos cosas en tiempo polinomial. Para los problemas de tipo P podemos: i) comprobar que una solución es válida y correcta, y i Básicamente, los problemas tipo P son aquellos que se pueden resolver mediante una combinación de operaciones sencillas tales como sumas, restas, multiplicaciones, etc. Ejemplos de este tipo de problemas son: algoritmos de ordenación de listas o vectores, por ejemplo, el algoritmo de la burbuja tiene una complejidad N2, búsqueda binaria (log(N)) o multiplicación de matrices (N3), entre otros. La Figura 2.7 muestra los problemas de tipo P como un subconjunto de los problemas NP. Esto es así por la concepción que se tiene de estos dos tipos de problemas a día de hoy. Cada cierto tiempo un problema que es de tipo NP pasa a ser de tipo P porque alguien encuentra un procedimiento o algoritmo determinista para resolver el problema en tiempo polinomial. Por lo tanto, se piensa en P como un subconjunto de NP. Los problemas que hoy en día son de tipo NP en un futuro pueden ser de tipo P17. Figura 2.7. Representación de la función de optimización. Antes de continuar con otros tipos de complejidades, debemos hablar de algoritmos deterministas y no deterministas. Cuando hemos hablado del tipo NP, se ha hecho referencia a que no existe un algoritmo determinista que resuelva el problema en tiempo polinomial. Un algoritmo es determinista si no tiene ningún tipo de funcionamiento probabilístico en su ejecución. Es decir, el procedimiento es totalmente determinista y así será el resultado. Por el contrario, un algoritmo no determinista o estocástico tiene componentes probabilísticas en su procedimiento y, por consiguiente, el resultado del algoritmo puede ser distinto en cada intento. Veamos este concepto con un ejemplo. El siguiente script muestra un algoritmo para encontrar el mínimo de una lista de valores (l1). Se puede ver que el algoritmo no es determinista, ya que utiliza la función random.randint para obtener una posición aleatoria de la lista y comprobar si el elemento de dicha posición es el mínimo o no. Así, el comportamiento de este algoritmo será distinto en cada intento. Esto no significa que el algoritmo no haga bien su trabajo, simplemente significa que no podemos garantizar de forma determinista su comportamiento. Un algoritmo genético es un algoritmo no determinista, ya que tiene muchas componentes aleatorias en su ejecución (probabilidades de cruce y mutación). No obstante, el número de operaciones que realiza un algoritmo genético sí está acotado por el número de individuos que tiene, la población y el número de generaciones. A continuación, debemos hablar de los problemas de tipo NP-duro. Según la Figura 2.7, este tipo de problemas pueden ser NP o no. Para estos problemas pueden existir algoritmos o procedimientos que pueden resolverlos pero: i) no son deterministas o ii) requieren un tiempo exponencial 2n (no es tiempo polinomial). Hay que tener en cuenta que un tiempo exponencial de tipo 2n significa muchas operaciones y, en consecuencia, mucho tiempo de computación. De hecho, en muchos casos significa probar todas las posibles combinaciones, es decir, emplear un algoritmo de fuerza bruta, cosa que puede ser prohibitiva. Los problemas de tipo NP-duro son problemas que requieren algoritmos no deterministas y/o Continuemos por el último tipo de problemas que vamos a definir, los problemas NP-completos. Si observamos la Figura 2.7, vemos que los problemas NPcompletos están dentro del conjunto NP y de NP-duro. Por lo tanto, un problema es de tipo NP-completo si es de tipo NP y de tipo NP-duro a la vez. Es decir, es un problema intratable pero se puede comprobar la validez de la solución. Los problemas de tipo NP-completos son problemas de decisión. Podemos entender mejor los problemas NP-completos estudiando el problema factibilidad booleana. De hecho, la teoría de los problemas NP-completos surge a partir del teorema de Cook, demostrado para el problema de factibilidad booleana o SAT (Boolean satisfiability problem). Este problema consiste en obtener los valores de varias variables booleanas que cumplen cierta condición o función lógica18. Por ejemplo, la siguiente expresión lógica: Cada valor de xi puede ser 1 o 0 e i = 4, ya que tenemos cuatro variables booleanas. Por lo tanto, tenemos 24 posibles soluciones. Para resolver el problema, debemos evaluar todas las posibilidades y comparar si se cumple la condición o no. Este procedimiento se conoce en electrónica digital como “tabla de verdad” y requiere tiempo exponencial (2n intentos en el peor de los casos). Se puede ver que el problema de factibilidad se puede reducir a 2n problemas de decisión binarios, en los que la decisión es comprobar si se cumple (1 lógico) o no se cumple la condición (0 lógico) para cada combinación de las variables. Para la función lógica planteada, la solución sería x1 = 1,x2 = 0,x3 = 1,x4 = 1. En general, todo problema en el que podamos hacer este tipo de reducción, será de tipo NP-completo y, por consiguiente, también NP-duro. De hecho, este es el procedimiento estándar para comprobar si un problema es de tipo NP-completo. Lo que ocurre es que esta reducción solo se puede aplicar en problemas de decisión, como el problema expuesto, en el que solo se comprueba si se cumple la condición de satisfacción o no. En general, los problemas NP-completos o NP-duros, para n pequeños, se pueden incluso resolver a mano. Sin embargo, si aumentamos n necesitamos un ordenador para comprobar todas las condiciones; y si aumentamos mucho n, es inabordable incluso para un súperordenador. Por ello, este tipo de problemas se conocen como “problemas intratables”. Volviendo al problema de factibilidad booleana, este es NP-completo ya que se puede comprobar en tiempo polinomial que la solución es válida; basta con realizar las operaciones lógicas correspondientes para una combinación de valores de las variables. Sin embargo, hay problemas en los que esto no ocurre, por ejemplo el problema del viajero. En el TSP, la única forma de saber si la solución es óptima19 es resolver el problema, y el problema solo se puede resolver en tiempo no polinomial o mediante un algoritmo no determinista. Por lo tanto, el problema del viajero no es tipo NP, por lo que no puede ser NP-completo. Para entender mejor la diferencia entre NP-completo y NP-duro en el contexto del TSP, podemos mencionar el problema de encontrar un ciclo Hamiltoniano en un grafo. Un ciclo es Hamiltoniano si pasa solo una vez por cada nodo del grafo. Este problema es muy parecido al problema del TSP, pero en este caso es un problema de decisión ya que solo hay que encontrar un ciclo que cumpla la condición. Por lo tanto, estamos ante un problema NP-completo. En el caso del TSP, no nos basta con encontrar un grafo Hamiltoniano; queremos el grafo de distancia mínima, por lo que ya no estamos ante un problema de decisión. Los problemas NP-completos son problemas de tipo NP-duros y NP a la vez. Los problemas N Como hemos dicho, los problemas de tipo NP-completo se consideran los problemas más complicados de resolver dentro de NP. Se puede demostrar, mediante el teorema de Cook, que un problema NP se puede convertir por una transformación determinista en tiempo polinomial en NP-completo. Esta propiedad es muy importante ya que si se encontrara una solución a un problema NP-completo en tiempo polinomial con una máquina determinista, se podrían resolver todos los problemas de tipo NP. Existen más tipos de complejidad, pero su clasificación está fuera del alcance de este libro20. Es evidente que los algoritmos genéticos son una buena herramienta para aquellos problemas que son del tipo NP-duro y NP-completo, ya que requieren tiempo exponencial para ser resueltos. En los problema de tipo P, cuando el número de variables es muy grande, el número de operaciones necesarias para obtener una solución exacta puede ser muy elevado; en dicho caso, los algoritmos genéticos son también una buena alternativa. La Figura 2.8 representa el número de operaciones necesarias según su complejidad. Se puede observar cómo la complejidad de tipo exponencial 2n crece mucho incluso para valores pequeños de n. Figura 2.8. Número de operación según la complejidad. Por último, nos gustaría comentar que existe hoy en día un debate abierto sobre si todos los problemas NP son o serán de tipo P ya que, de vez en cuando, aparecen soluciones en tiempo polinomial y con algoritmo deterministas a problemas de tipo NP, convirtiéndolos así en tipo P. Es por ello que existe un debate abierto en la comunidad científica que consiste en demostrar si P = NP. En otras palabras, se intenta demostrar si ambos conjuntos se solapan. Este problema está considerado como uno de los problemas del milenio, y hay una recompensa de un millón de dolares para quien lo demuestre21. 2.11 Código completo y lecciones aprendidas El siguiente Código 2.8 muestra todas las líneas de código necesarias para resolver el problema del viajero. Como resumen del código: ■Las líneas 1-8 importan las librerías y módulos necesarios. ■Las líneas 11-12 cargan el archivo JSON como un diccionario en el objeto tsp . Dicho diccionario incluye información relevante del problema como, por ejemplo, la matriz de distancias entre ciudades ( distance_map ) y el tamaño del tour o número de ciudades ( IND_SIZE ). ■Las líneas 20 y 21 definen el problema de minimización mediante el atributo weights = (–1.0 , ) y la clase plantilla para los individuos, que en la mayoría de los casos será una lista. En cuanto a la representación de las soluciones, cada individuo define el orden de visita de las ciudades. ■La línea 23 crea el objeto caja de herramientas. La línea 25 registra la función indices necesaria para crear los genes aleatorios de los individuos. La línea 28 registra la función individual que permite generar un individuo aleatorio. La línea 30 registra la función population que permite crear la población inicial. También se define el tamaño de la población, que será de 100 individuos. ■Las líneas 33-40 definen la función objetivo, la cual mide la distancia total del tour . Para ello, primero calcula la distancia entre la primera y la última ciudad del tour y, después, se recorren de dos en dos las ciudades consecutivas en el tour gracias al bucle de las líneas 38-39. Finalmente, se devuelve la distancia total recorrida. No hay olvidar la coma, ya que la función objetivo siempre debe devolver una tupla. ■Las líneas 43-46 registran los operadores genéticos utilizados: cruce ordenado (línea 43), mutación mediante intercambio de índices (línea 44), selección mediante torneo (línea 45) de tamaño tres y el registro de la función objetivo (línea 46). ■La función plot_evolucion (líneas 48-66) ya ha sido utilizada en el ejemplo del Capítulo 1 ; en este caso, simplemente se han modificado los valores máximo y mínimo del eje y en la línea 58 para poder visualizar mejor la evolución del algoritmo. ■La función main ejecuta el algoritmo genético. En primer lugar, definimos la semilla del generador de números aleatorios (línea 69). La línea 70 define los hiperparámetros del algoritmo, tales como la probabilidad de cruce ( CXPB = 0.7), la probabilidad de mutación ( MUTPB = 0.3) y el número de generaciones del algoritmo ( NGEN = 120). La línea 71 crea la población inicial de 100 individuos. La línea 72 indica el tamaño los parámetros µ y λ del algoritmo µ + λ . Se definen del mismo tamaño que la población inicial: por lo tanto, la población extendida es de doble tamaño. La línea 73 crea el objeto hof que almacenará el mejor individuo a lo largo de la evolución del algoritmo. Las líneas 74-78 definen el objeto estadístico y registran las funciones para obtener las métricas de evolución de la población. La línea 79 crea el objeto registro de evolución log , que almacenará todos los datos de evolución del algoritmo. Finalmente, la línea 80 ejecuta el algoritmo µ + λ y devuelve la población final ( pop ) y el registro de evolución. ■Para finalizar el código, la línea 87 llama a la función main para ejecutar el algoritmo y las líneas 88-89 muestran por pantalla los mejores resultados. En último lugar, la línea 90 ejecuta la función plot_evolucion para visualizar la evolución del algoritmo. Código 2.8. Código completo problema TSP. En cuanto a las lecciones aprendidas: ■En este capítulo hemos aprendido la diferencia entre init.Repeat y init.Iterate para la creación de individuos aleatorios. Si disponemos de una función que nos crea de manera aleatoria cada uno de los genes, debemos utilizar init.Repeat . Por otro lado, si tenemos una función que nos genera todos los genes del individuo debemos utilizar init.Iterate . También es importante recordar que para la creación de la población inicial siempre utilizaremos init.Repeat . ■Existen problemas en los que las variables no pueden estar repetidas, como en el problema del viajero. Para ese tipo de casos existen algoritmos de cruce y mutación que respetan dicha condición. En este capítulo se ha utilizado el cruce ordenado, que permite intercambiar información genética entre dos individuos para crear otros dos individuos sin que exista duplicidad de genes en la descendencia. El operador de mutación utilizado en este capítulo, basado en la mezcla de índices, también respeta la condición de que no se repitan valores de variables, siempre y cuando el individuo original respete la condición. ■Se ha utilizado un algoritmo genético µ + λ , el cual está basado en crear una población extendida de tamaño µ + λ , creando una descendencia de tamaño λ , mediante los operadores genéticos (selección, cruce y mutación). De la población extendida (población actual más descendencia) se seleccionan λ individuos para la siguiente generación. Si los valores de µ y λ coinciden, el tamaño de la población no varía. La principal novedad de este algoritmo es que presenta un mayor elitismo que el eaSimple , ya que los progenitores compiten con su descendencia para pasar a la siguiente generación. ■Hemos estudiado cómo acelerar la convergencia del algoritmo genético. En concreto, hemos analizado dos técnicas: aumentar el tamaño del torneo y aplicar elitismo. ■En este capítulo también hemos aprendido cómo utilizar archivos de texto para realizar un análisis más profundo del impacto de las probabilidades de cruce y mutación en los resultados obtenidos. Esta técnica es muy útil cuando se quiere comparar los resultados de distintas configuraciones. También se puede utilizar para comparar distintos algoritmos genéticos u operadores genéticos; por ejemplo, si queremos comparar los resultados del algoritmo genético para distintos operadores de cruce y/o mutación. ■Por último, hemos aprendido a clasificar los problemas de optimización según su complejidad. En particular, se han definido los problemas de tipo NP, P, NPduro y NP-completo, el nicho de aplicación que tienen los algoritmos genéticos para la resolución de problemas NP-duros, NP-completos y los problemas de tipo P con un gran número de dimensiones. 2.12 Para seguir aprendiendo La literatura sobre el problema del viajero es amplia, ya que es un problema clásico. A continuación, se destacan varias fuentes: ■Una referencia clásica sobre el problema del viajero se puede encontrar en (Goldberg et al., 1985). ■En (Abdoun et al., 2012) se analiza la solución del problema considerando distintos operadores genéticos. Para más información sobre el algoritmo genético µ + λ se recomienda consultar (Ter-Sarkisov y Marsland, 2011). ■El modelo del problema del TSP puede ser utilizado para resolver problemas de cobertura o planificación de rutas en vehículos autónomos. En (Arzamendia et al., 2016), (Arzamendia et al., 2019b) y (Arzamendia et al., 2019) se utilizó el problema del viajero para planificar las rutas óptimas de un vehículo acuático autónomo de superficie para monitorizar la calidad del agua del lago Ypacarai en Asunción (Paraguay). Una extensión de dichos trabajos se encuentra en (Arzamendia et al., 2018) y (Arzamendia et al., 2019a). En este caso, el problema de planificación de rutas del vehículo se plantea con el modelo del cartero chino ( Chinese Postman Problem ) ((Edmonds y Johnson, 1973)), resuelto también con algoritmos genéticos 22. ■Existe una amplia literatura sobre el dilema P = NP . Por ejemplo, en (Fortnow, 2009) se puede encontrar una buena revisión sobre este problema. Como ejercicios se plantean los siguientes: ■En el repositorio del libro 23, se encuentran los archivos JSON para 24 y 120 ciudades; se propone a los lectores que modifiquen el código de esta sección para resolver el problema del TSP para esos números de ciudades. ■Realice una comparación entre los operadores de cruce estudiados en esta sección. ■Realice una comparación entre las dos implementaciones de algoritmo genético estudiadas ( eaSimple y µ + λ ) para el problema con 24 ciudades. Para ambas implementaciones debe utilizar la misma configuración en cuanto a operadores genéticos, tamaño de población y probabilidades. ■Cambie el objetivo del problema del viajero para maximizar la distancia que recorre el agente. Debemos comprobar que el algoritmo funciona correctamente. ■Para el problema con 17 ciudades, introduzca rutas prohibidas en la matriz de distancia. Cuando una ruta esté prohibida entre dos ciudades, penalice con el valor de 1e6 (u otro valor elevado). Utilice la pena de muerte para penalizar aquellas soluciones que sean inválidas. _________________ 1Más adelante cuando hablemos de la complejidad de los problemas, se definirá qué es un tiempo razonable. 2Según la Ley de Moore, que predice que el número de transistores que incluye un microprocesador se duplica cada 18-24 meses. 3Hay que recordar que range(N) genera valores desde 0 a N – 1. 4Para más información sobre random.sample, consultar https://docs.python.org/3/library/random.html 5https://deap.readthedocs.io/en/master/api/tools.html#deap.tools.initRepeat 6https://deap.readthedocs.io/en/master/api/tools.html#deap.tools.initIterate 7https://deap.readthedocs.io/en/master/api/tools.html#deap.tools.cxPartialyMatched 8https://deap.readthedocs.io/en/master/api/algo.html 9https://deap.readthedocs.io/en/master/examples/eda.html 10El número de veces dependerá del tiempo que estemos dispuestos a esperar. 11También se podría pasar como argumento al main. 12Siempre se pueden encontrar excepciones a esta afirmación. Por lo tanto, no hay que tomarlo como una regla general sino más bien como una recomendación. 13https://docs.python.org/3.3/library/functions.html#zip 14Solo se debe registrar un operador por cada operación genética. En caso contrario, solo el último tendrá efecto. 15Este aspecto dependerá de los recursos computacionales que tengamos disponibles. 16https://deap.readthedocs.io/en/master/examples/ga_onemax.html 17Es solo una posibilidad, puede ser que sí o puede ser que no. 18Este problema se considera la madre de los problemas NP-completos. 19No estamos buscando si existe una posible ruta; estamos buscando la ruta óptima. 20En el siguiente vídeo se explica con más detalles los tipos de problemas que podemos encontrar: https://www.youtube.com/watch?v=YX40hbAHx3s 21Si algún lector se anima, aquí dejamos el enlace, le deseamos suerte y le animamos a que comparta el premio con nosotros http://www.claymath.org/millennium-problems/millennium-prize-problems 22El problema del cartero chino es similar al problema del viajero, pero en este caso se quiere encontrar el ciclo Euleriano de distancia mínima de un grafo. 23https://github.com/Dany503/Algoritmos-Geneticos-en-Python-Un-EnfoquePractico 3.1 Introducción a las funciones de benchmark Llegados a este punto, ya somos capaces de desarrollar nuestros propios algoritmos genéticos. No obstante, como hemos visto en el primer capítulo, el uso de estrategias metaheurísticas tiene el inconveniente de no proporcionar certeza alguna sobre la optimalidad de las soluciones obtenidas. Así, antes de aventurarnos a resolver problemas de ingeniería con los algoritmos que programamos, es de vital importancia que nos aseguremos de su correcto funcionamiento. ¿Cómo comprobamos si funcionan bien, si hemos dejado claro que el algoritmo no nos da nin Bien, aquí entran en juego las denominadas “funciones de benchmark". Se denominan así a una serie de funciones de diferente naturaleza y complejidad, pero cuyas soluciones óptimas son conocidas, de manera que pueden servir de test para comprobar el buen funcionamiento de nuestros algoritmos. También son ampliamente utilizadas por la comunidad científica a la hora de demostrar la validez de nuevos operadores genéticos. Así, cuando un investigador propone un nuevo mecanismo de cruce o mutación, lo prueba utilizando funciones de benchmark para validar su hipótesis. La librería deap pone a nuestra disposición un amplio abanico de funciones de benchmark que podemos importar fácilmente en nuestro código. Estas funciones están clasificadas en cuatro grupos, en función de la naturaleza del problema de optimización que permiten estudiar: continuas con un objetivo, continuas multiobjetivo, binarias y de regresión simbólica. En la Tabla 3.1 se listan las funciones disponibles1. Es importante elegir adecuadamente la/s función/es que usemos para testear nuestros algoritmos, pues cada una de ellas tiene ciertas características que las hacen relevantes para evaluar ciertas capacidades del algoritmo, por ejemplo, tener un gran número de óptimos locales o ser muy planas en el entorno del óptimo global. Cabe indicar que de la Tabla 3.1 nos vamos a centrar en el estudio de las función continuas con un solo objetivo (la primera columna). Las funciones multiobjetivo se verán en el siguiente capítulo. En cuanto a las funciones de regresión simbólica, se utilizan para algoritmos de programación genética, los cuales son otra rama de la computación evolutiva, que queda fuera del alance de este libro (Koza, 1992). Tabla 3.1. Funciones de benchmark implementadas en el módulo deap Continuas Monoobjetivo Binarias Multiobjetivo Regresión simbólica cigar() plane() sphere() rand() ackley bohachevsky() griewank() h1() himmelblau() rastrigin() rastrigin_scaled() rastrigin_skew() rosenbrock() schaffer() schaffer() schwefel() shekel() fonseca() kursawe() schaffer_mo() dtlz1() dtlz2() dtlz3() dtlz4() zdt1() zdt2() zdt3() zdt4() zdt6() chuang_f1() chuang_f2() chuang_f3() royal_road1() royal_road2() kotanchek() salustowicz_1d() salustowicz_2d() unwrapped_ball() rational_polynomial() rational_polynomial2() sin_cos() sipple() 3.2 Aprendiendo a usar las funciones de benchmark : Formulación del problema En esta sección vamos a formular el problema de hasta tres funciones de benchmark: un problema de maximización y dos de minimización. Una propiedad que tienen los tres problemas que veremos a continuación, es que los tres tienen variables continuas comprendidas en un determinado intervalo. 3.2.1 Función h1 La función h1, representada en la Figura 3.1, es una función continua en dos dimensiones, con un máximo global y varios máximos locales. Observando la figura, podemos comprobar que aparecen máximos locales en las proximidades del máximo global, lo cual constituye un enorme inconveniente para los métodos analíticos basados en gradiente. Aunque la función h1 se puede usar con más de dos variables, nosotros nos ceñiremos al caso de dos dimensiones. Figura 3.1. Representación de la función de benchmark h1 para dos dimensiones sin_cos() 3.2.2 Función Ackley La función Ackley (representada en la Figura 3.2) es continua en N dimensiones, con un mínimo global y una gran cantidad de mínimos locales. Nosotros la estudiaremos con N = 4. Figura 3.2. Representación de la función de benchmark Ackley para dos dimensiones. 3.2.3 Función Schwefel La función Schwefel (representada en la Figura 3.3) es una función continua con un alto número de mínimos y máximos locales. Se puede definir para N dimensiones y, en nuestro caso, vamos a llegar hasta N = 40, para tratar un problema con un número alto de variables. Figura 3.3. Representación de la función de benchmark Schwefel para dos variables Una vez formulados los tres problemas que se abordarán en este capítulo, seguiremos los mismos pasos que en los anteriores capítulos: definir el problema en deap y crear la población inicial. 3.3 Definición del problema y generación de la población inicial Aunque en este capítulo se abordarán tres problemas distintos, los tres comparten el tipo de variables, esto es, variables continuas. Tendremos que distinguir en cada caso si el problema es de maximización o minimización. En el caso de la función h1, el problema se define de la siguiente forma: maximización: Mediante el atributo weights definimos el problema como de único objetivo y de maximización. Tanto para la función Ackely como para Schwefel, debemos proceder de la siguiente forma: En ambos casos estamos interesados en minimizar la función objetivo. A continuación, debemos crear la plantilla para almacenar nuestro individuos. En este caso, para los tres problemas podemos proceder de la siguiente forma: Estamos creando una nueva clase Individual que hereda de la clase array del módulo array2 (ver Apéndice A). Los objetos de tipo array tienen el atributo typecode que define el tipo de datos que contienen. En este caso, el valor ” f ” indica que los elementos del vector o array son de tipo flotante. El siguiente paso es crear la caja herramientas o toolbox y registrar las funciones que nos permitirán crear individuos aleatorios para la población inicial. En primer lugar, vamos a definir una función que nos genere un lista de números flotantes entre dos límites, y la usaremos para los tres casos que estamos estudiando. Mediante el siguiente código, la función creará una muestra de un tamaño especificado por tam, cuyos genes tendrán valores uniformemente aleatorios entre los valores min y max: Esta función es muy versátil y nos será útil para la mayoría de problemas de benchmark de variables continuas de la Tabla 3.1. Sin embargo, antes debemos definir unas variables que dependerán de cada función de benchmark, ya que determinan el rango de las variables del problema. Esas tres variables definen: ■minimo : Valor mínimo del rango de las variables del problema en el intervalo de generación. ■maximo : Valor máximo del rango de las variables del problema en el intervalo de generación. ■tamaño : Número de variables del problema. Los valores anteriores son los rangos de la función h1. Para la función Ackely y Schewel, únicamente debemos cambiar los valores de las variables con respecto a la formulación de dichos problemas. Pasamos ahora a registrar dicha función en el objeto toolbox. La función se ha registrado con el alias attr: Es importante observar que la función crea_individuo crea el cromosoma completo del individuo, por lo tanto, debemos utilizar la función initIterate para registrar la función que nos permite crear individuos aleatorios. Como ejemplo de creación de individuos, en el Resultado 3.1 se muestra la generación de un individuo para la función de benchmark h1 utilizando la función que acabamos de registrar. Resultado 3.1. Ejemplo de individuo para el problema h1. Para registrar la función que nos genera la población inicial, procedemos como hemos hecho en capítulos anteriores. 3.4 Función objetivo y operadores genéticos Una vez hemos definido los problemas que queremos resolver y sabemos cómo crear la población inicial, pasamos a describir el procedimiento para acceder a las funciones de benchmark desde deap y a describir algunos operadores genéticos nuevos. 3.4.1 Función objetivo En primer lugar, es indispensable que importemos el módulo3 de la librería correspondiente: Todas las funciones son accesibles a través de dicho módulo; por lo tanto, para registrar la función de evaluación debemos proceder de la siguiente forma: En este caso, estamos registrando la función h1, pero el procedimiento sería análogo para cualquier otra función de benchmark; simplemente debemos modificar el último parámetro. Para la función de Ackley debemos utilizar benchmarks.Ackley, y benchmarks.Schwefel para la función de Schewefel. Otra forma de utilizar las funciones de benchmark, sería construir la función de fitness llamando a la función de benchmark correspondiente, donde debemos sustituir funcion por el nombre de la función, tal y como aparece en la Tabla 3.1: El registro de dicha función se haría de la siguiente forma: Una cosa importante que debemos tener en cuenta en este caso, es que ahora no se devuelve el objetivo como una tupla, ya que la función dentro del módulo benchmark se encarga de ello. Así, el script anterior devuelve fitness y no fitness, como hemos visto en capítulos anteriores. 3.4.2 Operadores genéticos Veamos ahora las operaciones genéticas de cruce, mutación y selección. En este capítulo vamos a aprovechar para definir nuevos operadores genéticos que no se han utilizado hasta ahora. Con respecto a los operadores de cruce, vamos a introducir dos nuevos mecanismos: (i) Simulated Binary Crossover (SBX) y (ii) Blend Crossover (Blend). El primero de estos mecanismos, SBX, se basa en el operador cxSimulatedBinaryBounded, y es un poco más sofisticado que el Blend, como veremos a continuación. Este operador se basa en el operador cxSimulatedBinary, con la consideración adicional de establecer unos límites, low y up, para los individuos. Para registrar dichos operadores se procedería de la siguiente forma: En este caso, se ha elegido un valor de eta(η) igual a 2, pero dicho valor podría ser ajustado libremente. Más adelante veremos cómo afecta al funcionamiento de este operador de cruce. Por otro lado, también tenemos disponible en deap el operador de Blend. Para registrarlo en la caja de herramientas debemos proceder de la siguiente manera: En este caso, se ha elegido un parámetro alpha igual a cinco (α = 0.5). No obstante, ese valor se puede ajustar según nos interese, como veremos más adelante. Ambas técnicas de cruce se pueden utilizar para variables continuas. Es decir, se pueden utilizar en los tres problemas que se abordarán en este capítulo. Por el contrario, el SBX y la operación de Blend no pueden usarse en problemas con variables discretas. Por último, es importante añadir que si queremos que los progenitores no se salgan de los intervalos establecidos para las variables, tenemos que tener cuidado con el parámetro α en la operación de blend. A diferencia de lo que hemos visto en capítulos anteriores, aquí vamos a definir nosotros un mecanismo de mutación. Hasta ahora hemos utilizado métodos de mutación que vienen implementados en deap. No obstante, la librería permite definir nuevos métodos, tanto para el cruce como para la mutación, y registrarlos en la caja de herramientas. Para la mutación vamos a crear un operador similar a mutGauss (estudiado previamente), al que llamaremos mutTriangular. En cuanto al mecanismo de selección, en este capítulo introduciremos otro procedimiento de selección muy popular, como es la selección mediante ruleta. Se propone como ejercicio evaluar el funcionamiento del algoritmo utilizando operadores de mutación alternativos, como por ejemplo el operador de mutación Gaussiana, introducido en el Capítulo 1. Por último, configuraremos nuestro algoritmo para utilizar unas probabilidades de cruce y mutación de 0.7 y 0.3, respectivamente, así como un tamaño de población de 100 individuos y un total de 100 generaciones. Esto se hace fácilmente en una sola línea: Antes de continuar, nos gustaría recalcar las diferencias entre la selección por ruleta y la selección mediante torneo: ■La selección mediante ruleta, en teoría, solo se puede utilizar para problemas de maximización y nunca si la función de fitness devuelve valores negativos, ya que daría lugar a probabilidades negativas. Una posibilidad es cambiar el signo al fitness de los individuos. ■La selección mediante torneo siempre se puede utilizar y, en general, es más elitista que la selección mediante ruleta. ■El principal problema que tiene la selección por ruleta es que, a medida que se avancen las generaciones del algoritmo genético, los individuos comenzarán a parecerse entre sí, en términos de cromosoma y fitness , por lo que todos los individuos de la población tendrán prácticamente la misma probabilidad de ser seleccionados como progenitores. Ese problema no ocurre con la selección mediante torneo, ya que un individuo que tenga un mayor fitness siempre tendrá mayor probabilidad de ser seleccionado, por muy pequeña que sea la mejora en su fitness . Para ilustrar el problema, consideremos el siguiente ejemplo: tenemos un algoritmo genético en un problema de maximización con una población de cuatro individuos. En un determinado momento de la evolución del algoritmo, el fitness f de los cuatro individuos es f 1 = 10.1, f 2 = 10.2, f 3 = 10.3 y f 4 = 10.4. Aplicando la selección mediante ruleta, la probabilidad p de seleccionar cada individuo es p 1 = 0.246, p 2 = 0.248, p 3 = 0. , 251 y p 4 = 0.253. Podemos comprobar que son probabilidades muy parecidas, todas rondan el 0.25, lo que significa tienen casi las mismas probabilidades de ser seleccionadas, ya que hay muy poca diferencia entre la mejor solución ( p 4 = 0.253) y la peor ( p 1 = 0.246). Por el contrario, si utilizamos la selección mediante torneo, si consideramos un torneo de tamaño dos, la mejor solución tiene una probabilidad de ganar p 4 = 0.437 4. Por lo tanto, el mejor individuo ha pasado de tener un 25.3% a tener un 43.7% de posibilidades de ser seleccionado. 3.5 Código completo Puesto que vamos a utilizar algoritmos muy similares para evaluar las funciones de benchmark, en este capítulo haremos una excepción e introduciremos primero el código completo, sobre el cual realizaremos pequeñas modificaciones para utilizarlo con las diferentes funciones propuestas. Así, el algoritmo completo se muestra en el Código 3.2. Este código se desarrolla de la siguiente manera: ■Líneas 1-10: En primer lugar, importamos las librerías necesarias. ■Líneas 12-13: Creamos los objetos para definir el problema y el tipo de individuo. ■Línea 16: Creamos el toolbox donde incluiremos todas las herramientas necesarias. ■Líneas 18-20: Aquí, definiremos el dominio del problema (el rango de valores posibles de los genes), y el tamaño del individuo. ■Líneas 22-24: Creamos la función de generación de individuos, en este caso mediante siguiendo una probabilidad uniforme entre los límites indicados. ■Líneas 26-33: Registramos todas las funciones necesarias, tanto las relativas a la creación de individuos y de la población inicial (líneas 26 a 28) como los operadores de cruce, mutación y selección (líneas 30 a 32) y la función de fitness (línea 33). ■Líneas 35-51: Función principal para lanzar el algoritmo genético. ■Líneas 53-55: Ejecución de la función principal. Código 3.2. Código final para testear las funciones de benchmark. 3.6 Evaluación de algunas funciones de benchmark Utilizaremos el código anterior, con pequeñas modificaciones que detallaremos, para evaluar algunas de las funciones que hemos introducido al principio de este capítulo. Para cada una de las ejecuciones, analizaremos gráficamente la evolución de los individuos a lo largo de las generaciones utilizando la función plot_evolucion descrita en capítulos anteriores. Y evaluaremos el fitness del mejor individuo: 3.6.1 Función h1 Para evaluar este problema, deberemos hacer las siguientes modificaciones en nuestro código: Configuramos nuestro problema como maximizacion: Elegimos la función h1 como fitness: Con respecto al rango de las variables y al tamaño del problema, en este caso generaremos individuos de dimensión 8, cuyos genes tendrán valores entre -100 y 100: Tras 100 generaciones, usando una población de 100 individuos, el mejor individuo obtenido es el obtenido en el Resultado 3.3: Resultado 3.3. Obtención del mejor individuo para el problema h1. Su evaluación en la función de fitness es 1.9979077811118524, muy próxima al máximo teórico. La evolución del fitness a lo largo de las generaciones se muestra en la Figura 3.4. 3.6.2 Función Ackley Para evaluar este problema, deberemos hacer una serie de modificaciones en nuestro código. En primer lugar configuraremos nuestro problema como minimización: Elegimos la función Ackley como función de fitness: Figura 3.4. Evolución del fitness para el problema h1. Elegimos adecuadamente los individuos. En este caso, generaremos individuos de dimensión 8, cuyos genes tendrán valores entre -15 y 30: Por último, puesto que el problema es de minimización, no podemos utilizar el mecanismo de selección de ruleta, de manera que recurriremos a un proceso de selección por torneo (operador selTournament5) con tres individuos: Tras 100 generaciones, usando una población de 100 individuos, se obtiene el siguiente mejor individuo: Resultado 3.4. Obtención del mejor individuo para el problema Ackley. Su evaluación en la función de fitness es 0.0005428258269102315, muy próxima al máximo teórico (0). Para mejorar el resultado podríamos incrementar el número de individuos que componen la población (en este caso eran 100). La evolución del fitness a lo largo de las generaciones se muestra en la Figura 3.5. 3.6.3 Función Schwefel Estudiaremos este problema en 40 dimensiones. Así, empezaremos configurando nuestro problema como minimización: Figura 3.5. Evolución del fitness para el problema Ackley. Elegimos la función schwefel como fitness, indicando las matrices comentadas anteriormente: Vamos a poner a prueba nuestro algoritmo aumentando notablemente la complejidad del problema. Para ello, estudiaremos el problema con dimensión 40; es decir, cada individuo estará compuesto por 40 genes, que tendrán valores entre 0 y 500: Para este caso, además, incrementaremos el número de individuos a 1000 y el número de generaciones a 300. Para ello basta utilizar: Por último, volvemos a utilizar de nuevo el mecanismo de selección por torneo, pero esta vez el torneo será de 4 individuos: Tras las 150 generaciones se obtiene el siguiente mejor individuo: Resultado 3.5. Obtención del mejor individuo para el problema Schwefel. cuya evaluación en al función de fitness es 0.0187036032184551, bastante próxima al mínimo teórico. Un paso adecuado sería calibrar adecuadamente las probabilidades de cruce y mutación para comprobar su influencia. La evolución del fitness a lo largo de las generaciones se muestra en la Figura 3.6. Figura 3.6. Evolución del fitness para el problema Schwefel. 3.7 Ajuste de los hiperparámetros de los operadores genéticos En esta sección vamos a analizar la influencia de los hiperparámetros de ajuste en algunos de los operadores genéticos estudiados en este capítulo. En primer lugar, vamos a empezar analizando el parámetro η del operador SBX. Este parámetro controla la distancia ente los hijos y los padres. Como regla, un valor alto de η crea hijos muy cercanos a los padres. La mejor forma de ver esta regla es mediante un ejemplo gráfico. Imaginemos que tenemos un problema con dos variables independientes, como el problema h1, representadas como [x1,x2]. En la Figura 3.7, se puede observar la distancia en el plano x1,x2 de los descendiente frente a los progenitores. Las coordenadas de los progenitores en el plano x1,x2 son [0.3,0.3] y [0.7,0.7]6. Se han generado hasta 10 descendientes distintos y en cada operación los padres son siempre los mismos. Se puede observar que conforme disminuimos el valor de η, los hijos se separan más de los padres. Para valores altos, la descendencia que se crea se parece mucho y está muy cerca en distancia a los padres. Otro aspecto interesante es observar el cruce de información genética entre las variables x1 y x2. Se pueden observar hasta cuatro conjuntos de soluciones. Dos de ellas se corresponden con los genes originales, y las otras dos, son el intercambio de genes entre los progenitores. Es por ello, que este operador genético realiza una función parecida al cruce de un punto, pero añadiendo un desplazamiento con respecto a los genes originales. Por último, hay que tener en cuenta que la Figura 3.7 es solo una representación de un caso particular, ya que en cada caso las distribuciones serán distintas. Este ejemplo solo intenta ilustrar el efecto de η en la creación de la descendencia con respecto a los progenitores. Sería interesante poder variar el valor de η a lo largo de las generaciones, pero desgraciadamente, la librería deap no tiene mecanismos para hacerlo; tendríamos que modificar nosotros el operador genético y la implementación del algoritmo. Figura 3.7. Descendencia creada con el operador SBX en función del hiperparámetro η. A continuación, vamos a estudiar el hiperparámetro α en el operador de Blend. Lo vamos a hacer con el mismo ejemplo, pero en este caso modificando el valor de α para generar la descendencia. La Figura 3.8 muestra los resultados obtenidos. Se puede ver cómo a medida que aumenta el valor de α aumenta la distancia entre los padres y los hijos. Si α = 0, los hijos están confinados entre los genes de los padres. Un aspecto importante en la operación de Blend es que, a partir de ciertos valores de α, es muy probable que los genes se salgan fuera de los rangos de las variables, en caso de que estos existan. En la operación de Blend no hay intercambio de genes tal y como se produce en el operador SBX. Esta es, sin duda, la mayor diferencia entre los dos operadores. Al igual que en la operación de SBX, sería interesante disminuir el valor de α a medida que avanzamos en las generaciones del algoritmo genético7. En resumen, en esta sección se ha analizado la influencia de los hiperparámetros en dos populares operadores genéticos para problemas con variables continuas. En este momento el lector se estará preguntando cómo elegir los parámetros óptimos para su problema en cuestión. Desgraciadamente, no existe una respuesta universal a dicho problema, ya que el hiperparámetro óptimo dependerá del problema en cuestión. En general, se recomienda hacer un barrido con distintos valores para ver los resultados. Si no es posible realizar un barrido porque requiera mucho tiempo, se recomienda no utilizar valores extremos. Por ejemplo, η = 2 y α = 1 pueden ser dos buenos puntos de partida. 3.8 Lecciones aprendidas A modo de resumen, podemos destacar las siguientes lecciones aprendidas: ■Que nuestros algoritmos genéticos converjan correctamente no es suficiente para garantizar su buen rendimiento a la hora de resolver diferentes problemas de ingeniería, por lo que antes de utilizarlos para aplicaciones de ingeniería debemos garantizar que son capaces de resolver problemas tipo o funciones de benchmark . Figura 3.8. Descendencia creada con el operador Blend en función del hiperparámetro α. ■Para ello, tenemos a nuestra disposición un amplio abanico de funciones, denominadas funciones de benchmark , implementadas en la librería de deap . Estas funciones tienen soluciones conocidas y, además, constituyen diferentes retos para testear diferentes aspectos de nuestros algoritmos como, por ejemplo, evitar óptimos locales. ■Como estas funciones ya están implementadas, simplemente tenemos que importar la librería de benchmark y definir la función con la que queramos trabajar como función de fitness . ■Analizando los resultados de la aplicación de nuestros algoritmos con funciones de benchmark podemos identificar rápidamente potencialidades y vulnerabilidades de nuestros algoritmos, lo cual será de vital importancia para su aplicación en problemas de ingeniería. ■Las funciones de benchmark devuelven el resultado como una tupla, aunque sea una función mono objetivo. ■Se han presentado dos nuevos operadores de cruce adecuados para problemas con variables continuas: SBX y Blend . Con respecto al primero, se ha utilizado su versión bounded , que impide que los valores de las variables se salgan de cierto intervalo. En ambos casos, tenemos parámetros de ajuste que afectan a la distancia de los descendientes con respecto a los progenitores. ■En este capítulo hemos visto cómo se puede crear un operador de mutación propio, no incluido en la librería. No debemos olvidar el alias con el que se debe registrar la función en el objeto toolbox . Para registrar operadores de cruce debemos utilizar el alias mate ; para el caso de mutación, debemos utilizar el alias mutation y, finalmente, para la selección debemos utilizar select . ■Hemos presentado la selección mediante ruleta. Este mecanismo solo funciona en problemas de maximimición 8. La selección mediante ruleta es menos elitista que que la selección mediante torneo. ■Por último, hemos estudiado la influencia de los parámetros de ajuste en los operadores genéticos. 3.9 Para seguir aprendiendo A continuación, se detallan algunas referencias para seguir profundizando en los conceptos aprendidos en este capítulo. ■Una comparación entre distintas técnicas de selección se puede encontrar en (Goldberg y Deb, 1991). ■El artículo original donde se describe el método de cruce Simulated Binary Crossover puede ser consultado en (Deb et al., 1995). Con respecto al operador Blend , el trabajo original se puede encontrar en (Eshelman y Schaffer, 1993). ■En (Herrera et al., 2003) se puede encontrar una taxonomía de métodos de cruce para variables continuas. ■La función de benchmark h1 se propuso en (Van Soest y Casius, 2003) donde, además, se puede encontrar una interesante comparación entre distintas técnicas de optimización. Como ejercicios se plantean los siguientes: ■Utilice otras funciones de benchmark para variables continuas disponibles en deap , y compruebe que se consigue alcanzar el máximo o mínimo global correspondiente. ■Utilice funciones de variables binarias disponibles en deap 9, y compruebe que sea capaz de alcanzar los máximos y mínimos correspondientes. _________________ 1https://deap.readthedocs.io/en/master/api/benchmarks.html 2https://docs.python.org/3/library/array.html 3shttps://www.overleaf.com/project/5e00d3f97979160001d2b7f6 4La probabilidad de no seleccionar ninguna de las dos veces al mejor es q = 0.75 × 0.75, ya que cada intento es independiente, por lo que la probabilidad de seleccionar el mejor es p = 1 – q = 0.43. 5https://deap.readthedocs.io/en/master/api/tools.html#deap.tools.selTournament 6Se han elegido esos valores simplemente para mejorar la visualización de los resultados. 7Se invita a los lectores a que trabajen en ello. 8Aunque siempre se puede multiplicar por -1 el fitness. 9https://deap.readthedocs.io/en/master/api/benchmarks.html#moduledeap.benchmarks.binary 4.1 Introducción a los problemas con múltiples objetivos Hasta ahora, los problemas que hemos visto solo tenían un único objetivo. En el primer capítulo, vimos un problema de maximización con variables continuas cuyo objetivo era obtener el máximo de una función de dos variables independientes x e y. En el segundo capítulo, se estudió el popular problema del viajero o TSP, que consiste en un problema de minimización con un solo objetivo: optimizar la distancia que recorre el viajero. Este problema se abordó con variables discretas (las diferentes ciudades que el viajero visita). En el tercer capítulo, abordamos la optimización de distintos problemas de benchmark. Aunque estos problemas nos han servido para motivar el uso de los algoritmos genéticos, en un gran número de problemas reales que aparecen en el ámbito de la ingeniería los ingenieros nos enfrentamos a problemas con múltiples objetivos. Además, en la mayoría de los casos los objetivos son opuestos, es decir, mejorar un objetivo implica empeorar otros. Un ejemplo claro de objetivos opuestos es el de la potencia y el coste en ingeniería. En cualquier problema de ingeniería, aumentar la potencia requiere un aumento del coste, y viceversa. Es por ello que si nuestro algoritmo genético se centra en optimizar la potencia, siempre estaremos aumentando el coste, y lo mismo ocurre a la inversa. Parece lógico pensar que necesitamos un mecanismo específico que nos permita poder balancear los dos objetivos para tener un visión conjunta de ambos en función de las variables de nuestro problema. Una técnica muy usada para el primer propósito se basa en asignar pesos a cada uno de los objetivos y agregarlos en uno único (función de utilidad), convirtiendo así el problema en un solo objetivo. Por ejemplo, imaginemos un problema de optimización de una planta industrial, ilustrado en la Figura 4.1. Consideramos la potencia de la planta como P y el coste como C; el problema multiobjetivo se convierte en monobjetivo de la siguiente forma: F = w1 × P + w2 × C. Figura 4.1. Planta industrial con xn parámetros de entradas y dos salidas P y C. La cuestión, en este caso, es determinar los valores de w1 y w2 para maximizar F. Si consideramos ambos objetivos igual de importantes, podemos hacer w1 = w2 = 0.5. Esta técnica es totalmente válida y se utiliza mucho en la práctica; sin embargo, el valor F puede ser el mismo para un valor de P alto y C bajo, y para un valor P bajo y C alto. Lo podemos ver dando valores a los pesos, las potencias y los costes. Si consideramos w1 = w2 = 0.5, podemos tener las siguientes soluciones al problema: ■La primera solución con P 1 = 0.8 y C 1 = 0.2 nos lleva a F 1 = 0.5 × 0.8 + 0.5 × 0.2 = 0.5. En este caso, tenemos un valor alto de potencia y un valor bajo de coste. ■La segunda solución con P 2 = 0.2 y C 2 = 0.8 nos lleva a F 2 = 0.5 × 0.2+0.5 × 0.8 = 0.5. En este segundo caso, tenemos un valor de potencia bajo y un valor de coste alto. En este ejemplo, es fácil ver cómo dos soluciones muy distintas dan un mismo valor de F. Por lo tanto, esta técnica de convertir un problema multiobjetivo es un problema monobjetivo puede enmascarar los valores de los objetivos. Como resultado, tenemos un mismo fitness para dos soluciones muy distintas en el plano de los objetivos P y C. Este mismo problema lo tendríamos aunque cambiásemos los valores de w1 y w2. En consecuencia, parece más interesante tener un mecanismo que nos permita obtener un conjunto de soluciones que representen todas las posibles combinaciones de los objetivos. Con ese conjunto de soluciones, podemos decidir en función del presupuesto que tenemos. Es decir, con un determinado coste podemos decir qué potencia podemos alcanzar, y al contrario. Además, esto nos permite cambiar de solución en el momento en que nuestro presupuesto cambie (variación en el coste) o nuestra demanda varíe (modificación en la potencia requerida). Este mecanismo es el frente de Pareto que veremos con más detalle en la siguiente sección. 4.2 Introducción a la Pareto dominancia El frente de Pareto se define como el lugar geométrico que forman todas las soluciones de nuestro problema que no están dominadas. Hay que preguntarse, pues, qué significa que una solución esté dominada por otra. Una solución domina a otra si es estrictamente mejor que la otra en todos los objetivos consid Para entender mejor la dominancia de Pareto, lo mejor es verla con un ejemplo práctico. La Figura 4.2 muestra cuatro soluciones Si para un problema con dos objetivos, F1 y F2. En este caso, deseamos minimizar ambos objetivos. Podemos considerar que esas cuatro soluciones representan cuatro individuos de la población de un algoritmo genético. No estamos indicando cuántas variables tiene nuestro problema pero, en general, podemos considerar que tenemos n variables. Así, F1 = f (xn) e, igualmente, F2 = f (xn). En la Figura 4.2, cada Si corresponde con unos valores concretos de las variables xn. En este caso, no nos vamos a centrar en la representación de las variables, ya que estamos interesados en comparar soluciones en función de los objetivos del problema. Para saber qué soluciones están dominadas, debemos comparar de dos en dos las cuatro soluciones. ■Empecemos comparando s 1 y s 2; s 1 es mejor que s 2 en F 2, ya que s 1 tiene un valor de 10 en F 1, mientras que S 2 tiene un valor de 20. Sin embargo, con respecto a F 1, ambas soluciones tienen el mismo valor; por lo tanto, no podemos decir que ninguna de ellas sea estrictamente mejor que la otra en F 1. Por este motivo, no podemos decir que s 1 domine a s 2. A su vez, s 2 no domina a s 1. ■Continuamos ahora comparando s 1 y s 3; de nuevo s 1 es mejor que s 3 con respecto al objetivo F 1, pero no ocurre lo mismo con respecto a F 2, ya que ambas soluciones obtienen el mismo valor. Por lo tanto, tampoco podemos decir que s 1 domine a s 3, ni que s 3 domine a s 1. ■Podemos seguir haciendo comparaciones, pero pasemos a realizar la comparación más interesante para nuestro objetivo de obtener el frente de Pareto. Esto es, pasemos a comparar s 1 y s 4. En este caso, sí podemos decir que s 1 es estrictamente mejor que s 4 para los dos objetivos, F 1 y F 2. Por lo tanto, podemos decir que s 1 domina a s 4 o, lo que es lo mismo, que s 4 está dominada por s 1. ■Si seguimos haciendo comparaciones, llegaremos a la conclusión de que el caso de s 1 y s 4 es el único en el que podemos encontrar una solución que domina a la otra. Llegado a este punto, se puede afirmar que el frente de Pareto para nuestro ejemplo (según las cuatro soluciones que estamos considerando) está formado por las soluciones s1, s2 y s3. Podemos incluso trazar una curva que una esas tres soluciones y que nos dé la forma que tiene el frente de Pareto según las soluciones que tenemos. La Figura 4.3 muestra el frente de Pareto para las soluciones del ejemplo. Se puede observar que el frente de Pareto divide en dos la gráfica. Todas las soluciones que puedan aparecer en el lado derecho estarán dominadas por alguna solución del frente de Pareto. Por otro lado, si nos aparece una nueva solución en el lado izquierdo, esto hará que el frente de Pareto se actualice para contener dicha solución. Por lo tanto, cada vez que el algoritmo genético encuentre una nueva solución no dominada, el frente de Pareto deberá actualizarse. Figura 4.2. Ejemplo de soluciones para un problema con dos objetivos, F1 y F2. Figura 4.3. Frente de Pareto. Parece lógico pensar que un buen algoritmo genético multiobjetivo será aquel que nos permita obtener el frente de Pareto de una manera eficiente. El objetivo de un algoritmo genético multiobjetivo será encontrar de manera eficiente el frente Con el objetivo de ilustrar el concepto de frente de Pareto en un problema más complejo de dos dimensiones, la Figura 4.4 muestra el frente de Pareto para la función de benchmark ZDT1. Esa función tiene dos objetivos, ZDT11 y ZDT12, y el objetivo es minimizar ambos objetivos simultáneamente. En la Figura 4.4 se pueden ver, de nuevo, las zonas de soluciones que están dominadas y las zonas que no. Otro apunte que debemos realizar es que cada punto del frente de Pareto equivale a una solución del problema convertido a monobjetivo, tal y como se explicó anteriormente. Es decir, si seleccionamos un punto del frente de Pareto de la Figura 4.4, dicho punto representa la solución del problema equivalente monobjetivo F = w1 × f ZDT1 + w2 × f ZDT2, para unos valores determinados de w1 y w2. Por lo tanto, si el frente de Pareto que se obtiene es cercano al óptimo, este representará las soluciones del problema convertido a monobjetivo para todos los valores de w1 y w2. Figura 4.4. Frente de Pareto para la función de optimización ZDT1. No todos los frentes de Pareto serán como el mostrado en la Figura 4.4, ya que podemos encontrar de varios tipos. La Figura 4.5 muestra tres tipos de frente que podemos encontrarnos. El frente para el problema ZDT1 es tipo cóncavo, mientras que en el caso ZDT2 es de tipo convexo. Por último, el problema ZDT3 presenta un frente de Pareto discontinuo. En los problemas de optimización planteados en este libro podremos encontrar los tres tipos de frentes de Pareto mostrados en la Figura 4.5. Figura 4.5. Tipos de frente de Pareto. Antes de continuar, debemos destacar que en el caso del ejemplo que hemos visto, con dos objetivos, el frente de Pareto es una curva. Si tenemos tres objetivos, el frente de Pareto será una superficie 3D (un plano), y así sucesivamente conforme vayan aumentando el número de objetivos. Por lo tanto, para más de tres objetivos no podremos representar gráficamente el frente de Pareto. En estos casos, lo que se suele hacer es presentar las proyecciones de frente de Pareto dos a dos. En la siguiente sección estudiaremos el algoritmo NSGA-II que, sin lugar a dudas, es uno de los más utilizados en la actualidad para obtener el frente de Pareto en problemas de optimización con múltiples objetivos. 4.3 Selección del algoritmo genético Para este problema utilizaremos el algoritmo Non-Sorted Genetic Algorithm II (NSGA-II), sin lugar a dudas uno de los algoritmos genéticos multiobjetivo más utilizados en la actualizad (Deb et al., 2002). Las principales características del algoritmo genético NSGA-II se pueden resumir en dos puntos: ■En primer lugar, establece un mecanismo rápido para comparar soluciones. Hemos visto en la sección anterior que la comparación de soluciones es muy importante para obtener el frente de Pareto. ■En segundo lugar, establece una medida de distancia entre las soluciones. La idea es que las soluciones del Pareto estén bien distribuidas a lo largo de todo el frente de Pareto y, no acumular muchas soluciones en algunas zonas del frente. El mecanismo para comparar soluciones es extremadamente relevante para obtener el frente de Pareto de manera rápida y eficiente. Imaginemos que debemos comparar todos los individuos de una población de N individuos, comparando uno por uno con el resto de individuos de la población. Este procedimiento requeriría N2 operaciones. Si consideramos que tenemos M objetivos, el número de operaciones necesarios serían M × N2. Utilizando la notación Big-O, se representa que el algoritmo de comparación tiene una complejidad O(M × N2). Lo que estamos diciendo básicamente es que: i) el número de operaciones aumenta de manera cuadrática con el número de individuos de la población, y ii) que el número de comparaciones aumenta de manera lineal con el número de objetivos. Los algoritmos multiobjetivo tradicionales se basan en realizar un ranking de dominancia entre las soluciones o individuos de la población. Es decir, se ordenan los individuos de la población en función de su dominancia. En el primer nivel están las soluciones dominantes o, lo que es lo mismo, no dominadas por ninguna otra. En el segundo nivel estarían las soluciones que solo están dominadas por una solución, y así sucesivamente se pueden definir distintos niveles de dominancia. Este procedimiento utilizando un método de comparación O(N2) puede alcanzar una complejidad de O(M × N3), ya que en el peor de los casos tendremos tantos niveles como individuos en la población. El algoritmo NSGA-II propone un método para obtener el ranking de dominancia de manera eficiente. Así, para cada individuo p se obtienen dos parámetros: i) en primer lugar, se obtiene el número de individuos que dominan a la solución p, denominado np, y ii) se obtiene el conjunto Sp de soluciones que están dominadas por p. Este procedimiento tiene una complejidad O(M × N2). En el primer nivel, los individuos tendrán np = 0, y así sucesivamente. Los valores de np y los elementos Sp se obtienen mediante un doble bucle; es por ello que hacen falta M × N2 operaciones de comparación. Para más detalles sobre el procedimiento, se recomienda consultar la publicación original (Deb et al., 2002). En cuanto a la medida de distancia entre individuos, la idea es tener un mecanismo de selección de individuos dentro de un nivel de dominancia. En general, siempre vamos a preferir soluciones que estén en los primeros niveles de dominancia, a ser posible en el primer nivel, ya que son soluciones que no están dominadas (forman parte del frente de Pareto). Sin embargo, a lo largo de las generaciones del algoritmo genético, necesitamos un procedimiento de comparación y selección para aquellos individuos que estén en el mismo nivel de dominancia. El algoritmo NSGA-II define un medida de distancia basada en la densidad de soluciones en la vecindad de una solución. Para ilustrar el concepto de distancia, vamos a volver a utilizar nuestro ejemplo de la Figura 4.3. La Figura 4.6 muestra cómo se mide la distancia de una solución en un nivel de dominancia. En este caso, estamos considerando el frente de Pareto utilizado anteriormente; por lo tanto, estamos en el primer nivel de dominancia. En este caso sería el nivel 0, ya que estamos midiendo distancia entre soluciones del frente de Pareto. La métrica de distancia de un punto, por ejemplo el S1 en la Figura 4.6, se mide como la distancia entre las dos soluciones vecinas, en este caso S2 y S3. En nuestro ejemplo, como tenemos dos objetivos, tenemos una distancia Euclídea o norma 2, pero el procedimiento es el mismo para más dimensiones. Para los extremos del frente de Pareto la distancia es infinita. El cálculo de la distancia se debe realizar para todos los individuos de la población del algoritmo genético. La idea principal detrás de la métrica de distancia es la siguiente: si un individuo tiene una distancia pequeña significa que está en una zona de alta densidad de soluciones. En general, cuanto mayor sea la distancia, menos poblada será esa zona y más interesante será mantener dicha solución en la población. Figura 4.6. Métrica de distancia de una solución basada en densidad. En las siguientes secciones de este capítulo, se describirán dos problema de optimización con múltiples objetivos. En primer lugar, abordaremos un problema clásico como es el problema de la suma de subconjuntos y, en segundo lugar, trataremos un problema de benchmark. De esta forma abordaremos de nuevo un problema con variables discretas y otro con variables continuas. 4.4 El problema de la suma de subconjuntos con múltiples objetivos Como primer caso de problema de optimización con múltiples objetivos, vamos a resolver el problema de la suma de subcojuntos con dos objetivos. 4.4.1 Formulación del problema El problema de la suma de subconjuntos con dos objetivos se define de la siguiente forma: Como ocurrió con el problema del viajero, aunque la formulación del problema es simple, se puede demostrar que este problema es de tipo NP-completo; por lo tanto, no existe en la actualidad un algoritmo determinista que resuelva el problema en tiempo polinomial. Si quisiéramos resolver el problema probando todas las posibles soluciones, tendríamos que evaluar 2n combinaciones, siendo n el número de elementos en el conjunto original. En consecuencia, se necesita un tiempo exponencial que se hace inabordable para valores de n altos. En nuestro caso, el conjunto original va a venir dado por una lista de valores enteros Si comprendidos en un rango de valores. El valor c será un entero cualquiera comprendido entre cero y la suma de todos los elementos del S, ya que si el valor de c fuera mayor sería imposible encontrar una solución al problema. Veremos que tanto el tamaño del conjunto como el rango de valores será configurable. También será configurable el valor de c, siempre que cumplamos con la limitación expuesta anteriormente. En nuestro caso el problema lo vamos a considerar como multiobjetivo, ya que vamos a considerar los siguientes objetivos: ■Objetivo 1 - N o Elementos: Minimizar el número de elementos que componen el subconjunto, de manera que aquellos subconjuntos con menos elementos serán mejores. ■Objetivo 2 - Diferencia: Minimizar la diferencia con respecto al valor de c . Cuanto menor sea la diferencia, mejor será la solución. En el caso de que la suma sobrepase el valor de c , se aplicará la pena de muerte para penalizar las soluciones. En cuanto a la representación de los individuos, cada solución se representará como una lista de ceros y unos, de manera que las posiciones que contengan un uno serán elementos que se consideren en el subconjunto. Es decir, vamos obtener los xi de la formulación del problema. Hay que destacar que en este caso estamos trabajando con cromosomas binarios. 4.4.2 Definición del problema y generación de la población inicial Como en ejemplos anteriores, lo primero que debemos hacer es crear el tipo de problema y realizar funciones para crear individuos aleatorios. El siguiente script incluye las líneas de código necesarias. En este capítulo, vamos a centrarnos con más detalle en aquellos puntos que son nuevos para problemas con múltiples objetivos. Se puede ver que, en este caso, antes de definir el problema tenemos que definir algunas variables relativas al conjunto S: ■LIMITE_INF : Límite inferior del intervalo de los valores del conjunto S . ■LIMITE_SUP : Límite superior del intervalo de los valores del conjunto S . ■TAM_CONJUNTO : Número de elementos del conjunto S . ■SUMA_OBJETIVO : Valor objetivo de la suma c . ■CONJUNTO : Conjunto de valores S . Es importante indicar que, en este caso, la semilla del problema se ajusta antes para obtener siempre el mismo conjunto de valores. El Resultado 4.1 muestra los valores del conjunto S con el que trabajaremos en este capítulo. Resultado 4.1. Elementos del conjunto de datos del problema de la suma de subconjuntos. Si realizamos la suma con la función nativa de Python sum(), o mediante la función de la librería de numpy np.sum(), podemos ver que la suma total es de 435. Por lo tanto, no debemos elegir un SUMA_OBJETIVO inferior a 435. Se puede observar, en el script anterior, que se fijará el valor de 333. Hay que indicar que el conjunto se ha definido como un objeto de tipo ndarray de numpy para poder filtrar de manera eficiente las posiciones del individuo que contengan un uno, como veremos más adelante. 4.4.3 Definición del problema y plantilla del individuo Es muy importante observar que, en este caso, cuando estamos creando el problema, el atributo weights tiene una tupla con dos valores (–1,–1): Esa tupla significa que estamos definiendo un problema con dos objetivos y que queremos minimizar ambos objetivos. Con respecto a la definición de la plantilla del individuo, procedemos exactamente igual que en problemas anteriores. Así, el individuo se representará como una lista de valores binarios de longitud TAM_CONJUNTO. Si en la posición i del individuo se encuentra un 1, el elemento CONJUNTO[i] se tendrá en cuenta en el subconjunto. Insistiremos más adelante en esto cuando hablemos de la función objetivo. La Figura 4.7 ilustra la representación de la cadena cromosómica en este problema. Figura 4.7. Representación del individuo para el problema de suma de subconjuntos. A continuación, debemos definir una función que nos permita generar individuos aleatorios. En este caso necesitamos cadenas binarias de tamaño TAM_CONJUNTO. El siguiente script muestra una posible implementación. El parámetro de entrada size de la función crea_individuo será la variable TAM_CONJUNTO. De esta forma, la función es escalable a problemas de cualquier tamaño. Se ha utilizado una list comprenhension junto a la función random.randint(0,1) para generar una lista de 0s y 1s. Cabe recordar que cada 1 representa la inclusión del elemento correspondiente en el subconjunto. Para registrar dicha función en la caja de herramienta procedemos de la siguiente forma: Por último, las siguientes líneas registran las funciones para crear individuos aleatorios y la población inicial: Como la función crea_individuo permite crear cromosomas aleatorios, volvemos a utilizar tools.initIterate1. Un aspecto diferenciador con respecto a los capítulos anteriores (Capítulo 1 y Capítulo 2) es que en este caso no hemos definido el tamaño de la población en este momento en la función population. El tamaño de la población lo pasaremos más adelante en el main cuando definamos la población inicial. 4.4.4 Función objetivo y operadores genéticos A continuación, vamos a centrarnos en la función objetivo que vamos a utilizar en nuestro problema y en los operadores genéticos. El siguiente script muestra las líneas de código necesarias que se detallarán a continuación. Función objetivo La función objetivo, denominada funcion_objetivo, recibe dos parámetros de entrada. El primer parámetro es un individuo a evaluar, y el segundo parámetro es la suma objetivo que se quiere satisfacer. El primer paso de la función es realizar un filtrado del array CONJUNTO, utilizando para ello aquellas posiciones del individuo que contienen un uno. Como resultado, el objeto subconjunto contendrá aquellos elementos seleccionados para formar el subconjunto. Para obtener el sumatorio de los elementos seleccionados, se utiliza la función de numpy np.sum, y se almacena en el objeto suma_conjunto. El siguiente paso es calcular la diferencia entre la suma objetivo y la suma de los elementos que forman el subconjunto; el resultado se almacena en el objeto diferencia. Este es el primer objetivo del problema que queremos minimizar. En este punto ya podemos calcular el número de elementos que contiene el subconjunto. Utilizamos la función nativa sum2 para sumar el número de 1s en el individuo. Este es el segundo objetivo del problema que queremos minimizar. A continuación, debemos comprobar dos condiciones3: ■Lo primero que debemos comprobar es que la suma de los elementos del subcobjunto no sobrepasen el valor objetivo. ■En segundo lugar, debemos comprobar que el número de elementos no sea 0 ya que, entonces, no tendríamos ningún subconjunto. En el caso de que alguna de las condiciones ocurra, se debe penalizar la solución. Hemos optado por utilizar la pena de muerte, tal y como se explicó en el Capítulo 1. Para ello, lo que hacemos es devolver un valor muy alto para ambos objetivos, ya que el objetivo es minimizar ambos. Se ha elegido un valor de 10,000, que es mucho mayor que la mayor diferencia que podemos encontrar entre un subconjunto de valores y el valor objetivo. Por lo tanto, cualquier solución que no cumpla alguna de las condiciones será invalidada para participar en la operaciones genéticas. Una cosa importante que debemos destacar es que estamos devolviendo dos resultados. En los problemas con múltiples objetivos, la función objetivo deberá devolver tantos objetivo Operadores genéticos Una vez explicada la función objetivo, podemos pasar a los operadores genéticos. Para el cruce utilizaremos el operador cxTwoPoint, que es similar al cruce de un punto utilizado en el Capítulo 1, el cual debemos registrarlo en la caja de herramientas. En cuanto a la mutación, usaremos el operador mutBitFlip. Se ha seleccionado una probabilidad de 0.05 (indpb) para conmutar cada uno de los genes del individuo. De nuevo, es importante recordar que estas operaciones son transparentes para nosotros, ya que los operadores serán utilizados internamente por el algoritmo genético utilizado como caja negra. El siguiente paso es registrar el algoritmo NSGA-II, que se debe registrar como mecanismo de selección, ya que el NSGA-II realmente define un procedimiento de selección para generar los individuos de las siguientes generaciones. Por último, debemos registrar la función objetivo. En el registro indicamos el valor del parámetro suma_objetivo. 4.4.5 Últimos pasos: Ejecución del algoritmo multiobjetivo El siguiente script muestra el código necesario para ejecutar el algoritmo genético multiobjetivo, visualizar el frente de Pareto y almacenar los resultados en dos archivos de texto. A continuación, vamos a centrarnos en las partes de código que son nuevas con respecto a capítulos anteriores. 4.4.6 Configuración del algoritmo genético multiobjetivo Comenzaremos describiendo el contenido de la función main. En primer lugar, configuramos los hiperparámetros de los operadores genéticos: En este caso se ha elegido una probabilidad de cruce de 0.7, una probabilidad de mutación de 0.3 y un número de generaciones de 300. Se mostrará más adelante que con estos valores se pueden conseguir resultados satisfactorios. A continuación, asignamos valores a los parámetros µ y λ del algoritmo. Estos parámetros definirán el tamaño de la población inicial y de la población extendida. Como se explicó en el Capítulo 2, µ y λ se eligen con el mismo valor para que la población siempre tenga el mismo tamaño a lo largo de todas las generaciones del algoritmo genético. Es importante indicar que en este paso se está definiendo el tamaño de la población inicial al crearla. Esto es nuevo con respecto a capítulos anteriores. Por lo tanto, ambas formas son completamente válidas: i) podemos indicar el tamaño de la población al registrar la función population o ii) podemos indicar el tamaño de la población cuando se ejecuta el método toolbox.population. En ambos casos lo que estamos haciendo es asignar el valor del parámetro n de la función initIterate4. Otro aspecto que es nuevo para los problemas multiobjetivo es el objeto de tipo ParetoFront5. El objeto pareto almacenará todas las soluciones que forman el frente de Pareto. Internamente el algoritmo en cada generación actualizará (mediante el método update) los individuos que forman el frente de Pareto; ese proceso es trasparente para nosotros. A continuación, podemos lanzar el algoritmo genético µ + λ. La principal diferencia con respecto a capítulos anteriores es que como objeto hof se pasa el objeto pareto que se ha creado anteriormente. Al finalizar el algoritmo, ese objeto contendrá el frente de Pareto definitivo. Por último, es importante indicar que la función main ahora devuelve tres valores: la población final pop, el registro de evolución logbook y el frente de Pareto pareto. En cuanto a los resultados, en este caso se han vuelto a utilizar dos archivos para almacenar los resultados. El archivo individuosconjuntos.txt contendrá los individuos que forman el Pareto, y el archivo fitnessconjuntos.txt incluye el fitness· de los elementos del frente de Pareto. Se utiliza un bucle para guardar el frente de Pareto en los archivos .txt. Si echamos un vistazo a los resultados del almacenados en los archivos, podemos ver que en realidad hay varias soluciones que alcanzan el valor de suma objetivo. Podemos ver los resultado almacenados en el Texto 4.2. Tenemos un total de 21 soluciones en el frente de Pareto, aunque realmente como veremos más adelante en la Figura 4.8 solo se observan 15, ya que hay seis que se solapan en la gráfica. Esto era de esperar, ya que existen varias combinaciones de elementos del conjunto que suman el valor objetivo. Texto 4.2. Resultados almacenados en el archivo fitnessconjuntos.txt. Como ejemplo de solución que forma parte de Pareto, se muestra en el Texto 4.3 el último individuo del Texto 4.26. Cabe recordar que un 1 significa que el elemento correspondiente se incluye en el subconjunto. Texto 4.3. Resultados almacenados en el archivo fitnessconjuntos.txt. Representación del frente de Pareto A continuación, pasamos a representar el frente de Pareto obtenido. Para ello, utilizamos la función plot_frente, la cual lee el achivo de texto fitnessconjuntos.txt. Al tener soluciones discretas, el frente de Pareto se ha representado como un diagrama de dispersión utilizando la función scatter7. La Figura 4.8 muestra el frente de Pareto obtenido. Se puede ver cómo a medida que el número de elementos aumenta la diferencia con respecto a la suma objetivo disminuye, y viceversa. Figura 4.8. Frente de Pareto para el problema suma de subconjunto. La Figura 4.9 muestra el frente de Pareto con información adicional. En la gráfica de la izquierda se muestra la suma obtenida por cada conjunto incluido en el frente de Pareto. Se puede observar como la suma se va incrementando conforme más elementos se añaden en el subconjunto. Finalmente, en el extremo inferior derecho, se puede observar que la suma alcanza el valor deseado. En cuanto a la gráfica de la derecha, el frente de Pareto incluye los elementos incluidos en cada subconjunto. Se puede observar que hay varios subconjuntos que alcanzan el valor deseado. La función plot_frente necesaria para representar la información extra de la Figura 4.9 se muestra en el siguiente script. 4.4.7 Algunos apuntes sobre los algoritmos genéticos con múltiples objetivos Antes de terminar este capítulo, deberíamos realizar algunos apuntes con respecto a la configuración de los algoritmos genéticos multiobjetivo: ■En relación con las probabilidades de los operadores genéticos. En el caso de los algoritmos genéticos con un solo objetivo, se indicó que es conveniente realizar un barrido de las probabilidades de cruce y mutación. En el caso de los algoritmos multiobjetivo, también se puede realizar dicho barrido; no obstante, no es tan importante como en el caso de los monobjetivo. Se recomienda probar con probabilidades estándar, tales como probabilidad de cruce de 0.7 y probabilidad de mutación de 0.3. Si vemos que el frente de Pareto no incluye suficientes elementos, podemos probar con otras configuraciones. ■En principio, no hemos limitado el tamaño del frente de Pareto. Eso puede hacer que en algunos casos el frente de Pareto aumente mucho. El objeto Pareto permite que se defina una función de distancia, mediante el atributo similar , para limitar los individuos que se incluyen en el frente 8. El aumento del frente Pareto tiene también el problema añadido de que cada iteración del algoritmo tarda más, debido a que el algoritmo debe comparar cada nueva solución con todos los elementos de frente de Pareto. ■Un aspecto que no se ha tratado es la convergencia del algoritmo. En capítulos anteriores la convergencia de los algoritmos genéticos se comprobaba mediante la visualización de la evolución de la población. Cuando no se observaba cierta mejora en un número consecutivo de generaciones, se consideraba que el algoritmo había convergido. Sin embargo, en los problemas con múltiples objetivos no podemos utilizar dicho procedimiento, ya que el objetivo del conjunto de la población no es evolucionar hacia un mismo objetivo, sino encontrar soluciones no dominadas. Es por ello que en los problemas con múltiples objetivos se deben utilizar otro tipo de procedimientos. La forma más simple es ver que el frente de Pareto tiene suficientes soluciones. Es decir, que el frente de Pareto incluye un número de soluciones significativo en número y en cuanto al rango de valores de los objetivos. ■Aunque el número de objetivos no está limitado en principio, hay que tener en cuenta que con problemas con más de tres objetivos la representación gráfica del frente de Pareto no es posible. Además, se ha demostrado que para números elevados de objetivos el frente de Pareto crece muchísimo, ya que es prácticamente imposible encontrar soluciones dominadas. Los problemas de optimización con muchos objetivos ( many objectives optimization problems ) son un campo de investigación activo del que se puede encontrar amplia literatura (Ishibuchi et al., 2008) (Ishibuchi et al., 2014) (Li et al., 2015a). Figura 4.9. Frente de Pareto con información sobre la suma obtenida por cada solución y el conjunto resultante. 4.4.8 Código completo Pasamos a describir brevemente el código completo utilizado en el problema de la suma de subconjuntos: ■Las líneas 1-7 importan todas las librerías necesarias. ■La semilla de números aleatorios se fija en la línea 9 para obtener el mismo conjunto de valores. ■En las líneas 12-26 definimos los límites de los valores del conjunto (línea 12), el tamaño del conjunto (línea 14), la suma objetivo (línea 15), y creamos el conjunto con valores aleatorios (línea 16). El conjunto se crea como un array de numpy para facilitar el filtrado. ■En las líneas 20-21 se realiza la creación del problema multiobjetivo (línea 20), con dos objetivos de minimización. La plantilla del individuo será una lista (línea 21). ■En la línea 23 se define la caja de herramientas. ■Las líneas 26-27 definen una función para generar individuos aleatorios. Dicha función recibe como entrada el tamaño del conjunto de valores. Se devuelve una cadena cromosómica de unos y ceros. ■En las líneas 30-33 se realiza el registro de la función para generar muestras de los cromosomas de los individuos (línea 30), la función para generar individuos aleatorios (línea 31), y la función para generar la población inicial (línea 33). ■La función multiobjetivo se define en las líneas 37-54. Hay que tener en cuenta que tenemos que devolver dos objetivos. Si no se cumplen los objetivos se penaliza con pena de muerte (líneas 51 y 53). En cuanto a lo que devuelve la función, en primer lugar se devuelve el número de elementos y, en segundo lugar, la diferencia con respecto a la suma objetivo (línea 54). ■Los operadores genéticos se registran en las líneas 57-60. El operador de cruce es un cruce de dos puntos (línea 57), y para la mutación se utiliza el método mutFlipBit (línea 58). El proceso de selección es el algoritmo NSGA-II (línea 59). Por último, se registra la función de fitness (línea 60). ■Las líneas 62-75 definen la función plot_frente , la cual lee el frente de Pareto del archivo fitnessconjuntos.txt (línea 66) y representa el frente de Pareto como un diagrama de dispersión (líneas 67-75). ■La función main se define en las líneas 77-93. En primer lugar, se definen los hiperparámetros de los operadores genéticos, así como el número de generaciones del algoritmo (línea 78). A continuación, en la línea 79, se definen los parámetros µ y λ del algoritmo µ + λ . Con los valores elegidos, el tamaño de la población permanece constante a lo largo de las generaciones. La población inicial se genera en la línea 80. La línea 81 crea el objeto para calcular las estadísticas y, a continuación, en las líneas 82-85 se registran las funciones estadísticas. La línea 86 define el registro de evolución, aunque en este ejemplo no se ha utilizado. Es importante no olvidar que, en este caso, el objeto hof tiene que ser de tipo ParetoFront . Este objeto se define en la línea 87. La línea 88 lanza el algoritmo eaMupPlusLambda del módulo algorithms . El resultado es la población final pop y el registro de evolución logbook . Para terminar, la línea 93 devuelve la población final, el frente de Pareto y el registro de evolución. ■La líneas 97 y 98 crean archivos de texto para almacenar los resultados. El archivo individuosconjuntos.txt almacenará los individuos del frente de Pareto. Por otro lado, el archivo fitnessconjuntos.txt incluirá el fitness de los individuos del frente. ■El bucle de las líneas 99-105 recorre los elementos de objeto pareto y almacena los resultados en los archivos de textos creados. ■Las líneas 106 y 107 cierran los archivos para que los cambios se guarden. ■Por último, la línea 108 llama la función plot_frente para representar gráficamente el frente de Pareto obtenido. Código 4.4. Código completo para resolver el problema de la suma de subconjuntos. Antes de continuar, merece la pena comentar otros operadores genéticos que se podrían haber utilizado en el problema. En cuanto a la operación de cruce, se podría haber utilizado el cruce de un punto. Sin embargo, debido al tamaño de los cromosomas, parece más lógico utilizar el cruce de dos puntos. En cuanto a la mutación, también se podría haber optado por el método mutShuffleIndexes, el cual intercambia la información genética de dos genes. No obstante, si los genes seleccionados para el intercambio contienen la misma información, el individuo resultante sería el mismo. Así, el operador utilizado parece el más adecuado de los que tenemos disponibles en deap para cromosomas binarios. 4.5 Funciones de benchmark con múltiples objetivos En la Figura 4.4 se mostró el frente de Pareto para la función ZDT1; en esta sección vamos a describir el código necesario para obtener dicho frente de Pareto. Dicho código nos servirá también para obtener el frente de Pareto de otras funciones de benchmark, como ZDT2 y ZDT3. 4.5.1 Definición del problema y población inicial En primer lugar, se definen algunos parámetros del problema ZDT1, BOUND_LOW, BOUND_UP y NDIM. Los dos primeros son los límites inferior y superior de las variables. Por otro lado, NDIM representa el número de variables del problema. Los valores de estas variables se pueden ajustar para otras funciones de benchmark9. En este caso vamos a resolver el problemas para diez variables. A continuación, seguimos por la definición del problema multiobjetivo y la plantilla de los individuos. En este caso, como plantilla se utiliza la clase array10. Los objetos de esta clase son parecidos a los arrays de numpy. Sin embargo, su comportamiento con respecto a la operación de slicing es diferente (ver Apéndice A). Los array tienen el atributo typecode, que define el tipo de elementos que almacena. En este problema, el tipo es double. Por lo tanto, estamos definiendo un array de flotantes de tamaño doble. Para definir los individuos que forman la población inicial, se ha definido la función crea_individuo, que recibe como parámetros de entrada los límites inferior (low) y superior (up) de las variables y el tamaño de los individuos (size). Esta función debemos registrarla como siempre en el toolbox. Debido a que la función crea_individuo, registrada en el toolbox como attr_float, genera el individuo completo, se ha utilizado la función tools.initIterate para generar los individuos aleatorios. Para crear la población, se opera como siempre utilizando la función tools.initRepeat. En problemas donde las variables tengan unos rangos distintos, la función crea_individuo se debe adaptar a esas condiciones. El siguiente script muestra una posibilidad para generar individuos con variables con distintos rangos. Se puede observar que, en este caso, se ha dado por hecho que low y up son de tipo secuencia. Los valores de dichas secuencias podrían ser diferentes, para asignar un rango de valores distinto a cada variable del problema. 4.5.2 Función objetivo y operadores genéticos En esta ocasión, vamos a comenzar con el registro de los operadores genéticos, ya que la función objetivo está definida en el módulo benchmarks, por lo que no tenemos que definir nada. El cruce utilizado es el cxSimulatedBinaryBounded. Este mecanismo de cruce, como se explicó anteriormente, se basa en una operación matemática entre los genes de dos individuos, que obtiene un resultado parecido al cruce de un punto para cromosomas con variables binarias. El parámetro η determina cuánto se parecen los descendientes a los progenitores. En este caso se utiliza la variante Bounded, para limitar los valores que pueden tomar las variables. Hay que tener en cuenta que aunque en las operaciones de cruce en general realizan un intercambio de genes, el operador tools.cxSimulatedBinaryBounded realiza una operación matemática entre genes que puede dar lugar a nuevos que se salgan de los límites del espacio de búsqueda. Eso ocurre especialmente para valores de η pequeños. Es por ello que en este caso se limitan los valores máximos y mínimos de los resultados de cruce. En cuanto a la operación de mutación, se utiliza el operador tools.mutPolynomialBounded. En cuanto a la selección, se utiliza el algoritmo NSGA-II, basado en la Pareto dominancia y la métrica de distancia basada en densidad, para seleccionar los individuos que pasan a la siguiente generación. Finalmente, al igual que vimos con las funciones de benchmark con un solo objetivo, podemos registrar la función ZDT1 como benchmark.zdt1. Es importante destacar que el código mostrado también funcionaría con las funciones de benchmark ZDT2 y ZDT3; simplemente, debemos cambiar benchmark.zdt1 por benchmark.zdt2 o benchmark.zdt3. 4.5.3 Ejecución del algoritmo multiobjetivo El resto del código que nos falta para poder ejecutar el algoritmo se muestra en el siguiente script. Se puede observar que no hay notables diferencias con respecto al ejemplo anterior del problema de la suma de subconjuntos. Se ha elegido una población de 100 individuos (μ=100); teniendo en cuenta que tenemos 10 variables, es un tamaño adecuado. Si aumentamos el valor de NDIM, debemos aumentar también los valores de i μ y λ. En este caso, los archivos para guardar los resultados se denominan individuosmulti.txt y fitnessmulti.txt. El Texto 4.5 muestra los tres primeros individuos del frente de Pareto almacenado en fitnessmulti.txt11. Hay que indicar que el algoritmo encuentra 1948 soluciones que forman el Pareto. Es decir, soluciones que no están dominadas. Como se verá a continuación, es un número significativo de soluciones. Texto 4.5. Resultados almacenados en el archivo fitnessmulti.txt. Con respecto a los individuos, el Texto 4.6 muestra los cromosomas de las tres soluciones mencionadas anteriormente. Cabe indicar que esas tres soluciones corresponden a valores bajos del primer objetivo ZDT11 y valores altos del segundo objetivo ZDT12. Para las soluciones almacenadas al final del archivo fitnessmulti.txt ocurrirá al contrario. Esto es así porque los objetivos ZDT11 y ZDT12 son opuestos. Texto 4.6. Resultados almacenados en el archivo individuosmulti.txt. 4.5.4 Representación del frente de Pareto La Figura 4.10 muestra el frente de Pareto obtenido por el algoritmo genético basado en NSGA-II. Podemos ver que los resultados coinciden prácticamente con el Pareto óptimo. De hecho, es difícil distinguir uno del otro12. Figura 4.10. Comparación del frente de Pareto óptimo y obtenido por el algoritmo NSGA – II. Para representar dicha figura se ha utilizado de nuevo la función plot_frente, con algunas modificaciones que se muestran a continuación: La librería deap tiene disponible el frente de Pareto óptimo para la función ZDT1. Dichos valores óptimos se encuentran almacenados en un archivo JSON denominado zdt1_front.json. En el código anterior, en primer lugar, se lee el archivo fitnessmulti.txt para representar el frente de Pareto obtenido y, a continuación, se utiliza el archivo JSON para obtener el frente de Pareto óptimo. Ambos frentes de Pareto se representan con diagramas de dispersión. 4.5.5 Ajuste de los hiperpámetros de los operadores genéticos Para ilustrar mejor el funcionamiento de la técnica de mutación utilizada en este ejemplo mutPolynomialBounded, la Figura 4.11 representa 20 hijos creados con dicho operador. Se ha considerado un individuo original de solo dos dimensiones, x1 = 0.5 y x2 = 0.5 para poder visualizar mejor los resultados. Se puede observar cómo a medida que aumenta el valor eta los hijos se alejan del progenitor. No siempre se alejan de distinta forma ya que, como se detalló anteriormente, existe cierta aleatoriedad que define el polinomio de separación entre el padre y los hijos. Además, hay que tener en cuenta que dicha distancia también dependerá del parámetro indpb. En este ejercicio se ha utilizado el valor 1/NDIM. Por lo tanto, para NDIM = 2, tenemos indpb = 0.5. En el problema ZDT1 resuelto anteriormente se utilizó NDIM = 10 y, por lo tanto, indpb = 0.1. Figura 4.11. Individuos generados mediante mutación polinómica e individuo original para diferentes valores del parámetro η. Al igual que se ha comentado anteriormente con otros operadores, sería interesante modificar este operador en función del número de generaciones. No obstante, esta funcionalidad no está disponible en deap. 4.5.6 Código completo El siguiente Código 4.7 muestra el código completo del problema ZDT1 planteado en esta sección. A modo de resumen, se describen brevemente las líneas de código, indicando cómo se deben modificar para utilizar otras funciones de benchmark: ■Las líneas 1-10 incluyen las librerías necesarias. Cabe destacar el módulo array que permite definir vectores de manera nativa en Python . ■Las líneas 12 y 13 definen los límites de las variables ( BOUND_LOW y BOUND_UP ) y el número de variables del problema NDIM . En este caso las variables están comprendidas en el intervalo [0 , 1], y tenemos diez variables. En cada problema de benchmark los límites de las variables pueden ser distintos; por lo tanto, habrá que adaptarlos en cada caso. En cuanto al número de variables, normalmente este es ajustable, aumentado la complejidad del problema con el aumento del número de variables. ■Las líneas 16-18 definen, en primer lugar, el problema multiobjetivo, con dos objetivos de minimización Weights = (–1 ,– 1) (línea 16), y el tipo de individuo, que en este caso es un array nativo de Python (líneas 17-18). Para utilizar otras funciones incluidas en deap , se debe tener en cuenta el número de objetivos y el tipo (maximización o minimización) 13. ■La función crea_individuo (líneas 21-22) permite crear individuos aleatorios dentro del rango de las variables. A la función se le deben pasar tres argumentos: el límite inferior low , el límite superior up y el número de variables del problema size . En problemas en los que las variables tengan distintos rangos, la función se debe modificar, ya que los argumentos de entrada low y up serán secuencias con los límites de cada una de las variables. Anteriormente, se ha mostrado un ejemplo para este tipo de casos. ■El objeto caja de herramientas toolbox se crea en la línea 24. ■Las líneas 27-32 registran en el objeto toolbox las funciones necesarias para crear individuos aleatorios y la población inicial. ■Las líneas 35-41 registran los operadores genéticos y la función de evaluación. Para otras funciones de benchmark , la línea 41 se debe modificar, incluyendo la función que se desee. ■La función plot_frente se implementa en las líneas 43-59. Esta función permite representar el frente de Pareto obtenido por el algoritmo. ■La función main , que ejecuta el algoritmo genético, se define en las líneas 6178. Las probabilidades de cruce y mutación se definen en la línea 62, así como el número de generaciones. El tamaño de la población y los progenitores se definen con las variables µ y λ en la línea 63. La población inicial se crea en la línea 64. A continuación, el objeto estadística se define y se configura en las líneas 65-69. El registro de evolución se crea en la línea 70, aunque en este ejemplo no se utiliza. La línea 71 crea el objeto que almacenará el frente de Pareto. La línea 72 lanza el algoritmo genético y la línea 78 devuelve la población final, el registro de evolución y el frente de Pareto. ■La línea 81 ajusta el generador de números aleatorios. ■La línea 82 ejecuta la función main . Las líneas 83 y 84 crean los dos archivos de texto. El archivo individuosmulti.txt almacenará los individuos que forman el frente de Pareto. Por otro lado, el archivo fitnessmulti.txt almacenará el frente de Pareto. Ambos archivos se escriben en el bucle de las líneas 85-91. Finalmente, los archivos se cierran para guardar los cambios en las líneas 92 y 93. ■Por último, la línea 94 lanza la función plot_frente , que permite visualizar el frente de Pareto y compararlo con el óptimo. Código 4.7. Código completo para problemas de benchmark multiobjetivos. Antes de terminar esta sección, nos parece adecuado mencionar otros operadores genéticos que se podrían haber utilizado en este problema. En el caso del operador de cruce, se podría haber utilizado el cruce de un punto o de dos puntos. Sin embargo, para variables continuas suele funcionar mejor el operador Simulated Binary Crossover. Otra posibilidad es utilizar el operador de Blend; este caso se deja a los lectores como ejercicio. En cuanto a la mutación, se podría haber utilizado la mutación Gaussiana mutGaussian; no obstante, habría que comprobar que los genes de los individuos generados no se salgan de los límites de las variables. 4.6 Lecciones aprendidas En este capítulo las secciones aprendidas comprenden los dos ejemplos que se han visto, tanto el problema de la suma de subconjunto como las funciones de benchmark. En cuanto a las lecciones aprendidas, podemos citar las siguientes: ■En esta sección hemos estudiado los problemas con múltiples objetivos. Hemos visto que la conversión de un problema con múltiples objetivos en un problema con un solo objetivo mediante la utilización de pesos tiene algunas limitaciones. ■Como alternativa, se ha presentado la dominancia de Pareto y los algoritmos multiobjetivo basados en dicha dominancia. ■Se ha estudiado en profundidad el algoritmo NSGA-II basado en la dominancia de Pareto y la métrica de distancia basada en densidad. Para demostrar el funcionamiento del algoritmo NSGA-II se han resuelto dos problemas. En primer lugar, un problema clásico como es el problema de la suma de conjuntos (considerando dos objetivos) y, en segundo lugar, una función de benchmark con múltiples objetivos. ■Se ha mostrado que en problemas con dos objetivos el frente de Pareto es una curva. Por otro lado, en problemas con tres objetivos el frente de Pareto es una superficie. El frente de Pareto en problemas con más de tres objetivos no puede ser representado de manera gráfica (se pueden representar las proyecciones dos a dos). Además, cuando el número de objetivos es muy elevado no podemos utilizar el algoritmo NSGA-II , ya que prácticamente todas las soluciones que se encuentren no serán dominadas. La librería deap incluye el algoritmo NSGA-III 14, el cual es relativamente reciente, y cuyo estudio se sale fuera del alcance de este libro (Deb y Jain, 2014). ■No todos los frentes de Pareto tienen la misma forma. Podemos encontrar frentes de Pareto continuos y discontinuos, así como cóncavos, convexos y no convexos. ■Los problemas con múltiples objetivos siempre devuelven una tupla, con tantos elementos como objetivos tenga el problema. ■Hemos aprendido que el tamaño de la población se puede pasar de dos maneras. Si tenemos muy claro el tamaño que queremos utilizar, lo debemos incluir cuando se registre la función population en la caja de herramientas. Como segunda opción, podemos incluir el tamaño de la población en la función main cuando creemos la población inicial, pasando como parámetro a toolbox.population(N) el tamaño de la población N . ■Si se aplica la pena de muerte en un problema debido a que una solución no cumple las restricciones, se deben penalizar todos los objetivos. ■Se ha aprendido a utilizar la mutación polinómica con límite tools.mutPolynomialBounded . Esta técnica de mutación se utiliza con variables continuas comprendidas entre dos valores límite. La mutación tiene un parámetro eta que determina la distancia de los mutantes con respecto a los individuos originales. Cuanto mayor sea el valor de eta , menor será la distancia, y viceversa. ■En los problemas con múltiples objetivos no se puede estudiar la convergencia del algoritmo en función del fitness de los individuos, ya que no existe un solo criterio. Por lo tanto, la configuración de las probabilidades de cruce y mutación se debe realizar mediante la visualización del frente de Pareto. Por ejemplo, si en un problema con objetivos continuos vemos discontinuidades, es una muestra de que el algoritmo genético no ha explorado/intensificado lo suficiente en el espacio de búsqueda. En estos casos debemos probar con distintas probabilidades de cruce y mutación. 4.7 Para seguir aprendiendo La literatura sobre algoritmos genéticos mulitobjetivo es amplia y variada, hasta el punto de que hoy en día sigue siendo un tema puntero de investigación. A continuación, se incluyen algunas referencias para seguir profundizando en esta dirección: ■Más información sobre la resolución del problema de la suma de conjuntos con algoritmos genéticos se puede encontrar en (Wang, 2004) y (Li et al., 2015b). ■Como bibliografía básica sobre algoritmos genéticos multiobjetivo se recomienda (Coello, 2006), (Coello et al., 2007) y (Bechikh et al., 2016). ■Para profundizar sobre problemas con muchos objetivos ( many-objective genetic algorithms ), se puede consultar (Ishibuchi et al., 2008) y (Ishibuchi et al., 2014). Como ejercicios se plantean los siguientes: ■Utilizar el operador de cruce cxUniform 15 en el problema de la suma de conjuntos y comparar los resultados con el operador de cruce de uno y dos puntos. Realizar un barrido de probabilidades de cruce y mutación para hacer una comparación justa entre los distintos operadores. ■Cambiar el tamaño del conjunto de datos y la suma objetivo. Comprobar que se consiguen resultados satisfactorios para varios valores. ■Utilizar el Código 4.7 para optimizar otras funciones de benchmark , por ejemplo las funciones ZDT2 y ZDT3 . _________________ 1El Capítulo 2 explica las diferencias entre tools.initRepeat y tools.initIterate. 2https://docs.python.org/3.6/library/functions.html#sum 3El orden no tiene por qué ser ese. 4Se recomienda ir al Capítulo 2 si no se tiene claro dicho parámetro. 5https://deap.readthedocs.io/en/master/api/tools.html#deap.tools.ParetoFront 6Para su correcta visualización se ha dividido en dos líneas de texto. En el archivo original aparece en una sola línea. 7https://matplotlib.org/3.2.1/api/_as_gen/matplotlib.pyplot.scatter.html 8https://deap.readthedocs.io/en/master/api/tools.html#deap.tools.ParetoFront 9https://deap.readthedocs.io/en/master/api/benchmarks.html 10https://docs.python.org/3/library/array.html 11No se muestra todo el archivo ya que es bastante grande. 12Se han utilizado distintos tamaños en los marcadores para poder diferenciarlos. 13https://deap.readthedocs.io/en/master/api/benchmarks.html 14https://deap.readthedocs.io/en/master/api/tools.html#deap.tools.selNSGA3 15https://deap.readthedocs.io/en/master/api/tools.html#deap.tools.cxUniform II Parte 2: Algoritmos genéticos para ingeniería 5Funcionamiento óptimo de una microrred 5.1 Introducción 5.2 Formulación del problema 5.3 Problema con un objetivo: Minimizar el coste de operación 5.4 Problema con múltiples objetivos: Minimizando el coste de operación y el ciclado de la batería 5.5 Código completo y lecciones aprendidas 5.6 Para seguir aprendiendo 6Diseño de planta microhidráulica 6.1 Introducción 6.2 Formulación del problema 6.3 Problema con un objetivo: Minimizando el coste de instalación 6.4 Problema con múltiples objetivos: Minimizando el coste de instalación y maximizando la potencia generada 6.5 Código completo y lecciones aprendidas 6.6 Para seguir aprendiendo 7Posicionamiento de sensores 7.1 Introducción 7.2 Formulación del problema 7.3 Problema con un objetivo: Maximizando el número de puntos cubiertos 7.4 Problema con múltiples objetivos: maximizando el número de puntos cubiertos y la redundancia 7.5 Código completo y lecciones aprendidas 7.6 Para seguir aprendiendo 5.1 Introducción Un sistema eléctrico de potencia se define como una red formada por varios componentes que permiten generar, transportar, almacenar y usar la energía eléctrica. Los sistemas eléctricos tradicionales tienen una arquitectura centralizada, cuyos componentes pueden ser principalmente clasificados como: ■Sistemas de generación: Aportan energía eléctrica al sistema. ■Sistemas de transmisión: Permiten el transporte de esta energía a través de la red eléctrica hacia los centros de consumo. ■Sistemas de distribución: Se encargan de alimentar a las cargas del sistema. El auge de las energías renovables y su integración en la red eléctrica está provocando una evolución de los sistemas eléctricos que, gradualmente, van transformando su estructura tradicional hacia una arquitectura distribuida. En esta nueva arquitectura, los generadores renovables (habitualmente de menor potencia que las máquinas convencionales) se encuentran en cualquier punto de la red (incluso en el sistema de distribución), disminuyendo así la distancia entre los puntos de generación y consumo y, con ello, disminuyendo las pérdidas del sistema y aumentando el rendimiento del mismo. Así, es posible llevar a cabo una partición del sistema eléctrico que facilite su análisis y operación. Cada una de estas particiones es lo que denominamos microrred. Una microrred no es más que un sistema de generación eléctrica bidireccional, que permite la distribución de electricidad desde los proveedores hasta los consumidores utilizando tecnología digital y favoreciendo la integración de las fuentes de generación de origen renovable. Las microrredes tienen el objetivo fundamental de ahorrar energía, reducir costes e incrementar la fiabilidad del sistema. Es importante destacar que estas microrredes pueden ser operadas tanto de forma aislada (utilizando la energía generada localmente para alimentar las cargas del sistema) como conectadas a una red de mayor potencia (en este caso es posible cubrir excesos de carga o generación mediante la energía proveniente de una red de mayor potencia a la que la microrred se encuentra conectada). Un aspecto fundamental en el estudio de sistemas eléctricos es que toda la potencia generada debe ser consumida de forma instantánea por las cargas en la red o almacenada en sistemas de almacenamiento. La existencia de un desfase entre la potencia generada y la consumida/almacenada puede provocar problemas transitorios en la red que comprometan tanto su estabilidad como la continuidad y calidad del suministro eléctrico. Figura 5.1. Microrred considerada. Este capítulo aborda el problema de operación óptima de una microrred mediante el uso de algoritmos evolutivos, como veremos más adelante. El problema en cuestión se suele presentar en la literatura bajo el nombre de «despacho económico». El despacho económico consiste en determinar a corto plazo la producción óptima de varias instalaciones de generación de electricidad, de manera que se satisfaga la demanda del sistema optimizando una función objetivo y cumpliendo las restricciones de operación. En este capítulo trataremos la optimización desde el punto de vista de un único objetivo, minimizando el coste de producción necesario para abastecer la demanda eléctrica, y desde un enfoque multiobjetivo en el que, además, se tendrá en cuenta la posible degradación por ciclado de un sistema de almacenamiento. 5.2 Formulación del problema Con el fin de proporcionar un ejemplo que permita poner en pie los conceptos a explicar, consideraremos la microrred presentada en la Figura 5.1. Esta red se encuentra operando de forma aislada; es decir, sin conexión con cualquier otra red de mayor potencia. Esto significa que, para garantizar la estabilidad en el suministro, toda la potencia producida debe ser consumida o almacenada de forma instantánea. La microrred considerada cuenta con los siguientes componentes: 1. Un generador eólico. 2. Una planta de generación fotovoltaica. 3. Un generador diésel. 4. Una microturbina. 5. Un sistema de almacenamiento de energía. 6. Una zona residencial que actúa como carga en nuestro sistema. En este punto realizaremos una simplificación importante. Dado que la estabilidad eléctrica de la microrred queda fuera del alcance de este libro, consideraremos que tanto generadores como cargas y sistemas de almacenamiento trabajan a la misma tensión y que, además, las pérdidas a través de las líneas son despreciables. Por lo tanto, el objetivo principal de nuestro problema será el de cubrir para cada instante de tiempo la demanda de potencia de la zona residencial mediante una gestión óptima de los cuatro generadores considerados y del sistema de almacenamiento de energía. Para el ejemplo objeto de estudio, nos marcaremos como objetivo la optimización del funcionamiento de la microrred durante un periodo de un día entero, conocidos los datos de demanda y generación en cada hora. En cuanto a los generadores, es importante destacar que pueden dividirse en dos grupos principales: las 5.2 Formulación del problema unidades despachables y las no despachables. Una unidad despachable es aquella que permite ajustar la potencia de salida de manera arbitraria, dentro de unos límites de operación. Este es el caso del generador diésel y la microturbina, ya que permiten regular la potencia de salida controlando el flujo de entrada de combustible. Por otro lado, en nuestra microrred existen dos unidades no despachables: el generador eólico y la planta fotovoltaica. En este caso la producción vendrá determinada por las condiciones meteorológicas. Aunque hoy en día es posible aplicar estrategias de control para obtener una potencia de salida controlada, consideraremos que esta no puede controlarse y que, por tanto, vendrá impuesta por dichas condiciones. A continuación definiremos cada uno de los componentes de la red por separado, incluyendo la notación que utilizaremos en el resto del capítulo. Por último, plantearemos formalmente el problema a resolver. 5.2.1 Recursos renovables Como ya introdujimos anteriormente, consideraremos dos recursos renovables: una turbina eólica y una planta de generación fotovoltaica. Ambas unidades se consideran no despachables; es decir, su generación de potencia no puede ajustarse libremente sino que viene impuesta por las condiciones meteorológicas en cada momento. Así, denominaremos PPV,t y PWT,t a las potencias instantáneas generadas por la planta fotovoltaica (PV) y la turbina eólica (WT) en el instante t, respectivamente. La Figura 5.2 muestra los perfiles de generación durante 24 horas de ambas unidades que consideraremos en nuestro problema. Dichos perfiles han sido generados de forma aleatoria, pero teniendo en cuenta el funcionamiento habitual de cada uno de estos generadores. Así, se presentan dos modelos simplificados que nos permiten obtener la potencia de salida de ambos generadores a partir de la radiación (en el caso de la fotovoltaica) y de la velocidad del viento (en el caso de la turbina eólica: ■Generador fotovoltaico (Lasnier y Ang, 1990): La potencia de salida en el instante t , P PV,t , puede ser calculada a partir de comparar la irradiancia y la temperatura de los módulos fotovoltaicos en dicho instante, ( E M,T y T M,t respectivamente) respecto a los valores bajo condiciones de testeo estándares: P STC , E STC , T STC . donde n representa el número de módulos que componen la instalación. ■Generador eólico (Deshmukh y Deshmukh, 2008): La potencia de salida en el instante t , P WT,t , se obtiene a partir de la velocidad del viento v , las velocidades mínimas y máximas ( v ci y V co ) y la potencia y velocidad de viento nominal del generador ( P r y v r ): Así, supondremos que el generador eólico se encuentra siempre trabajando con velocidades de viento entre vci y vco y, por tanto, la potencia aportada es siempre positiva con unos valores que fluctúan entre 40 y 80kW. Por otro lado, el generador fotovoltaico no produce potencia durante las horas de noche, mientras que alcanza su producción máxima en las horas centrales del día. Para definir los perfiles de potencia y generar la Figura 5.2, basta con utilizar las siguientes líneas de código: Figura 5.2. Generación del sistema fotovoltaico y la turbina eólica para el periodo de tiempo considerado de 24h. Se puede observar, que en ambos casos se han utilizado arrays de numpy, con los valores de los perfiles de generación de la Figura 5.2. La longitud de dichos arrays es de 24 elementos, uno por hora del día. 5.2.2 Unidades despachables Las unidades despachables (cuya potencia de salida puede ser controlada) consideradas en nuestro problema son el generador diésel y la microtrubina. Ambas reciben como entrada un flujo de combustible (diésel y gas, respectivamente) y como resultado proporcionan una potencia de salida que denotaremos como PDE,t y PMT,t donde DE hace referencia al generador diésel, MT a la microturbina y t al instante de tiempo considerado. La potencia de salida de ambas unidades se encuentra restringida a una banda de operación: donde son los límites inferiores de operación y son los límites superiores de operación de cada una de las unidades. Debemos tener en cuenta que los límites de operación vienen marcados por los límites físicos de funcionamiento del generador y, por tanto, son independientes del instante de tiempo en el que nos encontremos. A continuación, definimos los límites de operación en nuestro programa, así como una función que evalúe si la solución proporcionada por el algoritmo genético cumple o no las restricciones establecidas. En caso de no cumplirse, dicha solución será penalizada con pena de muerte. La pena de muerte, como ya hemos introducido en capítulos anteriores, consiste en la asignación de un valor elevado a la función objetivo o función de fitness, con el fin de descartar la solución y que dicho individuo no pueda ser seleccionado para participar en las operaciones genéticas. Todo esto puede observarse en el siguiente fragmento de código: Por otro lado, y a diferencia de lo que ocurre con los recursos renovables, estas unidades necesitan un combustible como entrada para poder funcionar, cuyo coste debe ser considerado. Así, consideraremos que el coste de combustible viene dado por las siguientes expresiones: donde hemos utilizado CDE,t y CMT,t para expresar el coste de combustible del generador diésel y la microturbina en el instante t, respectivamente. Hay que tener en cuenta cómo el coste de combustible es expresado como una función dependiente de la potencia de salida; tenemos un primer término fijo, un segundo término proporcional a la potencia y un tercero cuadrático con esta. Ambos costes los introduciremos en nuestro programa mediante funciones. Estas funciones recibirán como entrada la potencia entregada por el generador y devolverán como salida el coste de funcionamiento. Para los parámetros dDE, eDE, fDE, dMT, eMT y fMT se han tomado los valores presentados en (Alvarado-Barrios et al., 2019). El código desarrollado es el siguiente: Observe cómo el coste de operación únicamente actúa si el generador está funcionando, siend La Figura 5.3 muestra el coste de funcionamiento de ambos generadores para todos los valores de potencia que pueden ofrecer. Como se puede observar, el generador diésel es la solución más económica para bajas potencias, mientras que la microturbina comienza a ser más adecuada cuando se requieren potencias superiores a los 55kW. El fragmento de código utilizado para generar la Figura 5.3 es el siguiente: 5.2.3 Sistema de almacenamiento de energía Con el fin de dotar de flexibilidad a nuestra microrred, consideraremos además un sistema de almacenamiento de energía conectado a la misma. Para este ejemplo, asumiremos que dicho equipo es una batería que se encuentra conectada a la red mediante un convertidor de potencia. Sin embargo, para simplificar el problema intentaremos abstraernos de la configuración adoptada. Por lo tanto, nuestra batería estará caracterizada por tres parámetros principales: Figura 5.3. Coste de combustible del generador diésel y la microturbina en función de la potencia entregada. 1. Capacidad ( SOC max ): Máxima energía que puede ser almacenada en la batería. Expresaremos dicha energía en kWh . 2. Máxima capacidad de descarga Máximo valor de potencia que puede ser entregado por la batería en un instante determinado. Dicho valor será expresado en kW . 3. Máxima capacidad de carga Máximo valor de potencia que puede ser absorbido por la batería en un instante determinado. Este valor será también expresado en kW . Además, es necesario considerar que la batería será tratada como un sistema dinámico, es decir, un sistema que evoluciona en el tiempo dependiendo de su estado anterior y la potencia entregada o demandada. Así, definimos el estado de carga en un instante t (SOCt) como la cantidad de energía almacenada en la batería en dicho instante expresada en kWh. De esta forma, el estado de carga de la batería se encuentra acotado en todo momento por la capacidad de la misma: 0 ≤ SOCi ≤ SOCmax, donde SOCmax es la capacidad de la batería. Existen multitud de baterías con diferentes características. En nuestro caso, hemos escogido u Por otro lado, como hemos mencionado, la batería es un sistema dinámico cuyo estado de carga evoluciona con el tiempo de acuerdo al estado previo y a la potencia entregada/absorbida en dicho instante por la misma. Así, dicha evolución viene dada por la siguiente expresión: SOCt+1 = SOCt – PESS,tΔt, donde PESS,t es la potencia entregada/demandada por la batería en el instante t y Δt es el periodo de tiempo expresado en horas entre t y t + 1. Debemos tener en cuenta que PESS,t puede tomar tanto valores negativos como positivos. En el caso de que PESS,t > 0 quiere decir que la batería se encuentra suministrando potencia a la red y, por consiguiente, el estado de carga en el instante siguiente debe ser inferior al actual (SOCt+1 < SOCt). Por otro lado, si PESS,t < 0 la batería actúa demandando energía y se carga, es decir, SOCt+1 > SOCt. Por último, si consideramos la máxima capacidad de carga/descarga de la batería, podemos establecer los siguientes límites: Al igual que en secciones anteriores, hemos intentado simplificar el problema lo máximo posi De acuerdo con las restricciones introducidas anteriormente, desarrollaremos dos funciones. La primera recibirá como entrada un vector con los valores de potencia entregados/demandados por la batería durante el periodo considerado, así como el estado de carga inicial de la batería. A partir de dichos valores, esta devolverá un vector con los estados de carga de la batería en dicho periodo. Dado que, como veremos más adelante, la capacidad de la batería se fijará en 280kWh, tomaremos como estado de carga inicial 140kWh con el fin de dar al problema suficiente flexibilidad como para cargar o descargar la batería en los primeros instantes de tiempo. A continuación, se muestra el fragmento de código correspondiente a la definición de dicha función: Por otro lado, será necesario construir una función que permita al algoritmo genético evaluar la factibilidad de las soluciones. Esta función deberá evaluar si las restricciones se cumplen para dicha solución. En caso de cumplirse, devolverá un cero. En caso contrario, se penalizará dicha solución con pena de muerte otorgándole un valor muy grande (dicho valor será posteriormente utilizado como solución de la función objetivo o de fitness, como ya veremos más adelante). 5.2.4 Balance de potencia Además de todas las restricciones de operación introducidas en los apartados anteriores, el balance de potencia se debe cumplir en todo momento. Esto significa que la cantidad de energía generada debe coincidir con la demandada más la almacenada en todo instante de tiempo. Dicha afirmación puede expresarse como: PDM,t = PPV,t + PWT,t + PMT,t + PDE,t + PESS,t, donde PDM,t es la potencia demandada por la zona residencial en el instante t. Consideraremos el siguiente perfil de potencia demandada: Dicha curva de demanda tiene la forma típica de la curva de consumo diario en España. Sin embargo, sus valores han sido escalados con el fin de poder ser cubierta por los generadores considerados en nuestro problema. Debemos destacar que estos valores son accesibles desde la página web de Red Eléctrica Española (REE) a través de la siguiente dirección: https://demanda.ree.es/visiona/home. 5.3 Problema con un objetivo: Minimizar el coste de operación Si bien es cierto que las ecuaciones introducidas hasta ahora definen completamente el problema, es evidente que existen multitud de soluciones para el mismo, según se controlen las unidades despachables y el sistema de almacenamiento de energía. Entonces, parece lógico definir una función que indique el objetivo principal que se quiere cumplir cuando se trata de operar de forma óptima la microrred. Para ello, nuestro principal objetivo va a ser reducir el coste de combustible de las unidades despachables. Es decir, la función objetivo puede ser definida como: Así, la finalidad de nuestro algoritmo será obtener una planificación óptima de la generación tanto de las unidades despachables como del sistema de almacenamiento, con el fin de minimizar el coste de operación. Para ello, se tendrán en cuenta todas las restricciones operacionales introducidas anteriormente. Con toda la información introducida anteriormente, podemos definir el problema de optimización como sigue: Observe la gran cantidad de restricciones que presenta el problema; eso hace que su resolución a través del uso de herramientas que usen métodos convencionales para la resolución de problemas de optimización sea muy complicada. De esta forma, queda aún más justificado el uso de algoritmos genéticos para resolver el problema. 5.3.1 Definición del problema y generación de la población inicial Llegados a este punto, es conveniente definir cómo va a ser nuestro individuo. Como hemos ido razonando a lo largo del capítulo, el algoritmo debe ajustar de forma óptima los valores de potencia entregados por el generador diésel, la microturbina y el demandado/aportado por el sistema de almacenamiento. Es decir, si consideramos un periodo de 24 horas con datos cada hora, se deben calcular 72 valores. Sin embargo, si definimos el individuo como un vector de 72 valores, será muy complicado generar una solución que cumpla el balance de potencias, ya que deben generarse valores para cada hora que hagan que el sumatorio de potencias sea igual a la potencia demandada en cada instante. Así, la solución que adoptaremos será la de definir a cada individuo como un vector de 48 genes, como se muestra en la Figura 5.4. Los 24 primeros corresponderán a la potencia entregada por el generador diésel durante las 24 horas, mientras que los genes desde el 24 en adelante, corresponderán a la potencia aportada por la microturbina. Figura 5.4. Representación genética de las soluciones del problema. Usando esta estrategia para representar a los individuos, la potencia de la batería podrá ser deducida a partir de las dos anteriores mediante un balance de potencia. Este razonamiento se refleja en el siguiente código: donde la función crea_individuo se encarga de generar un individuo de forma aleatoria, y será introducida a continuación. Pasamos ahora a definir el problema y la plantilla que utilizaremos para representar los individuos en Python. Como vemos, se trata de un problema de minimización en el que los individuos estarán definidos mediante un array de numpy. El siguiente paso es definir la función crea_individuo. Como ya hemos mencionado, cada uno de los individuos estará formado por 48 genes (ver Figura 5.4), que se corresponden con los niveles de generación de potencia de las dos unidades despachables durante el periodo de 24 horas considerado. Así, es necesario definir una función que genere individuos de forma aleatoria y que cumplan, en la medida de lo posible, con las restricciones impuestas en el problema. Existen multitud de alternativas para llevar a cabo este propósito. En este apartado introduciremos una posible solución cuyo buen funcionamiento hemos validado previamente. Así, usaremos el siguiente código: Observe que se comienza asignando valores de potencia al generador diésel, dado que corresponde a los primeros genes de cada individuo. Sin embargo, se podría haber comenzado asignando valores a la microturbina y, posteriormente y basándonos en dichos valores, asignar las potencias restantes que corresponderán al generador diésel. La solución propuesta se basa en: 1. Generamos un perfil de potencia para el generador diésel de forma aleatoria. Para ello, imponemos que la potencia entregada por dicho generador debe estar entre el valor mínimo de generación y el mínimo entre el valor máximo y la potencia demandada una vez considerado el aporte de las unidades renovables. En caso de que la demanda sea menor que el aporte de las energías renovables, apagaremos la unidad despachable. 2. A partir del perfil creado para el generador diésel, obtenemos las potencias entregadas por la microturbina, de tal modo que la batería no entre en funcionamiento, es decir, que P ESS,t = 0 , t (tengamos en cuenta que buscamos una solución factible y no óptima). 3. Evaluamos las potencias entregadas por la microturbina de tal manera que estén dentro de sus límites de operación. En caso de que la potencia entregada se encuentre por debajo del límite inferior, se fijará en cero y el resto de energía será suministrada por la batería. Por otro lado, si la potencia es mayor al límite superior, se fijará en su capacidad máxima y el exceso de potencia será absorbido por la batería. Para registrar las funciones que nos permiten crear individuos aleatorios y la población inicial, procedemos como hemos visto a lo largo del libro. Debemos indicar que la población inicial es generada mediante tools.iterate, ya que nuestra función generará individuos completos y no genes del mismo1. 5.3.2 Operadores genéticos Una vez definido el problema, es hora de desarrollar las funciones que utilizará nuestro algoritmo para realizar las operaciones genéticas. Comenzaremos por la operación de cruce. En este caso utilizaremos un cruce de dos puntos. Dicha operación requiere seleccionar dos puntos en los individuos de la generación previa. Todos los datos entre los dos puntos se intercambian entre ambos individuos, creando dos hijos (nueva generación). Tanto la elección de ambos puntos como la de si llevar a cabo o no la operación genética se realiza de forma aleatoria. A continuación, seguimos con el operador de mutación. Esta función realizará la operación de mutación a los individuos de acuerdo a sucesos aleatorios. Para evolucionar desde una generación a otra, el algoritmo aplicará operaciones de mutación en los genes de ciertos individuos. Dichas mutaciones deben realizarse siguiendo un criterio adecuado con el fin de garantizar pequeños movimientos que permitan obtener mejores individuos en posteriores generaciones del algoritmo. En este problema, se plantea una estrategia ajustada al mismo. Es decir, no se utiliza un función de la librería deap. Esta propuesta se basa en un análisis previo para el que se han obtenido resultados positivos; no obstante, como ya se ha comentado anteriormente, puede ser modificada libremente. ■Se propone mutar genes variando su valor de acuerdo a una distribución normal cuya media es el valor actual del gen y cuya desviación típica es ajustable (mutación Gaussiana). Por ejemplo, considere que en un instante determinado el generador diésel está entregando 40 kW . Entonces, parece lógico variar esta cantidad en el entrono de dicho valor, realizando una pequeña modificación que repercutirá en la optimalidad del nuevo individuo. La variación será más acentuada conforme mayores valores se asignen a la desviación típica. Para nuestro problema consideraremos un valor de desviación típica igual a 30. ■Por otro lado, existe la posibilidad de que la unidad esté apagada y, por tanto, la potencia entregada sea nula. Es imposible conseguir a partir de la mutación anterior un valor igual a cero, por lo que se propone además una mutación que establezca a cero un determinado gen. Así, nuestro operador de mutación puede escribirse como se muestra a continuación: donde indpb es una tupla de dos componentes que establece la probabilidad en tanto por uno de que mute un gen (ya sea variando la potencia generada o apagando el generador). El último paso, con respecto a la operación de mutación, es registrarla en la caja de herramientas. En cuanto al mecanismo de selección, se ha utilizado la selección mediante torneo. El torneo consiste en escoger un número determinado de individuos de la última generación del algoritmo, comparar sus valores de función de fitness, y escoger aquella con menor valor. La última función que nos queda por registrar, es la función de fitness. En este problema y debido a que esta es más compleja, la veremos en una sección aparte. 5.3.3 Función objetivo La función objetivo o de fitness recibe como entrada un individuo, lo evalúa y devuelve un valor de acuerdo a la calidad del mismo. Dicho valor puede ser igual al coste de combustible (en el caso de que el individuo cumpla todas las restricciones de operación) o bien puede adoptar el valor de la penalización (en el caso de que alguna de las restricciones no se cumpla). De este modo, simplemente hay que evaluar todas las funciones desarrolladas en los apartados anteriores en el orden adecuado. Es decir, si un individuo no cumple las restricciones del problema, no es necesario evaluar su valor de coste de combustible, ya que esa solución será descartada. Así, la función objetivo será la siguiente: Observe cómo la función de fitness devuelve el valor de la pena de muerte, deteniendo la ejecución de la función cuando una restricción no se cumple. Cuando todas las restricciones son satisfechas, se calcula el coste de combustible, que es devuelto por la función. Por último, no debemos olvidar el registro de la función objetivo. 5.3.4 Ejecución del algoritmo Es el momento de ejecutar el algoritmo y analizar los resultados obtenidos. El algoritmo utilizado será el µ + λ (dicho algoritmo ya ha sido utilizado anteriormente, por ejemplo para resolver el problema planteado en el Capítulo 2). Para ejecutar este algoritmo es necesario definir una serie de parámetros que se exponen a continuación: ■NGEN : En este caso su valor es 500. Este parámetro se puede variar en función de lo observado en la gráfica de convergencia. ■MU : Fijamos el tamaño de la población. Establecemos 1500 para esta prueba. En este problema, debido a su complejidad, utilizamos un número elevado de individuos. Esto hará que el algoritmo tarde un poco en terminar 2. ■LAMBDA : Se fija el tamaño de descendientes en cada nueva generación. Se fija con un valor igual a 1500. Como el valor de MU y LAMBDA son iguales, el tamaño de la población permanece constante durante toda la ejecución del algoritmo genético. Además, como parámetros de entrada a la función se usarán las probabilidades de cruce y mutación expresadas en tanto por uno (c y m respectivamente). La función de llamada al algoritmo queda, por tanto, del siguiente modo: Como se puede observar, esta función inicializa el problema y realiza una llamada al algoritmo basado en la configuración adoptada en el toolbox. Se establece un registro de los valores mínimos, máximos y medios de lo devuelto por la función de fitness, así como de la desviación típica de dichos valores para todos los individuos de cada generación. El funcionamiento de los objetos stats, hof y logbook ha sido explicado en capítulos anteriores. Se recomienda consultarlos en caso de duda. Cabe destacar que, en este caso, el algoritmo genético no ha ejecutado desde la función main; esto es así porque en este caso se definen dos funciones distintas para ejecutar los dos tipos de problemas que se abordan en esta sección: problema con un objetivo o problema con dos objetivos. El script tarda en ejecutarse alrededor de 200 segundos. Este tiempo de ejecución ha sido medido tras ejecutar el algoritmo en un Intel Core i5-72000U a 2.5 GHz con 8 Gb de memoria RAM. 5.3.5 Resultados obtenidos En este momento nos encontramos en disposición de ejecutar nuestro algoritmo genético. Para ello, haremos una llamada a la función unico_objetivo_ga, para lo cual es necesario pasar como argumentos los parámetros c y m, que definirán la probabilidad de cruce y mutación, en tanto por uno respectivamente. Con el fin de explorar los resultados obtenidos para diferentes combinaciones de estos parámetros, planteamos la llamada al algoritmo bajo tres posibles configuraciones: (c = 0.6, m = 0.4), (c = 0.7, m = 0.3) y (c = 0.8, m = 0.2). Para cada una de las configuraciones se lanzará el algoritmo diez veces. Así, ejecutamos las siguientes líneas de código: Observe, en el código anterior, la existencia de dos bucles for anidados. El primer bucle fija la configuración de los parámetros c y m, mientras que el segundo realiza las 10 llamadas al algoritmo. Con el fin de registrar de forma adecuada los resultados obtenidos, se generan dos ficheros de texto: individuos_microrred.txt y fitness_microrred.txt. En el primero, se almacenarán los mejores individuos obtenidos de cada llamada al algoritmo. El fichero tendrá una línea por cada llamada con una estructura [número de llamada, c, m, individuo]. Por otro lado, el segundo archivo almacenará los valores de la función objetivo obtenidos para cada caso. Su estructura será análoga a la del fichero anterior: [número de llamada, c, m, fitness]. Antes de lanzar tantas llamadas al algoritmo evolutivo, lo primero que debemos hacer es lanzar un caso simple con el fin de cerciorarnos de que los valores fijados para NGEN, MU y LAMBDA son los apropiados para el problema. Para ello, basta con ejecutar la siguiente línea: en donde hemos escogido de forma aleatoria c = 0.7 y m = 0.3. Una vez finalizada su ejecución, podemos visualizar la gráfica de convergencia del algoritmo. De esta manera, podemos tener la certeza de que la solución obtenida es definitiva o aún esta sujeta a variaciones y es necesario ampliar el número de generaciones o el tamaño de la población. Figura 5.5. Gráfica que muestra la convergencia del algoritmo. Es lógico que en cada generación exista algún individuo que no cumpla las restricciones y que, por tanto, tenga un fitness condicionado por la pena de muerte. Sin embargo, el valor mínimo debe ser decreciente y asintótico al valor óptimo final. La evolución del algoritmo se muestra en la Figura 5.5. Se puede observar la total convergencia del algoritmo alrededor de la generación 300. Una vez comprobada la convergencia del algoritmo, estamos en posición de lanzar la batería de ejecuciones introducida anteriormente y analizar los resultados obtenidos. El Texto 5.1 muestra los datos almacenados en el fichero fitness_microrred.txt, mientras que el Texto 5.2 muestra un fragmento con el primero de los individuos almacenados en el fichero individuos_microrred.txt. Texto 5.1. Resultados del algoritmo almacenados en el archivo fitness_microrred.txt. Texto 5.2. Fragmento de los resultados del algoritmo almacenados en el archivo individuos_microrred.txt. La Tabla 5.1 muestra el valor mínimo, máximo y medio de la función objetivo para cada configuración de valores de c y m. Como se puede observar, el mejor resultado se obtiene para una probabilidad de cruce del 70% y una de mutación del 30%. Sin embargo, los resultados son muy similares para las tres configuraciones probadas. Pcx Pmut 0.6 0.4 0.7 0.3 0.8 0.2 min(CF) 556.63 554.82 559.42 max(CF) 564.43 564.82 571.65 avg(CF) 560.13 560.14 563.64 Tabla 5.1. Resultados de la función de fitness para diferentes configuraciones de la probabilidad de cruce y mutación. La mejor solución obtenida, correspondiente a las probabilidades Pcx=0.7 y Pmut=0.3, se resume en la Tabla 5.2. En esta tabla podemos observar los valores de la potencia entregada por los diferentes generadores, así como la gestión de la batería. Por un lado, se puede comprobar que la mayor parte del tiempo el generador diésel está apagado y, por tanto, no implica coste alguno de combustible. Por otro lado, la microturbina se encuentra entregando potencia en las horas de mayor consumo y, además, utiliza el exceso de potencia para cargar la batería (valores negativos de PESS,t). A la vista de la Figura 5.3, se puede comprobar que los resultados son lógicos ya que, según se ha visto, el coste marginal de producir un kW más de potencia por la microturbina es menor cuanto mayor sea la potencia total producida por la misma. Así, por ejemplo, tiene un menor coste producir 180kW con la microturbina y usar 80kW de ellos para cargar la batería (que aportará dicha energía en instantes posteriores) que producir 100kW y 80kW en dos instantes consecutivos. Para un mejor análisis de los datos generaremos dos nuevas figuras. En la primera de ellas, representaremos el aporte de cada generador en cada uno de los instantes de tiempo para cubrir la demanda correspondiente. En la segunda, representaremos la potencia entregada por la batería, así como la evolución del estado de carga durante el periodo de tiempo considerado. Tabla 5.2. Resultado obtenido tras ejecutar el algoritmo genético para el problema de optimización de la microrred. Para generar la primera figura (Figura 5.6) utilizaremos el siguiente código: Por otro lado, para generar la segunda de estas figuras (Figura 5.7) basta con ejecutar: Las Figuras 5.6 y 5.7 representan ambas gráficas de resultados. Ahora es el momento de analizar si la solución obtenida es coherente con los objetivos que nos hemos planteado. Figura 5.6. Solución del problema planteado. Se muestra la generación de cada uno de los recursos, así como la gestión de la batería en la microrred. Por un lado, la Figura 5.6 muestra un gran uso de la microturbina en decremento del generador diésel, el cual solo entra en servicio en el instante t = 5. Esto tiene sentido ya que, como se puede observar en la Figura 5.3, el coste de la microturbina es menor cuando sobrepasamos los 55kW de potencia. De esta manera, al tener el generador diésel inactivo nos ahorramos su coste de operación. Por otro lado, se puede observar en la Figura 5.7 cómo la batería entra continuamente en funcionamiento. Este hecho también es lógico, dado que su uso es gratuito y nos permite una mayor flexibilidad en la red. Es importante apreciar cómo el estado de carga de la batería alcanza sus valores máximos y mínimos durante el día, siendo operada para aprovechar todo su potencial. Figura 5.7. Evolución de potencia y estado de carga del sistema de almacenamiento para la solución obtenida. 5.4 Problema con múltiples objetivos: Minimizando el coste de operación y el ciclado de la batería Analizaremos, ahora, la situación en la que nos interesa optimizar más de un objetivo de forma simultánea. Además, consideraremos que ambos objetivos se oponen entre sí, es decir, que una mejora en uno implica inevitablemente un empeoramiento en el otro. En este escenario el algoritmo debe establecer una solución de compromiso entre ambos. Así, nos moveremos hacia a un escenario más realista, en el que tendremos en cuenta la posible degradación de la batería como consecuencia del ciclado de la misma. El ciclado de la batería no es más que la cuantificación del número de cargas y descargas que se realizan a lo largo del tiempo. Al aumentar el número de ciclos de una batería, sus propiedades se ven afectadas (disminución de la capacidad, disminución de la potencia máxima de carga y descarga, etc.), lo que en la práctica se traduce en un número limitado de cargas y descargas de la batería. Así, es evidente que aumentar el ciclado es un factor desfavorable en términos de vida útil de la batería. Vamos entonces a definir como un objetivo adicional la minimización del ciclado de la batería, utilizando el siguiente término de coste: donde SOCini es el estado de carga inicial del sistema de almacenamiento. Podemos observar cómo se penaliza el uso de la batería de forma más acentuada cuando el estado de carga se sitúa en posiciones más lejanas del estado inicial. Debemos tener en cuenta que, mientras la función objetivo del caso monoobjetivo penalizaba el coste de combustible (expresado en ), esta función penaliza la degradación de la batería (expresada en kWh). Así, la formulación del problema vendrá dada por: Por lo tanto, ahora tenemos dos objetivos que deseamos minimizar: CF y CESS. 5.4.1 Definición del problema, población inicial y operadores genéticos Estos pasos son equivalentes a los dados en el caso mono objetivo. Solamente es necesario realizar algunos cambios tanto en la creación del problema como en el toolbox. Estos cambios son los siguientes: Vemos que los dos únicos cambios considerados son, por una parte, que ahora al definir el problema se especifica que hay dos objetivos (ambos a minimizar) y, por otra, que la operación de selección se realiza a través del tools.selNSGA2, ya que nuestro objetivo es obtener el frente de Pareto del problema. No hay ningún cambio con respecto a los operadores genéticos. La función objetivo que se ha registrado (fitness_multi) se definirá a continuación. 5.4.2 Función objetivo La función objetivo o fitness recibe como entrada un individuo, lo evalúa y devuelve dos valores de acuerdo a la calidad de ambos términos de coste. Dichos valores adoptan la penalización en el caso de que alguna de las restricciones no se cumpla. De este modo, se puede construir la siguiente función objetivo: Podemos comprobar que esta función es idéntica a la del caso de un único objetivo, con la única diferencia de que, en este caso, se calcula también el valor de CESS al final del código. De igual forma, se devuelven dos valores en lugar de uno, que corresponden a los dos objetivos que se desean minimizar. 5.4.3 Ejecución del algoritmo La función de llamada al algoritmo evolutivo podrá ser definida de igual manera que en el caso de un único objetivo, ya que las variaciones necesarias han sido definidas el la caja de herramientas o toolbox: De nuevo, el toolbox es definido y pasado como argumento a la función, que inicializa el problema y comienza la rutina. 5.4.4 Resultados obtenidos Realizamos entonces la llamada a la función multiple_objetivo_ga, fijando los valores de configuración de la estrategia de crossover, como son c = 0.7 y m = 0.3 (los óptimos obtenidos para el caso de un único objetivo). De nuevo, y para facilitar el análisis de los datos, crearemos dos ficheros donde iremos almacenando los resultados entregados por el algoritmo (en este caso obtendremos más de un individuo como resultado). Este procedimiento se recoge a continuación: Ya que hemos utilizado los mismos parámetros que en el caso de un objetivo, supondremos que la solución aportada por el algoritmo ha convergido. Los resultados de la Figura 5.8 avalan que esos valores son adecuados, ya que vemos un amplio número de soluciones en el frente de Pareto. En el Texto 5.3 se puede observar un fragmento del fichero de texto fitness_microrred_multi.txt. Así mismo, el Texto 5.4 muestra uno de los individuos almacenados en el fichero individuos_microrred_multi.txt. Texto 5.3. Fragmento de los resultados almacenados en el archivo fitness_microrred_multi.txt. Texto 5.4. Fragmento de los resultados almacenados en el archivo individuos_microrred_multi.txt. La Figura 5.8 muestra el frente de Pareto obtenido como resultado de la ejecución del algoritmo. El frente Pareto muestra el valor de ambos términos de coste para diferentes individuos, de forma que ninguna solución es dominante sobre otra. Este concepto se puede entender de forma análoga al concepto de coste marginal en economía. Dada una solución con un coste en combustible y en degradación de la batería, este término indica qué implicaciones tendría en uno de los dos términos, un incremento en la calidad del otro. Así, si por ejemplo nos fijamos en la Figura 5.8, podemos tomar como punto de partida el (637, 190) (punto 6 en la gráfica). Supongamos, entonces, que deseamos disminuir el coste de combustible; nos desplazaríamos hacia la izquierda en la curva. La pendiente de dicha curva representa el coste marginal. Si nos movemos al punto (630, 210) (punto 5 en la gráfica) comprobamos que una mejora de 7 en el coste de combustible implica un empeoramiento de 20kWh en el término que cuantifica la degradación de la batería. Figura 5.8. Frente Pareto del problema abordado. Por último, la Figura 5.9 muestra la evolución del estado de carga del sistema de almacenamiento para diferentes puntos del frente de Pareto (los marcados en la Figura 5.8). Como se puede observar, conforme disminuye el coste en combustible, aumenta el coste de degradación de la batería y, por consiguiente, la evolución del estado de carga toma valores más extremos. De esta manera, cada vez sacamos más partido al sistema de almacenamiento. Sin embargo, esta mejora afecta a la vida útil de la batería. Así, para los puntos finales en los que menores valores se obtienen para el objetivo de degradación de la batería, se observa que el estado de carga de la misma se mantiene constante la mayor parte del tiempo, únicamente cubriendo el pico de demanda en t = 20h. Figura 5.9. Evolución del estado de carga para diferentes puntos del frente de Pareto. 5.5 Código completo y lecciones aprendidas Antes de finalizar este capítulo repasemos, a modo de resumen, los pasos que hemos seguido para la resolución del problema propuesto. Para ello nos apoyaremos en el código completo mostrado en Código 5.5: 1. Líneas 1-8: Primero es necesario incluir todas las librerías que utilizaremos en nuestro código. En este caso haremos uso de la librería deap para la resolución de los algoritmos genéticos, la librería numpy para la correcta operación de vectores y matrices y, por último, la librería matplotlib que nos permitirá representar gráficamente los resultados obtenidos. 2. Líneas 10-13: Definimos si el problema a resolver tiene un único objetivo o si, por el contrario, estamos considerando el caso de minimizar los dos objetivos propuestos. En la línea 13 se ha dejado la variable multi como True por defecto; por lo tanto, se ejecutaría el algoritmo multiobjetivo. Si se desea ejecutar el algoritmo con un solo objetivo, se debe cambiar el valor a False . 3. Líneas 15-25: A continuación, es necesario definir los datos del problema. En nuestro caso serán los perfiles de potencia aportados por el generador eólico y la planta fotovoltaica, así como la potencia demandada por el centro de consumo. 4. Líneas 27-43: Definimos los parámetros de las unidades del problema, así como el valor de penalización para cuando se aplique la pena de muerte. Como en casos anteriores, dicho valor se puede cambiar, siempre y cuando sea un valor que penalice mucho las soluciones inválidas. 5. Líneas 45-113: Después, implementamos unas funciones que nos permitan evaluar si las restricciones de operación se cumplen. En caso de no hacerlo, dichas funciones deberán devolver un valor que indique la pena de muerte. Elaboramos funciones para evaluar los límites de operación de las unidades despachables, así como del estado de carga y potencia demandada/suministrada por la batería. 6. Líneas 115-141: Se definen las funciones de generación de individuos y de mutación. 7. Líneas 143-163 y líneas 192-213: Ahora, estamos en posición de definir la función objetivo de nuestro problema, que devolverá un valor que el algoritmo intentará minimizar. En el caso de un único objetivo será el coste de combustible, mientras que en el caso multiobjetivo se considerará también el coste de ciclado de la batería. 8. Líneas 165-190 y líneas 217-248: Generamos una función que haga la llamada al algoritmo genético una vez se hayan definido todos los parámetros de configuración del mismo. Esta función es diferente para el caso de un único objetivo o de dos. 9. Líneas 251-282: Finalmente, configuraremos el algoritmo evolutivo mediante el toolbox y haremos la llamada a la resolución del problema. Para ello, nos apoyaremos en la elección tomada respecto al problema a través de la variable booleana multi . Código 5.5. Código final para el problema de gestión óptica de la microrred. Por otro lado, podemos resumir el trabajo de este capítulo como la aplicación de los algoritmos genéticos sobre un problema real de optimización de una microrred. El problema consiste en la gestión óptima de los generadores de la microrred para cubrir un determinado nivel de demanda, para lo que se han considerado datos horarios durante un periodo de 24 horas. Dos objetivos han sido abordados: ■Por un lado, se han calculado los puntos de funcionamiento óptimos de los generadores y el sistema de almacenamiento con el fin de minimizar el coste de combustible. ■Por otro lado, hemos considerado un escenario en el que la degradación de la batería es tomada en consideración. Así, a la vez que se intenta minimizar el uso de combustible, también se pretende minimizar el ciclado de la batería con el fin de alargar tanto como sea posible su vida útil. Se ha introducido una metodología de actuación adecuada para resolver este tipo de problemas y garantizar una solución aceptable. Así, factores como analizar la convergencia del algoritmo han sido destacados. Como principales lecciones aprendidas destacan las siguientes: ■En este capítulo hemos aprendido cómo aplicar los algoritmos genéticos a través de la librería deap a un problema real de ingeniería eléctrica. ■Hemos visto cómo dotar de mayor flexibilidad al problema mediante una adecuada definición de los individuos. Si recordamos, nuestro objetivo es conocer las potencias entregadas por el generador diésel, la microturbina y el sistema de almacenamiento; sin embargo, solo los datos correspondientes a los dos primeros han sido incluidos en los genes del individuo. De esta forma, las potencias de la batería se derivan de los otros generadores, haciendo el problema más flexible y eficiente, al asegurar que se cumple el estado de carga. ■Se han introducido recursos para obtener datos reales de demanda eléctrica a través de la página web de Red Eléctrica Española. ■Hemos observado el frente de Pareto de un problema real, poniendo en relevancia la necesidad de lograr un compromiso entre ambos objetivos. ■Por último, hemos realizado una batería de llamadas al algoritmo y hemos analizado los resultados obtenidos para diferentes probabilidades de cruce y mutación, lo que nos ha permitido encontrar la combinación de parámetros más adecuada. 5.6 Para seguir aprendiendo Con el fin de lograr un mayor conocimiento sobre este tipo de problemas, se recomienda la lectura de los siguientes artículos: ■Un resumen amplio sobre optimización de microrredes se puede encontrar en (Li et al., 2015b). (Khan et al., 2016). ■En (Nemati et al., 2018) se realiza una comparación entre algoritmos genéticos y técnicas de programación lineal mixta o MILP. ■En (Alvarado-Barrios et al., 2020) se aborda un problema parecido al planteado en este capítulo pero considerando incertidumbre en la demanda. El problema se resuelve mediante programación entera no lineal. ■En (Alvarado-Barrios et al., 2019) el problema planteado en (Alvarado-Barrios et al., 2020) se resuelve mediante algoritmos genéticos. Además, se proponen distintos modos de funcionamiento de la microrred, lo que da lugar a varios problemas de optimización. Por ejemplo, teniendo en cuenta el coste ecológico de funcionamiento. En (Rodríguez del Nozal et al., 2020) se abordan distintos tipos de baterías. ■El problema se amplía en (Rodríguez del Nozal et al., 2019), aplicando en este caso Model Predictive Control (MPC) (Camacho y Alba, 2013), el cual se basa en resolver el problema considerando un ventana temporal en la optimización del funcionamiento de la microrred. Como ejercicios se plantean los siguientes: ■Realice una comparación de los resultados para distintos operadores de cruce (cruce un punto, dos puntos, uniforme, etc.). ■Una vez obtenido el operador de cruce más adecuado, modifique el valor de σ del operador de mutación propuesto en el problema. Pruebe distintos valores e intente ajustar su valor. ■Introduzca rendimientos de carga y descarga en la batería y obtenga los resultados que minimizan tanto el problema con un único objetivo como el problema multiobjetivo. Esto se logra reescribiendo la función de la evolución del estado de carga como sigue: donde η representa el rendimiento de carga y descarga en tanto por uno. ■Resuelva el problema considerando diferentes parámetros de la batería: capacidad, máxima capacidad de descarga y mínima capacidad de descarga. Pruebe disminuir la capacidad hasta un punto en el que la factibilidad del problema se vea comprometida. _________________ 1Si se surge alguna duda sobre estás operaciones, se recomienda consultar capítulos anteriores. 2El tiempo dependerá del ordenador que se utilice, pero debe estar en el orden de las decenas de minutos. 6.1 Introducción En este capítulo se propone la resolución al problema del diseño de una planta micro-hidráulica, de manera que el emplazamiento de sus elementos más significativos permita sacar el máximo partido al terreno, satisfaciendo un nivel mínimo de potencia generada. Las plantas microhidráulicas son plantas de generación de energía eléctrica a partir de la energía potencial de un flujo natural de agua. El término micro hace referencia a niveles de potencia instalada inferiores a 100 kW, lo que se traduce en instalaciones económicas, robustas y eficientes, generalmente utilizadas para abastecer pequeñas zonas aisladas, con dificultades para acceder a la red principal. Aquellos problemas de optimización que tienen asignada una función de adaptación cuya evaluación supone un alto coste computacional pueden suponer un serio problema cuando son abordados por un AM. Esto es así porque a la demanda de evaluación de dicha función por parte de la BG de la EA hay que añadirle al exigida por la A pesar de la simplicidad de estas instalaciones, tanto su capacidad como su rendimiento están fuertemente condicionados por su ubicación y su trazado sobre el terreno. Por una parte, un trazado que explote una gran diferencia de altura puede permitir alcanzar niveles de generación más altos. Por otra, un trazado que abarque una gran distancia puede incrementar demasiado las pérdidas por fricción en la conducción del agua y afectar negativamente al rendimiento del sistema. La complejidad de resolver este problema de optimización no lineal considerando un terreno cualquiera sobre el que instalar la planta, no solo resulta inabordable desde un punto de vista analítico, sino que la misma existencia de un óptimo es, como mínimo, muy complicada de demostrar, si no imposible. Por este motivo se plantea en este capítulo el diseño de un algoritmo genético para encontrar una solución óptima. 6.2 Formulación del problema Para implementar nuestro algoritmo genético, necesitamos en primer lugar definir un conjunto de ecuaciones que nos permitan entender y predecir el funcionamiento del sistema, esto es, un modelo de nuestra planta microhidráulica. Este modelo nos permitirá obtener las variables de interés (como la potencia generada por la planta o cuánto cuesta instalarla) en función de las variables de diseño o de decisión (como la posición de la turbina y la extracción de agua). Esta será la herramienta mediante la cual nuestro algoritmo podrá cuantificar cuán buena es una determinada solución, en base a los criterios que consideremos oportunos. En términos generales, una central micro-hidráulica está compuesta por una serie de elementos destinados a extraer un flujo de agua con una cierta energía potencial (para garantizar el abastecimiento es habitual instalar en este punto un reservorio de agua mediante una presa), conducirla aguas abajo (mediante un conducto denominado tubería forzada) transformando esta energía en presión y, finalmente, transformarla de nuevo en energía eléctrica mediante un sistema de generación (generalmente compuesto por una turbina y un generador acoplado), que suele instalarse dentro de una pequeña construcción denominada casa de máquinas (ver Figura 6.1). Figura 6.1. Esquema de una planta micro-hidráulica. El problema presentado en este capítulo consiste en encontrar el trazado de la planta (posición de la casa de máquinas, punto de extracción y codos de la tubería forzada) que minimiza el coste total y que satisface una generación de potencia mínima. Veamos cómo introducir esta formulación en nuestro algoritmo. Para ello trabajaremos en dos partes diferenciadas: 1. En primer lugar, desarrollaremos una estrategia matemática para codificar las posibles soluciones del problema (trazados de la planta sobre el terreno) en un cromosoma. Esta formulación permitirá obtener, para cada posible solución, las variables fundamentales de la planta, como son la diferencia de altura bruta H g , la longitud de la tubería L o el número total de codos n c . Estas variables se explicarán en detalle más adelante. 2. En segundo lugar, desarrollaremos un conjunto de ecuaciones matemáticas, basadas en los principios físicos que rigen el comportamiento de la planta microhidráulica, que nos permitirá deducir las prestaciones de la planta (potencia, caudal o coste) en función de las variables fundamentales anteriores. Estamos, pues, ante un problema combinatorio ya que debemos decidir sobre la posición óptima de un conjunto de elementos que forman la microplanta. Perfil del terreno Para plantear el problema necesitamos, en primer lugar, considerar el perfil del terreno sobre el que se diseñará la planta. Así, llamaremos ϕ(s) a una función escalar que define el perfil del río en un plano. Es importante destacar que hemos considerado que la curvatura del río es suficientemente baja como para desarrollar su curva de altura en un plano, simplificando así el problema. La primera consideración importante es que, dada la imposibilidad de conocer una expresión analítica de ϕ(s) para cualquier terreno, consideraremos una definición discreta. Para ello se asumirá un conjunto arbitrario de N puntos en la forma (si,zi), que se pueden obtener mediante un análisis topográfico del terreno o a partir de sus curvas de nivel. Si partimos de la información topográfica en un determinado fichero externo (por ejemplo un archivo PuntosRio.csv con los valores de si y de zi en dos columnas), podemos construir los vectores s y z mediante los siguientes comandos: 6.2 Formulación del problema Figura 6.2. Representación del perfil del río. A continuación, se muestran las primeras líneas del archivo PuntosRio.csv que se ha utilizado para este problema: Podemos también comprobar de forma gráfica cómo es el perfil del río, utilizando una función que represente los puntos z frente a los puntos s. Para esto podemos crear la función dibujaRio, que se muestra a continuación. El resultado de esta función se muestra en la Figura 6.2. Trazado de la planta Veamos ahora cómo modelar el trazado de la planta sobre el terreno que acabamos de leer. Tal y como hemos comentado, las partes principales del sistema son tres: el punto de extracción, la casa de máquinas, y la tubería forzada. Como únicamente conocemos el terreno en N puntos discretos, consideraremos estos como puntos candidatos para ubicar uno de los elementos indicados. Así, codificaremos las soluciones X del problema como un conjunto de N variables binarias δi, como se muestra en la Figura 6.3: Cualquier combinación de estos Figura 6.3. Representación genética de las soluciones del problema. N bits definirá un posible trazado de la planta. Este será, por tanto, el cromosoma que definirá a cada uno de los individuos en nuestro algoritmo genético. Así, la función de interpolación de los nodos, llamémosla Γ(s), representará el trazado de la planta. Antes de seguir, vamos a crear una función similar a dibujaRio pero que permita representar sobre el perfil del río la solución correspondiente a un individuo. Esta función, que denominaremos dibujaSolucion, debe recibir el individuo como argumento, y podría escribirse de la siguiente manera: Para identificar los nodos de la solución hemos utilizado la función np.nonzero1, que nos devuelve los índices donde aparecen elementos no nulos, es decir, los puntos del río donde instalamos nodos. Veamos un ejemplo de esta formulación. Considerando el perfil de río introducido anteriormente, formado por un total de N=100 puntos, propongamos una posible solución formada por 5 nodos (δi positivas), situados en los puntos 47, 55, 65 y 85. Podemos crear esta solución de forma sencilla partiendo de un vector de 200 ceros, y transformar en 1 los elementos de las posiciones que se acaban de indicar. Para ello, podemos complementar el código anterior con los siguientes comandos: Debemos notar que, habiendo definido ϕ(s) en sentido creciente, el primero y el último de los nodos de este subconjunto representan, respectivamente, la casa de máquinas y la extracción, mientras que el resto de ellos representan dos codos de la tubería forzada. Si extraemos las coordenadas (s y z) de todos los nodos: podemos determinar fácilmente la información que necesitamos sobre la planta; por ejemplo, la altura bruta, Hg, restando la altura z del último y el primer nodo: O la longitud de la tubería forzada, Lt f, sumando la distancia euclídea entre cada nodo y el siguiente: O el total de codos de esta última, nc, que no es más que el número de unos del individuo menos dos (el de la turbina y el de la presa): Todas estas expresiones las usaremos más adelante para definir la función de fitness de nuestro algoritmo. 6.2.1 Modelado de la central micro-hidráulica Estudiaremos ahora cómo podemos determinar las prestaciones de la planta micro-hidráulica a partir de su trazado. Aunque existe una gran variedad de equipos y configuraciones, con el propósito de simplificar el estudio, este capítulo se centrará en el diseño de una planta de microgeneración de agua fluyente (sin presa ni reservorio de agua) de tipo Pelton. Las turbinas Pelton son un tipo de turbinas de acción, es decir, el intercambio de energía se produce a presión atmosférica, para lo cual se requiere la transformación de la presión en energía cinética mediante un inyector. El intercambio de energía se produce al impactar un chorro de agua sobre una serie de cucharas dispuestas circunferencialmente alrededor de un rodete (ver Figura 6.4). Figura 6.4. Esquema de funcionamiento de una turbina de acción de tipo Pelton. Prestaciones de la planta A continuación, es necesario determinar cómo el trazado de la planta (cada posible solución) condiciona las prestaciones de la misma, de manera que el algoritmo de optimización pueda evaluar la calidad de los individuos. Veamos, entonces, las ecuaciones que gobiernan el comportamiento de una planta de este tipo. Las variables de interés para nuestro problema de optimización van a ser la potencia generada por la planta, P, y el coste de la instalación, C. La potencia generada por el conjunto turbina-generador, P, se puede calcular a partir de la altura (presión del agua expresada en columna de agua equivalente) neta del agua en la turbina, Ht, y el caudal volumétrico turbinado, Q, como sigue: P = ηρgQHt, donde ρ y g son, respectivamente, la densidad del agua y la aceleración de la gravedad. Se ha introducido, además, un coeficiente η que representa la eficiencia del conjunto de generación. En un caso ideal (sin fricción) la altura neta en la turbina, Ht, sería igual a la altura bruta de la instalación, Hg. No obstante, en la realidad ocurre que las pérdidas debidas al paso del agua a través de la tubería provocan una cierta pérdida de altura, que denominaremos ΔHfric. Así, se puede escribir que: Ht = Hg – ΔHfric. Como la turbina es de acción, la transformación de energía en la misma se hace a presión atmosférica, es decir, toda la energía del agua (que forma un chorro, al que nos referimos con el subíndice jet) es cinética: donde, además, dada la incompresibilidad del agua, la velocidad se puede escribir en términos del caudal y la sección de salida del inyector Siny, según: siendo CD un coeficiente de descarga que se introduce para modelar la formación del chorro en el inyector (Thake, 2000) (ver Figura 6.4). Así, la altura del agua a la entrada de la turbina se puede reescribir como: En cuanto a las pérdidas en la tubería forzada anteriormente comentadas, ΔHfric, se pueden aproximar de forma muy sencilla mediante un coeficiente Kt f y la geometría de la tubería forzada (longitud Lt f y diámetro Dt f), lo cual se puede escribir: Combinando estas ecuaciones se puede escribir una expresión para la potencia generada en la planta: El coste de la planta, C, se puede estimar como la suma de los costes asociados a la tubería forzada, Ct f, al equipo de generación, Cgen, y a la red eléctrica, Cre. En este problema se harán dos consideraciones para simplificar su cálculo: (i) se considerará un determinado equipo de generación, y (ii) se asumirá además que el punto de consumo está suficientemente alejado del río, por lo que su longitud será aproximadamente constante, con independencia de la localización de la turbina. De esta manera, el coste de la instalación solo será sensible al trazado en lo que a la tubería respecta, de manera que podemos obviar los términos constantes y trabajar con el coste de la tubería forzada como variable de interés. El coste típico de una conducción de este tipo es proporcional a la longitud y al cuadrado del diámetro, lo cual se puede escribir de la siguiente forma: Para tener en cuenta el coste de instalar muchos codos sin complicar en exceso la formulación, haremos la siguiente consideración: cada codo que se instale equivaldrá a una cierta longitud adicional de tubería, que denominaremos mediante λ. Así, si llamamos nc al número total de codos, la función de coste se transforma en: Por lo tanto, ya podemos introducir estas ecuaciones en nuestro algoritmo para determinar las prestaciones de nuestra planta en función de las variables que hemos calculado antes (Hg, Lt f u nc): Ya podemos escribir una función, que llamaremos validaPlanta, que evalúe las prestaciones de la planta correspondiente a un individuo. En esta función incluiremos el modelo que acabamos de presentar, así como todas las constantes necesarias (diámetro de la tubería, densidad del agua, constante de fricción, etc). La función devolverá, además del coste y potencia de la planta correspondiente al individuo, una variable binaria que indicará si la potencia generada cumple o no con el valor mínimo exigido Pmin: Factibilidad de la planta A continuación, plantearemos una estrategia para definir cuándo una solución es factible y cuándo no, de manera que en la búsqueda de la solución óptima solo consideremos aquellos trazados cuya construcción sea posible. Así, definiremos la factibilidad de la planta en función de su adaptación al perfil del río. En particular, vamos a considerar que una solución es factible si su trazado no se separa del terreno más de una cierta distancia. Definiremos esta distancia máxima en función de la altura límite de soportes que podamos instalar en la tubería forzada, en el caso de que esta discurra por encima del terreno, y de la profundidad máxima que consideramos que puede realizarse para enterrar la tubería, en el caso contrario. Como hemos considerado que conocemos el perfil del río exclusivamente en N puntos, serán estos en los que verificaremos las condiciones de factibilidad. Así, consideraremos que una solución es factible si -y solo si- todos los puntos del trazado que representa cumplen las condiciones de factibilidad. Si llamamos εi a la diferencia entre el trazado de la tubería forzada, Γ(s), y el terreno, ϕ(s), en el punto i: εi = Γ(si) – ϕ(si). Podemos escribir las condiciones de factibilidad como: –εexc ≤ εi ≤ εsop, donde hemos introducido εexc y εsop para referirnos a la máxima profundidad excavable y a la máxima altura de soportes aceptable, respectivamente. Para determinar los valores de las diferencias de altura εi en nuestro algoritmo podemos utilizar: Cabe destacar que se ha utilizado la función interp1d2 del módulo interpolate de la librería scipy, para realizar una interpolación lineal de los puntos seleccionados por los individuos. Como resultado se crea la función de interpolación trazado. Ya podemos escribir una función, que llamaremos validaTrazado, que compruebe si el trazado correspondiente a un individuo cumple correctamente estas restricciones de factibilidad: 6.3 Problema con un objetivo: Minimizando el coste de instalación Puesto que el objetivo principal de nuestro problema es el de encontrar el trazado de la planta más económico, resulta lógico plantear el coste de la misma, C, como la función de fitness o función objetivo. Asímismo, se requerirá que la potencia generada, P, sea superior o igual a un determinado valor mínimo Pmin y que el trazado correspondiente sea factible: La complejidad de este problema es NP-duro, ya que no estamos ante un problema de decisión, en el que comprobar si se dan una serie de condiciones (potencia mínima requerida, restricciones en los soportes), sino que estamos buscando la solución óptima en términos de coste. Considerando N puntos posibles del trazado del río, el número de soluciones posibles a evaluar es 2N, lo que supone una complejidad exponencial con el número de puntos considerados. En el caso propuesto, se tendrían 2 **100 posibles soluciones, lo cual es inabordable mediante un algoritmo exhaustivo3. En los siguientes epígrafes introduciremos todos los componentes del algoritmo genético necesarios para resolver el problema. 6.3.1 Definición del problema y generación de la población inicial Estamos en posición de poder definir el problema en deap. Para ello, utilizaremos las siguientes líneas de código: Como se puede observar, hemos definido el problema como uno de minimización en el que los individuos vendrán estructurados en listas de N componentes. A continuación, empezaremos definiendo el algoritmo de generación, que no es más que el conjunto de reglas que permitirán crear de forma aleatoria los individuos que conformarán la población inicial. Como hemos visto anteriormente, una estrategia de generación adecuada permitirá al algoritmo genético realizar una buena exploración en las primeras generaciones. El objetivo de la generación, no solo consiste en generar individuos de forma aleatoria, sino que además debe hacerlo, en la medida de lo posible, evitando que estos violen las restricciones del problema. De esta manera se garantiza una buena exploración durante las primeras generaciones. En el problema que estamos estudiando, es evidente que las restricciones de factibilidad son especialmente severas y, a priori, es bastante improbable que los individuos generados las satisfagan de forma trivial. Por ello, planteamos la siguiente estrategia para garantizar la factibilidad de los individuos generados: 1. Generamos un individuo de tamaño N sin ningún nodo; es decir, con todos los genes a 0. 2. Seleccionamos aleatoriamente dos enteros entre 0 y N – 1, y hacemos 1 el gen correspondiente a cada una de estas posiciones. 3. Hacemos 1 todos los nodos contenidos en el intervalo que definen los dos anteriores. Así, los individuos generados (ver Figura 6.5) cumplirán de forma trivial las restricciones de factibilidad, ya que todos los valores de ε serán cero. Notemos que estas soluciones, en general, van a estar lejos del óptimo, ya que consistirán en instalaciones con un número innecesariamente alto de codos. Por lo tanto, estamos priorizando partir de soluciones lejanas al óptimo pero válidas. El trabajo de los operadores genéticos consistirá en guiar a la población inicial hacia la solución óptima. Figura 6.5. Estrategia para generar individuos aleatorios que satisfagan las restricciones de factibilidad. Así, creamos la caja de herramientas y registramos las funciones necesarias para generar la población inicial: Como ha pasado en la mayoría de los problemas que hemos visto, la función crea_individuo crea el cromosoma completo del individuo. Por ello, la registramos utilizando tools.initIterate. 6.3.2 Operadores genéticos En esta sección, definiremos las operaciones de cruce y mutación. Recordemos que estas son las reglas heurísticas que regulan la modificación de la información genética de los individuos. A continuación, definiremos una función de fitness, que no es más que la función que utiliza el algoritmo para cuantificar la calidad de cada individuo. Para la operación de cruce, vamos a utilizar un esquema de dos puntos, el cual ya fue explicado en el Capítulo 1. Recordemos que este método consiste en seleccionar de forma aleatoria dos puntos entre 0 y N – 1 e intercambiar entre los dos progenitores el fragmento de información genética contenido en este intervalo. Para este problema, una posible alternativa a este operador podría ser el operador de cruce uniforme. Para la operación de mutación, vamos a utilizar un operador personalizado, que funcionará igual que el mutFlipBit que estudiamos en el Capítulo 4, pero con una pequeña modificación que ayude a disminuir el número de nodos de nuestro problema. Puesto que nuestro propósito es obtener trazados con un bajo número de nodos, sumado a que hemos propuesto un esquema de generación que previsiblemente va a crear individuos con un alto número de nodos, nuestro operador de Bit-Flip Modificado tendrá una probabilidad diferente para mutar un gen según su valor. En particular, haremos que la probabilidad de que un 1 se convierta en 0 sea mayor que la probabilidad de que un 0 se convierta en un 1, es decir: p0→1 < p1→0 Así registramos dicho operador en nuestra caja de herramientas: La elección de este operador de mutación se justifica en dos aspectos. En primer lugar, puesto que el objetivo del problema es minimizar el coste, nos interesa incentivar la reducción del número de nodos en los individuos. En segundo lugar, dado el esquema de generación de individuos propuesto, es de esperar que en las primeras generaciones los individuos tengan un alto número de nodos. En cuanto al mecanismo de selección, se ha utilizado la selección mediante torneo. El torneo consiste en escoger un número determinado de individuos de la última generación del algoritmo, comparar sus valores de función objetivo y, por último, escoger aquella con menor valor. 6.3.3 Función objetivo o de fitness La función de fitness recibe un individuo como entrada, lo evalúa y devuelve un determinado valor de calidad. Como sabemos, para evitar que soluciones con un buen valor de calidad pero no factibles sean consideradas, la función de fitness asigna un valor de penalización (pena de muerte). Así, la función objetivo que vamos a escribir devolverá el coste de la planta cuando el individuo sea factible, pero devolverá un valor sustituto (un coste muy alto) en el caso de que no lo sea, con independencia del coste que tenga realmente. Esto se puede implementar fácilmente mediante: Podemos ver cómo la función fitness aplica el mecanismo de pena de muerte (deteniendo la ejecución de la función) cuando alguna de las restricciones no se cumple para el individuo bajo evaluación. Cuando todas las restricciones son satisfechas, se calcula el coste de la planta y se devuelve como salida. Debemos tener en cuenta que, para lograr un correcto funcionamiento posterior de la librería d Para registrar esta función en la caja de herramientas utilizaremos el siguiente comando: Observe que, en este caso, se ha definido el fichero donde se guarda la información del perfil del río como entrada a la función objetivo. 6.3.4 Ejecución del algoritmo Llegado a este punto, ya podemos lanzar el algoritmo y empezar a obtener resultados. Necesitamos definir primero una serie de parámetros relativos a la ejecución: ■Número de generaciones ( NGEN ): Fijaremos su valor en 100. Este parámetro se puede variar en función de lo observado en la gráfica de convergencia. ■Número de individuos padres en una generación ( MU ): Fijaremos el tamaño de la población. Establecemos 4000 para esta prueba. ■Número de individuos descendientes en una generación ( LAMBDA ): Se fija el tamaño de descendentes en cada nueva generación. Se fija con un valor igual a 4000. 6.3.5 Resultados obtenidos Ya estamos listos para lanzar nuestro algoritmo genético y obtener resultados. Para ello llamaremos a la función unico_objetivo_ga, cuyos argumentos son las probabilidades de cruce, pcx, y de mutación, pmut. Para este problema queremos estudiar diferentes combinaciones de estas dos probabilidades, por lo que utilizaremos el siguiente fragmento de código: Observemos que, análogamente al capítulo anterior, hemos anidado dos bucles for, con la finalidad de fijar los parámetros c y m por una parte, y llamar 10 veces al algoritmo, por otra. Generamos, además, dos ficheros de texto: individuos_turbina.txt y fitness_turbina.txt, para almacenar los mejores individuos de cada llamada al algoritmo, y los valores de la función objetivo obtenidos, respectivamente. Por último, antes de ejecutar la secuencia de llamadas a nuestro algoritmo, lanzaremos un caso simple para asegurarnos de que los valores de NGEN, MU y LAMBDA son apropiados para el problema (y permiten que algoritmo llegue a converger) o si, por el contrario, necesita un mayor número de generaciones. Para ello haremos lo siguiente: Tras su ejecución, representaremos la gráfica de convergencia del algoritmo. Así, obtenemos el resultado que se muestra en la Figura 6.6: Cabe destacar que el máximo en cada iteración es muy elevado debido a la pena de muerte que se aplica. Una vez comprobamos que se alcanza la convergencia del algoritmo, ya podemos lanzar la batería de ejecuciones planificada anteriormente y, después, analizar los resultados obtenidos. La Tabla 6.1 muestra los resultados obtenidos. Se observa que el mejor resultado se obtiene utilizando una probabilidad de cruce del 60% y una de mutación del 40%, aunque los resultados son similares en las cuatro combinaciones. Figura 6.6. Gráfica que muestra la convergencia del algoritmo. Tabla 6.1. Resultados obtenidos para el problema de optimización de la planta micro-hidráulica. Los Textos 6.1 y 6.2 muestran un fragmento del contenido de los ficheros de texto individuos_turbina.txt y fitness_turbina.txt tras la ejecución de la batería de resoluciones respectivamente. Texto 6.1. Fragmento del archivo individuos_turbina.txt tras ejecutar la batería de pruebas. Texto 6.2. Fragmento del archivo fitness_turbina.txt tras ejecutar la batería de pruebas. Tras comprobar que la ejecución ha sido exitosa, podemos localizar y estudiar la mejor solución obtenida, que se ha resumido a continuación: Tabla 6.2. Mejor solución obtenida para el problema de optimización de la planta microhidráulica. Podemos además utilizar la función dibujaSolucion, definida anteriormente, para representar el individuo, como se muestra en la Figura 6.7. Cabe destacar que la solución obtenida permite generar una potencia de 7.05 kW (ligeramente superior a los 7 kW mínimos exigidos), turbinando un caudal de 13.13 L de agua por segundo. El coste total de la planta es es de aproximadamente 28 k , y la tubería requerida mide 221.21 metros, con un total de 7 codos. Se comprueba, además, observando la representación gráfica de la solución, que esta aprovecha el tramo de máxima pendiente del terreno. Figura 6.7. Representación del trazado óptimo. 6.4 Problema con múltiples objetivos: Minimizando el coste de instalación y maximizando la potencia generada Una vez resuelto el problema de optimización con un único objetivo (la minimización del coste de la planta), nos podemos plantear la optimización multiobjetivo, en la que dos o más objetivos competitivos son considerados simultáneamente en la optimización. Así, definiremos el objetivo del problema como la minimización del coste, C, y la maximización simultánea de la potencia, P. Está claro que, dada la naturaleza de estas dos variables, la una no puede mejorar sin implicar un empeoramiento en la otra, por lo que las soluciones que obtendremos representarán un compromiso entre ambos objetivos. De esta forma obtendremos un análisis más exhaustivo del diseño de la planta, del cual podremos extraer información interesante. 6.4.1 Definición del problema, población inicial y operadores genéticos Analogamente al caso con un único objetivo, definimos el problema en deap y registramos las funciones necesarias en la caja de herramientas: Observamos cómo, en este caso, los pesos (weights) definidos en nuestro problema son (1.0,-1.0,), dado que pretendemos minimizar el coste de la instalación mientras que maximizamos la potencia aportada por la misma. Por otro lado, la operación de selección se realiza a través del tools.selNSGA2, ya que nuestro objetivo es obtener el frente de Pareto del problema. No hay ningún cambio con respecto a los operadores genéticos. La función objetivo que se ha registrado (fitness_function_multiobjetivo) se definirá a continuación. 6.4.2 Función objetivo o de fitness La función de fitness se define ahora como una tupla con dos elementos, la potencia generada y el coste de la planta. Análogamente al caso de un único objetivo, se utilizará la pena de muerte para penalizar aquellos individuos que representen soluciones que no sean factibles. La función fitness_function_multiobjetivo queda, por tanto: Podemos ver que esta función es idéntica a la del caso en el que se consideraba un único objetivo pero, esta vez, devuelve, además del coste de instalación, la potencia dada por el sistema. 6.4.3 Ejecución del algoritmo Para llamar al algoritmo utilizaremos multi_objetivo_ga, que definimos de la siguiente manera: Es importante mencionar que no se han realizado cambios con respecto al tamaño de la población y al número de generaciones del algoritmo genético. 6.4.4 Resultados obtenidos Podemos ejecutar el algoritmo llamando a multi_objetivo_ga; esto es: Observe cómo hemos establecido las probabilidades de cruce y mutación como c = 0.6 y m = 0.3, ya que fue la configuración con la que mejores resultados obtuvimos en el caso de un único objetivo. En la Figura 6.8 se muestra el frente de Pareto obtenido con el algoritmo genético de optimización multiobjetivo. Como era de esperar, los individuos obtenidos se corresponden con diferentes soluciones de compromiso entre la potencia generada y el coste de la planta, y podemos comprobar que no es posible incrementar la potencia sin incrementar el coste, y al contrario. Observando el frente de Pareto, es interesante comprobar que se puede apreciar una cierta tendencia lineal. Esta puede interpretarse como un coste marginal, aproximadamente constante, de incrementar la potencia instalada. Este coste marginal se puede estimar como la pendiente de la recta de tendencia. Si calculamos Esta mediante mínimos cuadrados para las soluciones más próximas a la solución de menor coste, obtenemos un valor de m = 2670 por kW adicional. Figura 6.8. Frente de Pareto Potencia-Coste del problema de optimización (la tendencia lineal se ha representado en rojo). 6.5 Código completo y lecciones aprendidas Para finalizar este capítulo, hagamos un repaso del procedimiento realizado para resolver el problema de diseño de la planta micro-hidráulica utilizando un algoritmo genético discreto. Usaremos como referencia el programa completo, que se muestra en el Código 6.3. 1. Líneas 1-10: Primero es necesario incluir todas las librerías que utilizaremos en nuestro código. Cabe destacar el módulo interpolate para definir la curva que representa el trazado de la planta. 2. Líneas 12-15: Definimos una variable binaria para indicar si abordamos el problema en modo multiobjetivo o monoobjetivo, y definimos además las probabilidades de mutación y cruce. 3. Líneas 17-39: A continuación, definimos la función de generación de individuos. Habíamos escogido una estrategia de generación que evitase crear individuos no factibles, para mejorar la exploración durante las primeras generaciones. 4. Líneas 41-50: Definimos el operador de mutación, consistente en cambiar el valor binario de ciertos genes. 5. Líneas 52-80: Aquí definimos la función de evaluación del trazado, que servirá para determinar si el trazado es factible o no. 6. Líneas 82-114: Definimos la función de evaluación de la planta, que nos permitirá determinar, en caso de que esta sea factible, las variables relativas a sus prestaciones (potencia y coste). 7. Líneas 116-128: Definimos ahora la función de fitness para el caso monoobjetivo. Notemos que esta función llama a las dos funciones de evaluación anteriores. 8. Líneas 130-142: Definimos también la función de fitness para el caso monoobjetivo. De nuevo podemos comprobar que esta función necesita llamar a las dos funciones de evaluación anteriormente descritas. 9. Líneas 144-169: Creamos la función de ejecución de nuestro algoritmo genético monoobjetivo, que requiere la especificación de dos argumentos: la probabilidad de cruce y la de mutación. Aquí definimos además el número de generaciones, de mu y de lambda . 10. Líneas 171-187: Aquí hacemos lo mismo para el caso multiobjetivo. 11. Línea 190: Con un condicional de tipo i f determinamos cuál es el modo que se desea resolver (indicado a través de la variable multi que definimos en la línea 13). 12. Líneas 192-214: Se crea el problema monoobjetivo, declarando todas las funciones necesarias. Creamos el problema (línea 214) y el individuo (línea 217), y creamos además el toolbox correspondiente, en el que registramos las operaciones de generación de individuos y población (líneas 223 y 225), la función de fitness (línea 229), y los operadores de selección, mutación y cruce (líneas 231-233). 13. Líneas 218-240: Se crea el problema multiobjetivo de forma análoga al monoobjetivo, declarando las funciones necesarias. Para ello creamos el problema (línea 240), el individuo (línea 243), y el toolbox (línea 246). Por último, incorporamos en el toolbox las operaciones de generación de individuos y población (líneas 249 y 251), la función de fitness multiobjetivo (línea 255), y los operadores de selección, mutación y cruce (líneas 257-259). 14. Líneas 242-246: Lanzamos la ejecución del problema en el modo correspondiente. Código 6.3. Código final desarrollado para el problema de optimización de una planta microhidráulica. En cuanto a las lecciones aprendidas, este capítulo ha consistido en la aplicación de un algoritmo genético para resolver un problema real de ingeniería, basado en diseñar de forma óptima el trazado de una planta microhidráulica, de forma que los recursos naturales del terreno se usen de la forma más eficiente posible. El problema, además, se ha abordado de dos formas: ■En primer lugar, se ha determinado el diseño óptimo de la planta microhidráulica para un nivel de generación fijado, de manera que se ha obtenido el trazado que resulta en el menor coste posible. ■En segundo lugar, se ha incrementado la complejidad del problema y se ha resuelto la optimización considerando simultáneamente la maximización de la potencia. Esto ha permitido evaluar no solo el trazado óptimo para satisfacer la generación de potencia mínima, sino que además hemos obtenido información acerca de cómo variaría el coste de la planta si el nivel de potencia requerida se incrementase, proporcionando un mejor análisis del verdadero potencial del recurso natural estudiado. ■Con respecto al diseño del algoritmo genético, se ha destacado cómo en problemas con restricciones complejas, es necesario estudiar una estrategia de generación de individuos (población inicial) apropiada. En este problema se ha optado por generar soluciones iniciales válidas aunque poco competitivas en cuanto al fitness , con la idea de mejorarlas en las distintas generaciones del algoritmo. Esta técnica puede hacer que el algoritmo necesite más generaciones para converger, pero en nuestro problema merece la pena. ■Se ha diseñado un método de mutación ajustado al problema, teniendo en cuenta, además, el punto de partida de las soluciones iniciales. Así, se ha optado por hacer más probable que un gen se haga 0 que que el mismo gen se haga 1, considerando que las soluciones iniciales tendrán gran cantidad de unos consecutivos. Los resultados han demostrado la capacidad de las estrategias evolutivas para resolver problemas discretos de ingeniería con difícil o imposible resolución analítica, utilizando un conjunto de ecuaciones sencillas y tiempos de computación relativamente bajos. 6.6 Para seguir aprendiendo El problema estudiado en este capítulo representa una versión sencilla de un problema de ingeniería complejo y, como tal, puede abordarse desde un punto de vista más ambicioso. Por ejemplo, podríamos incluir el valor del diámetro de la tubería como variable de diseño. ¿Cómo podríamos introducir esta nueva variable en el cromosoma? También podemos considerar que los nodos podrían ocupar cualquier posición del terreno y no solo en puntos discretos. ¿Podemos considerar una interpolación de los puntos para formular el problema de forma continua? Para profundizar más en este problema y en sus posibles aplicaciones recomendamos la lectura de los siguientes trabajos: ■En (Tapia et al., 2018) se aborda un problema similar al de este capítulo utilizando programación entera. ■En (Tapia et al., 2019) se comparan los resultados del algoritmo basado en programación entera y un algoritmo genético. Los resultados demuestran que el algoritmo genético es capaz de conseguir mejores resultados. ■El problema se amplía en (Tapia et al., 2020a) considerando el coste de la instalación eléctrica necesaria. ■Por último, en (Tapia et al., 2020b) se propone un algoritmo genético con cromosomas de longitud variable, también llamados Messy Algorithms (Goldberg et al., 1989). Además, el problema se resuelve utilizando variables continuas en vez de variables discretas, por lo que el problema aumenta en complejidad, pero también permite obtener mejores resultados. Información detallada de todos los trabajos anteriores se puede encontrar en (Tapia, 2019). Como problemas se plantean los siguientes: ■Realice una comparación entre los resultados de aplicar el cruce de un punto, el cruce de dos puntos y el cruce uniforme. ■Una vez obtenido el operador de cruce adecuado, realice un barrido de las probabilidades indpb_01 e indpb_10 para obtener los valores más adecuados. Compare los resultados obtenidos con el operador de mutación mutFlipBit incluido en deap . ■Aumente la potencia requerida, P min , para que una solución sea válida y compruebe que, a media que se requiere más potencia, el trazado de las soluciones aumenta. _________________ 1https://docs.scipy.org/doc/numpy/reference/generated/numpy.nonzero.html 2https://docs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.interp1d.html 3Realmente, considerando las restricciones impuestas el número total de posibles soluciones es algo inferior, pero igualmente elevado como para utilizar un algoritmo de fuerza bruta. 7.1 Introducción El posicionamiento óptimo de sensores es un problema recurrente en cualquier tipo de instalación (Chmielewski et al., 2002). Por ejemplo, considere un campo de cultivo que es regado periódicamente a través de canales de agua. Para asegurar un correcto funcionamiento del sistema de riego, es necesario tomar medidas de la presión del agua en diferentes puntos de la instalación. Dichas medidas pueden ser tomadas mediante sensores inalámbricos. Los sensores envían las medidas a puntos inalámbricos de conexión que las almacenan y, a su vez, envían las medidas a un sistema de monitorización y control centralizado. Como se puede observar, el posicionamiento de los puntos inalámbricos de conexión tiene una importancia notable para el correcto funcionamiento del sistema. Su mal posicionamiento puede ocasionar la falta de medidas de alguno de los sensores y, consecuentemente, el posible fallo del sistema de riego sin la correspondiente percepción del mismo. Otro problema similar se da en relación al posicionamiento de torres de telecomunicaciones para dar cobertura a los usuarios. De la misma forma, podemos pensar en el posicionamiento óptimo de puntos de acceso Wi-Fi para dar cobertura de manera eficiente al mayor número de usuarios posible (Eldeeb et al., 2017; Reina et al., 2013). Así, este capítulo pretende plantear y resolver el problema del posicionamiento de un conjunto de puntos de conexión inalámbricos con el fin de cubrir el máximo número posible de puntos de interés en una superficie. La formulación formal del problema, así como su planteamiento y su resolución, serán tratados en los siguientes apartados. Antes de introducir dichos desarrollos, es importante destacar que este problema se enmarca, dentro de la teoría de complejidad computacional, como un NPduro. Este concepto ya fue introducido en el Capítulo 2, sección 2.10, donde se ahondó más en estos conceptos. Aquí, simplemente diremos que un problema NP-duro es aquel para el cual pueden existir algoritmos o procedimientos que pueden resolverlo pero que no son deterministas o requieren un tiempo exponencial (y, por consiguiente, no polinómico). 7.2 Formulación del problema Consideraremos una superficie en la que existen cierto número de puntos de interés distribuidos. Para facilitar el problema, supondremos que la superficie objeto de estudio es rectangular. De tal manera, podemos definirla mediante su altura y su anchura. En este caso optaremos por una superficie cuadrada de 400 hectáreas, es decir, una superficie de 2000 metros de ancho y 2000 metros de altura. En dicha superficie, existirán un total de 75 puntos de interés1. Para generar los puntos de interés utilizaremos el siguiente código: Cabe destacar un aspecto importante de dicho fragmento de código. Como se puede observar, utilizamos una semilla de la librería random2. Dicha semilla permitirá que, al generar los puntos de interés de forma aleatoria, se obtengan los mismos valores. Así, siempre que ejecutemos nuestro algoritmo para resolver el problema, estaremos trabajando sobre el mismo escenario; de esta manera, podremos comparar los resultados obtenidos para distintas versiones o modificaciones del algoritmo genético (Likas et al., 2003). Ejecutando el script anterior obtenemos la Figura 7.1. Se puede ver que tenemos una distribución uniforme de puntos de interés en el escenario. Otro problema distinto, que no se abordará en el libro, es considerar distintos clusters de puntos de interés en el escenario. Si el lector no se encuentra familiarizado con el concepto de cluster, diremos que es un conjunto de nodos agrupados por una métrica de similitud, que en este caso y por ejemplo puede ser la distancia entre ellos. Con el fin de cubrir en cobertura los puntos de interés generados, contamos con varios puntos de conexión inalámbrica. Dichos puntos de conexión, tienen una cobertura circular que les permiten cubrir puntos de interés situados a una distancia igual o menor a 100 metros a su alrededor. De esta manera, si un punto de interés se sitúa a una distancia menor o igual a 100 metros respecto al punto de conexión, diremos que este se encuentra cubierto. En caso contrario, diremos que está fuera del alcance. Este modelo para definir el alcance del punto de conexión (también denominado modelo de propagación) se conoce como modelo de disco y, aunque simple, se utiliza en un gran número de casos. Es posible utilizar modelos de propagación más complejos, como por ejemplo el modelo de dos rayos (Bacco et al., 2014) o modelos multitrayectos (Tsai, 2008). Dichos modelos solo variarían el área de cobertura de los nodos inalámbricos, pero el problema sería idéntico al expuesto en este capítulo3. En la Figura 7.2 se muestran los puntos de interés considerados y una posible localización de un punto de conexión con su correspondiente área de cobertura. Para generar dicha figura basta con incluir al anterior script las siguientes líneas: Figura 7.1. Superficie considerada y puntos de interés. Figura 7.2. Cobertura cubierta por un punto de conexión situado en (500,1000). Así, si nos fijamos en la Figura 7.2 podemos observar cómo conseguimos cubrir dos puntos de interés mediante la introducción de un único punto de conexión inalámbrico en las coordenadas x = 500 e y = 1000. Una vez introducidos los conceptos necesarios para plantear el problema, procedemos a formularlo matemáticamente. Para ello, es necesario definir la condición que indica si un punto de interés dista menos de 100 metros de uno de conexión. Así, serán (xi,yi) las coordenadas de un punto de interés cualesquiera y serán las coordenadas de un punto de conexión inalámbrica. Entonces, el punto de interés se encuentra cubierto por dicho punto de conexión si -y solo si-: donde di–c representa la distancia euclídea entre ambos puntos. Así, podemos generar una función en Python que nos permita determinar si un punto de interés se encuentra cubierto o no por uno de conexión. Para ello, utilizaremos el siguiente fragmento de código: Nuestro problema puede ser definido como, dados 75 puntos de interés (xi,yi para i = {1,...,75}), determinar las coordenadas de los 50 puntos de conexión inalámbrica para c = {1,...,50}), de tal manera que se cubra en cobertura el mayor número posible. Para ello, cada uno de los individuos considerados estará compuesto por 100 genes que corresponderán a las coordenadas (x e y) de cada uno de los puntos de conexión inalámbrica. Una representación gráfica de la estructura del individuo considerado se puede observar en la Figura 7.3. Figura 7.3. Estructura de cada individuo considerado en el problema. Es importante destacar que la única restricción de nuestro problema será el posicionamiento de los puntos de conexión dentro del área objeto de estudio. Así, podemos definir una función que evalúe la posición de un punto de conexión y devuelva un valor indicando si se encuentra dentro o fuera de dicha área. El código presentado a continuación evalúa la pertenencia o no del punto de conexión al área definida. En las siguientes secciones se definirá la estrategia que adoptaremos para la resolución del problema, así como las consideraciones más importantes que hay que tener en cuenta con el fin de obtener una solución apropiada que cumpla nuestras expectativas. 7.3 Problema con un objetivo: Maximizando el número de puntos cubiertos En este apartado resolveremos el problema planteado a lo largo del capítulo. Sin embargo, aún es necesario definir formalmente cuál es el objetivo de nuestro algoritmo. Como hemos mencionado anteriormente, la finalidad del algoritmo será la de cubrir en cobertura el máximo número posible de los 75 puntos de interés considerados mediante el posicionamiento óptimo de 50 puntos de conexión inalámbricos. Así, se podrá cuantificar la optimalidad de la solución mediante un número entero que variará entre 0 y 75 atendiendo al número de puntos de interés cubiertos. El problema con un único objetivo que queremos resolver se puede definir matemáticamente como: Es importante destacar la imposibilidad de resolver dicho problema mediante métodos de resolución convencionales, debido al carácter altamente no lineal y al uso de variables tanto discretas como continuas en nuestro problema. 7.3.1 Definición del problema y generación de la población inicial Una vez definido el problema que deseamos resolver, estamos en posición de desarrollar las funciones que necesitará nuestro algoritmo para llevar a cabo las operaciones genéticas pertinentes. Así, será necesario definir una función que provea al algoritmo de individuos iniciales, es decir, individuos que defina una distribución inicial de los puntos de conexión inalámbricos. Posteriormente, mediante operaciones genéticas, dichos individuos irán cambiando hasta lograr una configuración óptima. Estos cambios se producirán como consecuencia de operaciones genéticas de mutación y cruce o crossover. De esta manera, lo primero que debemos hacer para poner en marcha lo que serán las bases del algoritmo es definir el problema y la caja de herramientas o toolbox en la cual registraremos las operaciones genéticas a utilizar: Como podemos observar, se trata de un problema de maximización; de ahí el peso positivo en el atributo weights en la creación del problema. Como siguiente paso, registramos en la caja de herramienta las funciones que realizarán las operaciones genéticas definidas anteriormente. Definir la población inicial que considerará el algoritmo evolutivo, es un punto crítico que marcará la calidad de la solución otorgada por el mismo. En este caso, y a diferencia de problemas introducidos en capítulos anteriores, el problema se encuentra poco restringido. La única restricción a considerar es que todos los puntos deben estar incluidos en la superficie considerada. De esta manera, una posible función para crear individuos es la que se presenta en el siguiente fragmento de código: En este caso hemos optado por asignar valores aleatorios siguiendo una distribución uniforme para cada valor de x y de y de cada punto de conexión. Cabe recordar que una distribución uniforme es aquella definida por dos extremos a y b en la que la variable aleatoria solo puede tomar valores comprendidos entre a y b, de manera que todos los intervalos de una misma longitud (dentro de a y b) tienen la misma probabilidad. Al igual que hemos comentado en otros capítulos, esta función se presenta como una posible candidata y no como la única posible para el problema. De hecho, si se tomara en consideración más información sobre la localización de los puntos de interés, tal vez se podría optar por otra manera de generar la población inicial, tratando de situar los puntos de conexión lo más cerca posible de los puntos de interés (veremos un ejemplo de esta posibilidad más adelante), por ejemplo, si consideramos que los puntos de interés están agrupados en clusters. Otra técnica posible es utilizar diagramas de Voronoi para dividir el escenario. Estos conceptos no se explicarán en el libro y simplemente se mencionan con el fin de despertar la curiosidad del lector. Así, registramos las funciones pertinentes para generar la población inicial de nuestro problema: Observe que para la creación de individuos se utiliza la herramienta tools.initIterate, ya que generaremos individuos de forma aleatoria y no únicamente genes. 7.3.2 Operadores genéticos Una vez definidos el problema y la función que nos proveerá de individuos iniciales, es el momento de declarar las operaciones genéticas que utilizaremos. Primero, registramos la función de cruce que, al igual que en capítulos anteriores, se tratará del cruce cxBlend: Con el fin de realizar pequeñas modificaciones en un individuo para evaluar si mejora o no la función objetivo a minimizar, utilizamos un operador de mutación Gaussiana acotado. Este operador es similar al de mutación Gaussiana que hemos visto anteriormente, pero trunca el valor del gen mutado si se excede de un rango. Registramos el operador de mutación en nuestro toolbox: En cuanto al algoritmo de selección, nuevamente utilizaremos la selección por torneo: A continuación, será declarada la función objetivo de nuestro problema, y se mostrarán y analizarán los resultados obtenidos. 7.3.3 Función objetivo Para registrar la función objetivo en nuestro toolbox utilizaremos la siguiente línea de código: Para generar la función objetivo o de fitness será necesario evaluar cada una de las funciones presentadas en los fragmentos de código introducidos a lo largo del capítulo en el orden adecuado. Así, con el fin de que la función sea lo más eficiente posible, parece lógico evaluar primero si el individuo cumple las restricciones del problema. De esta forma, en caso de no cumplir, sería posible descartar directamente la solución sin necesidad de evaluar el número de puntos de interés cubiertos. En consecuencia, parece claro que la evaluación de estas funciones de forma ordenada nos permitirá un mayor rendimiento computacional del algoritmo. De esta manera, se propone la siguiente función objetivo: Es importante observar la estructura de la función de fitness. Así, primero se evalúa si el punto de conexión se encuentra dentro de la región admisible. En caso de que lo esté, se procede a evaluar qué puntos de interés cubre. Entonces, para cada punto de interés, primero se observa si ya ha sido cubierto y, en caso de que no lo haya sido, evaluamos su proximidad. En caso de que cubra un punto de interés, escribirá en el vector pdi_vector un uno en la componente correspondiente. Hay que tener en cuenta que la variable penaliza hace referencia a la pena de muerte cuando se desea descartar una de las soluciones. En este caso, planteamos un problema de maximización ya que deseamos maximizar el número de puntos de interés cubiertos por los puntos de conexión. De esta manera, el valor de la variable penaliza debe ser negativo. Observe que un valor negativo de la función de fitness es peor que cualquier solución válida al problema. Por ejemplo, la situación más desfavorable sería no cubrir ningún punto de interés, en cuyo caso obtendríamos un cero. No hay que olvidar que para un correcto funcionamiento posterior de la librería deap, es un re A continuación, veamos cómo llamar al algoritmo genético que resolverá nuestro problema. 7.3.4 Ejecución del algoritmo Una vez definidas todas las partes necesarias del programa, estamos en situación de poder ejecutar el algoritmo y analizar los resultados obtenidos. En este caso, y al igual que realizamos en casos anteriores, optaremos por utilizar un algoritmo µ +λ. Para ejecutar el algoritmo es necesario definir una serie de parámetros que se encuentran expuestos a continuación: ■NGEN : Fijaremos su valor en 700. Este parámetro se puede variar en función de lo observado en la gráfica de convergencia. ■MU : Fijamos el tamaño de la población. Establecemos 300 para esta prueba. Teniendo en cuenta que el cromosoma tiene una longitud de 50, es un valor razonable para empezar. ■LAMBDA : Se fija el tamaño de descendientes en cada nueva generación. Se fija con un valor igual a 300. Como hemos fijado µ = λ , el tamaño de la población permanece constante a lo largo de las generaciones. Como parámetros de entrada a la función se usará la probabilidad de cruce (c) y mutación (m). Además, se utilizará la caja de herramientas o toolbox, que es necesario definirla como una variable global. La función de llamada al algoritmo se presenta a continuación: Como se puede observar, esta función inicializa el problema y realiza una llamada al algoritmo basado en la configuración adoptada en el toolbox. Se establece un registro de los valores mínimos, máximos y medios de lo devuelto por la función de fitness, así como de la desviación típica de dichos valores para todos los individuos de la cada generación. Observe, además, que al usar un indviduo de tipo np.ndarray, en el objeto HallOfFame, es necesario introducir la sentencia similar = np.array_equal. Esto es así porque la comparación entre listas y arrays de numpy no se realizan de igual manera (ver Apéndice A). El script tarda en ejecutarse alrededor de 1000 segundos. Este tiempo de ejecución ha sido medido tras ejecutar el algoritmo en un Intel Core i5-72000U a 2.5 GHz con 8 Gb de memoria RAM. 7.3.5 Resultados obtenidos Podemos ejecutar nuestro algoritmo mediante una llamada a la función unico_objetivo_ga a través de la siguiente línea de código: En este ejemplo, hemos tomado como valores de probabilidad una c =0.7 y una m =0.3 pero, al igual que hicimos en capítulos anteriores, realizaremos un barrido para explorar la calidad de las soluciones para diferentes configuraciones de los parámetros c y m. Una vez finalizada la ejecución, debemos analizar la gráfica de convergencia del algoritmo para evaluar si la solución otorgada por el mismo es óptima o si aún existe margen de mejora. Así, generamos la gráfica representada en la Figura 7.4. Como se puede observar, conforme avanzan las generaciones, el valor de la función de fitness aumenta hasta estabilizarse asintóticamente en 64. Dicho valor, es el óptimo obtenido mediante el uso del algoritmo. Se observa que el algoritmo converge aproximadamente en la generación 600. Una vez comprobada la convergencia del algoritmo, observamos que se cubren un total de 64 puntos de interés mediante el correcto posicionamiento de 50 puntos de conexión. La representación de los puntos de interés junto con los de conexión, así como su cobertura, son representados en la Figura 7.5. Para generar dicha figura hemos utilizado las siguientes líneas de código: Figura 7.4. Gráfica que muestra la convergencia del algoritmo. Como se puede observar, la solución obtenida mediante el algoritmo evolutivo es bastante buena ya que se cubre el 85.33% de los puntos de interés. Sin embargo, a la vista de los resultados, se observa que la solución puede mejorar aún más dado que existen puntos de conexión que no cubren ningún punto de interés. Por ejemplo, considere los puntos de conexión centrados en torno a las coordenadas (x = 600, y = 900). Estos puntos se solapan, cubriendo los mismos puntos de interés -e incluso no cubriendo ninguno de ellos-. Por tanto, deja en entredicho la optimalidad de la solución. En este simple ejemplo se muestra cómo la solución del algoritmo evolutivo no tiene por qué ser óptima; solo debe mejorar cualquier solución inicial válida. Así, dado que el problema planteado es altamente no lineal, al mezclarse tanto variables continuas como discretas, es muy difícil su resolución mediante métodos convencionales de resolución de problemas de optimización. Los algoritmos genéticos son una alternativa válida y eficiente para obtener soluciones. Del mismo modo, es posible mejorar la solución obtenida, ya sea mediante la reformulación de las funciones genéticas (por ejemplo la operación de mutación y/o cruce), o mediante la creación de individuos factibles iniciales más adaptados al problema objeto de estudio (por ejemplo teniendo en cuenta la posición de los puntos de interés para generar los de conexión). Así, por ejemplo, considere en este caso la siguiente función que nos permite generar los individuos iniciales: Figura 7.5. Resultados obtenidos tras ejecutar el algoritmo con un único objetivo. Como se puede observar, en este caso, cada punto de conexión se situará en la misma posición que un punto de interés. Dicho punto de interés se escoge de forma aleatoria. Por lo tanto, inicialmente, se contará con un mayor número de puntos de interés cubiertos y el algoritmo deberá adaptarse para conseguir cubrir los restantes. En este caso, se logra cubrir un total de 72 puntos de interés, es decir, un 96% del total. La solución obtenida es la presentada en la Figura 7.6. Por último, realizamos un barrido del resultado del problema para diferentes configuraciones de las probabilidades c y m. Una comparación de dichos resultados pueden observarse en la Tabla 7.1. Como se puede observar, se han probado las siguientes configuraciones: (c = 0.6, m = 0.4), (c = 0.7, m = 0.3) y (c = 0.8, m = 0.2) y para cada una de ellas se ha resuelto el problema diez veces. Para llevar a cabo esta batería de pruebas hemos utilizado el siguiente código: Figura 7.6. Resultados obtenidos tras ejecutar el algoritmo con un único objetivo con la nueva función para generar individuos. Observe cómo hemos generado dos archivos de texto para almacenar, respectivamente, los valores de la función objetivo y los mejores individuos obtenidos como solución. Pcx Pmut 0.6 0.4 min(CF) 70 max(CF) 74 avg(CF) 72.1 0.7 0.3 0.8 0.2 70 75 72.7 68 73 71.4 Tabla 7.1. Resultados de la función objetivo para diferentes configuraciones de la probabilidad de cruce y mutación La mejor solución se obtiene para el segundo caso que corresponde a una probabilidad de c = 0.7 y m = 0.3 para la cual se cubren, en el mejor de los escenarios, 75 puntos de interés, es decir, el 100% del total. La mejor solución se representa en la Figura 7.7. Se puede observar que todos los puntos de interés están cubiertos. Figura 7.7. Resultados obtenidos tras ejecutar el algoritmo con un único objetivo con la nueva función para generar individuos y unos valores de probabilidad de c = 0.7 y m = 0.43. Los Textos 7.1 y 7.2 muestran fragmentos de los archivos de texto individuos_sensores.txt y fitness_sensores.txt, respectivamente. Texto 7.1. Fragmento del archivo de texto individuos_sensores.txt con los resultados obtenidos tras el barrido. Texto 7.2. Fragmento del archivo de texto fitness_sensores.txt con los resultados obtenidos tras el barrido. En las siguientes secciones, se planteará la situación en la que existan dos objetivos a optimizar. Dichos objetivos serán opuestos. 7.4 Problema con múltiples objetivos: maximizando el número de puntos cubiertos y la redundancia Movámonos ahora a la situación en la que existe más de un único objetivo a optimizar. Supongamos que, en ocasiones, algunos de los puntos de conexión pueden presentar fallos o retrasos en las comunicaciones. Así, parece lógico intentar cubrir los mismos puntos de interés con más de un punto de conexión con el fin de tener redundancia en las medidas. Para ello, en este segundo objetivo intentaremos maximizar el número de puntos de interés cubiertos por los puntos de conexión sin tener en cuenta redundancias. Es decir, si dos puntos de conexión cubren los mismos dos puntos de interés, consideraremos un total de cuatro puntos cubiertos. En este caso, se tendrán dos objetivos contrapuestos. Por un lado, el objetivo original tenderá a distribuir los puntos de conexión de la forma más espaciada posible, con el fin de cubrir tantos puntos de interés como sea posible. Por otro lado, el nuevo objetivo priorizará la redundancia de cobertura por parte de los puntos de interés, en lugar de cubrir el mayor número. De esta forma, tenderá a focalizar los puntos de conexión en las zonas con mayor concentración de puntos de interés. Así, la formulación del problema será la siguiente: 7.4.1 Definición del problema, población inicial y operadores genéticos Para poder ejecutar el algoritmo, será necesario realizar algunas modificaciones en el toolbox, así como en la función que llama a la rutina. En concreto, el código quedaría como sigue: Como se puede apreciar, en la primera línea de código se ha modificado los pesos indicando que ahora hay dos parámetros a maximizar. Por otro lado, la selección se realiza mediante selNSGA2. 7.4.2 Función objetivo La función objetivo o de fitness de este algoritmo multiobjetivo puede ser escrita de la siguiente forma: Observe cómo la función objetivo devuelve una tupla cuya primera componente cuantifica el número de puntos de interés cubiertos por los puntos de conexión, mientras que la segunda devuelve el sumatorio del número de puntos de interés cubiertos por todos los puntos de conexión. 7.4.3 Ejecución del algoritmo Para poder ejecutar el algoritmo, será necesario realizar algunas modificaciones en la función que llama a la rutina. En concreto, el código quedaría como sigue: Finalmente, en la función multiple_objetivo_ga se indica que se desea buscar las componentes que conformarán el frente Pareto. 7.4.4 Resultados obtenidos Ejecutamos el algoritmo mediante el siguiente código: Mediante estas líneas de código, generaremos dos archivos de texto: individuos_sensores_multi.txt y fitness_sensores_multi.txt. En el primero se almacenarán los individuos óptimos que conforman el frente Pareto, mientras que en el segundo se guardarán los datos de su función de fitness correspondiente. Un fragmento de dichos ficheros puede observarse en los Textos 7.3 y 7.4. Texto 7.3. Fragmento del archivo de texto individuos_sensores_multi.txt. Texto 7.4. Fragmento del archivo de texto fitness_sensores_multi.txt. Así, la Figura 7.8 muestra el frente de Pareto obtenido como resultado de la ejecución del algoritmo. Dada una solución que genera una cobertura y un valor de redundancia, la recta tangente al frente Pareto muestra qué supondría en uno de los objetivos un incremento en la optimalidad del otro. Figura 7.8. Frente de Pareto del problema abordado. Analicemos, pues, el frente Pareto obtenido. Si partimos del punto más hacia la derecha, vemos que un punto del frente es aquel en el que se cubren 65/75 puntos de interés con una redundancia de 116 (tal y como muestra la Figura 7.9). Observe que a pesar de cubrir un alto porcentaje de los puntos de interés, existe una alta concentración de puntos de conexión en torno a las coordenadas (x = 450; y = 1000) y (x = 510; y = 0). Esto se debe a que en esa zona existe una gran concentración de puntos de interés, y por tanto, se consigue un mayor valor de redundancia en pequeño decremento del número de puntos cubiertos. Del mismo modo, si nos desplazamos a través de la curva del frente Pareto hacia la izquierda, disminuimos los puntos de interés cubiertos pero, por otro lado, aumenta la redundancia conseguida. De esta forma, los puntos de conexión se situarán en donde exista una mayor concentración de puntos de interés alcanzando una situación en la que cubriendo 55/75 puntos de interés se logra una redundancia de 130 tal y como muestra la Figura 7.10. Observe que en este caso, existe aún más concentración de puntos de conexión en el área en torno al punto (x = 510; y = 0) así como enb el (x = 1650; y = 800). Cabe destacar que la Figura 7.8 ha sido generada de forma similar a las figuras de resultados anteriores, y que lo único que debe tenerse en cuenta aquí, es realizar de forma adecuada la lectura de los ficheros de texto para escoger adecuadamente el individuo a representar. Así, ya que el archivo fitness_sensores_multi.txt está ordenado de forma creciente respecto al primer objetivo, hemos optado por representar el individuo correspondiente (del archivo individuos_sensores_multi.txt) a la primera y última línea. A continuación, se introduce un breve código para facilitar la lectura de los archivos donde se almacenan los datos y convertirlos a una lista: Figura 7.9. Solución obtenida para el punto del frente Pareto que mayor número de puntos cubre. Figura 7.10. Solución obtenida para el punto del frente Pareto que mayor redundancia alcanza. 7.5 Código completo y lecciones aprendidas Primero, analicemos los pasos que hemos seguido para la resolución de este problema. Para ello, nos apoyaremos en el código completo mostrado en Código 7.5: 1. Líneas 1-7: Primero, es necesario importar todas las librerías utilizadas. En nuestro caso, haremos uso de la librería deap 4 para todo lo referente a la resolución del problema mediante estrategias evolutivas. Las librerías numpy 5 y random 6 se utilizarán para operar matemáticamente, así como para generar números aleatorios. Por último, matplotlib 7 nos ayudará a generar gráficas. 2. Cabe destacar en la línea 10 la variable multi que determina si el problema se va a resolver con un solo objetivo o con dos objetivos. Por defecto se ejecutará el problema monoobjetivo. Si deseamos ejecutar el problema multiobjetivo, solo debemos asignar a dicha variable el valor True . 3. Líneas 13-37: Definimos las funciones area y cobertura , que nos servirán para evaluar si un punto de conexión se encuentra dentro del área de interés, y si un punto de interés se encuentra cubierto por uno de conexión. 4. Líneas 39-74: Se definen las funciones genéticas de generación de individuos y mutación. 5. Líneas 76-121: Se declara la función de fitness por la cual se conocerá la optimalidad de la solución evaluada (tanto para un único objetivo como para dos). 6. Líneas 123-183: Se define la función que llama al algoritmo. Se definen los parámetros característicos del mismo. 7. Líneas 187-270: El problema es definido. Se declaran los puntos de interés, el toolbox y se hace la llamada a la función que recoge el algoritmo evolutivo. Código 7.5. Código final desarrollado. Por otro lado, cabe concluir que este capítulo ha presentado la aplicación de los algoritmos genéticos sobre un problema real. El problema consiste en el reparto de unos puntos de conexión de manera óptima con el fin de recoger datos de ciertos puntos de interés. Dos objetivos han sido abordados: la maximización de los puntos de interés cubiertos y la redundancia en la cobertura de los mismos. Podemos destacar las siguientes lecciones aprendidas: ■Hemos tratado un problema cuyas variables o genes del individuo son continuas pero en el que, por el contrario, el valor devuelto por la función de evaluación tiene un valor discreto entero. ■A diferencia que en capítulos anteriores, hemos planteado el problema como uno de maximización (y no de minimización) exponiendo las diferencias que son necesarias. ■Hemos visto la importancia que tiene la población inicial a la hora de alcanzar resultados positivos. Una buena estrategia para crear la población inicial puede hacer que el problema converja hacia valores más adecuados. ■En el problema con dos objetivos se ha obtenido un frente de Pareto discreto. Además, se han analizado las soluciones del frente de Pareto. ■Hemos observado que aun siendo un problema con menor número de restricciones, al tratarse de un NP-duro su complejidad es elevada y, por consiguiente, también lo es su tiempo de resolución. Se puede observar que se necesita un elevado número de generaciones. 7.6 Para seguir aprendiendo Con el fin de lograr un mayor conocimiento sobre este tipo de problemas, se recomienda la lectura de los siguientes trabajos: ■En primer lugar, mencionar varios resúmenes sobre problemas de cobertura en redes inalámbricas: (Wang, 2011) (Ghosh y Das, 2008)(Reina et al., 2016a). ■Un trabajo sobre posicionamiento óptimo de nodos en redes malladas ( Wireless Mesh Networks ) puede encontrarse en (Oda et al., 2013). ■En (Reina et al., 2012) se utiliza un algoritmo genético para el posicionamiento de sensores en un entorno ferroviario mediante algoritmos genéticos. ■En (Reina et al., 2013) se emplea un algoritmo genético para obtener las posiciones óptimas de un conjunto de puntos de acceso, que mejoran la conectividad de un equipo de rescate en un escenario de desastres. ■Los siguientes trabajos están enfocados al posicionamiento óptimo de vehículos aéreos para dar cobertura a nodos en tierra: (Reina et al., 2018b), (Reina et al., 2018a), (Reina et al., 2016b). ■Un enfoque multiobjetivo al problema del posicionamiento de sensores para medidas medio ambientales puede consultarse en (Kim et al., 2008). Como ejercicios se plantean los siguientes: ■Realice una comparación de los resultados obtenidos por el algoritmo genético con distintos métodos de cruce y mutación. ■Utilice el método de selección de ruleta y compare los resultados con los obtenidos mediante torneo. ■Utilice un algoritmo de clustering para mejorar la población inicial. Por ejemplo, puede utilizar el algoritmo k-means de la librería scikit-learn 8. Utilice un número de clusters igual al número de puntos de interés. Compruebe si mejora la convergencia del algoritmo. ■Considere que la cobertura de un sensor es mejor cuanto más cerca se encuentra el punto de interés de conexión. Así, no se tendrá un uno si se cubre un punto y un cero en caso contrario, sino que se tendrá un número real que indique la calidad de dicha cobertura. Maximice la cobertura de la red. ■Considere un tipo diferente de cobertura que no venga dada por un círculo y observe los resultados. _________________ 1Se puede variar este número si se quiere aumentar la complejidad del problema. 2https://docs.python.org/library/random 3El análisis de los resultados con distintos modelos de propagación está fuera del alcance de este libro 4https://deap.readthedocs.io/ 5https://docs.scipy.org/doc/numpy/reference/ 6https://docs.python.org/3/library/random.html 7https://matplotlib.org/ 8https://scikit-learn.org/stable/modules/clustering.html#k-means Epílogo En esta obra hemos abordado el aprendizaje de algoritmos genéticos para resolver problemas de ingeniería mediante un enfoque práctico. Se comenzó con ejemplos sencillos que aumentaron en complejidad conforme avanzábamos. En la segunda parte del libro, hemos resuelto hasta tres problemas de ingeniería reales de áreas muy distintas, como la ingeniería eléctrica, hidráulica y redes de sensores, que sumados al problema del viajero estudiado en la primera parte y considerado un problema de transporte, representan un amplio espectro de posibilidades. Se han abordado problemas de distinta topología, incluyendo variables discretas y continuas. Además, todos los problemas de ingeniería resueltos se han planteado de dos modos: i) con un solo objetivo y ii) con múltiples objetivos. Así, esperamos que esta obra haya sido útil para los lectores y que sirva de referencia para futuros trabajos. Antes de terminar, nos gustaría mencionar otros campos de la computación evolutiva para los que la librería deap tiene herramientas disponibles: ■Estrategias evolutivas: Las estrategias evolutivas o evolutionary strategies nacieron años después de los algoritmos genéticos en la década de los años 60, en la Universidad Técnica de Berlín (Ingo Rechemberg y Hans Schwefel) (Beyer y Schwefel, 2002). Están más enfocadas a problemas con variables continuas y su fortaleza está en los métodos de mutación. En las estrategias evolutivas, no solo mutan las variables del problema, sino que también sufren modificaciones ciertos parámetros de los métodos de mutación empleados. La librería deap dispone de un amplio abanico de herramientas para desarrollar estrategias evolutivas 9. ■Programación genética: La programación genética o genetic programming es otra rama de la computación evolutiva, nacida a principio de los 90 de la mano de los trabajos de John Koza (Koza, 1992). El objetivo de la programación genética es encontrar el programa óptimo para cierta tarea. Por lo tanto, el algoritmo intenta buscar la relación más adecuada (operaciones) entre las variables del problema. El abanico de operaciones debe ser indicado por el programador mediante un set de primitivas donde el algoritmo elegirá en cada caso. Una de las principales características de los algoritmos de programación genética es la representación de los individuos, que se realiza en forma arbórea. Así, mediante un árbol se determinan las operaciones óptimas que se realizan entre las variables de entrada (hojas del árbol) hasta el resultado (nodo raíz). La librería deap dispone también de herramientas y ejemplos para desarrollar programación genética 10. ■Optimización basada en enjambre: Por optimización basada en enjambre se entiende un conjunto de técnicas de optimización bioinspiradas. Estas técnicas no están dentro de la computación evolutiva clásica; sin embargo, sí son algoritmos metaheurísticos como los algoritmos genéticos. Los algoritmos bioinspirados emulan el comportamiento social de ciertas especies para resolver problemas de optimización complejos. En la actualidad existen numerosos algoritmos de optimización bioinspirados (Ser et al., 2019), tales como Particle Swarm Optimization (PSO) (Kennedy y Eberhart, 1995), Firefly Algorithm (FA) (Yang et al., 2008) y Cuckoo Optimization Algorithm (COA) (Rajabioun, 2011), entre otros muchos. La librería deap dispone de ejemplos de aplicación del PSO 11. _________________ 9https://deap.readthedocs.io/en/master/examples/es_fctmin.html 10https://deap.readthedocs.io/en/master/examples/gp_symbreg.html 11https://deap.readthedocs.io/en/master/examples/pso_basic.html En este apéndice se abordan los problemas que surgen al heredar de un array de numpy a la hora de crear individuos. Imaginemos que definimos un problema de minimización y queremos utilizar arrays de numpy de la siguiente forma: La clase Individual hereda de np.ndarray. Desde el punto de vista de las operaciones que podemos realizar con arrays de numpy, este procedimiento es muy interesante, ya que podemos sacar provecho de la gran cantidad de funciones que tenemos disponibles en la librería numpy. Sin embargo, desde el punto de vista de cómo funciona deap, debemos tener cuidado con dos aspectos: 1. Los operadores genéticos de la librería no funcionarán correctamente debido a cómo se realiza la operación slicing en los arrays de numpy . 2. La forma de comparar soluciones debe adaptarse, debido al funcionamiento de las operaciones de comparación en arrays de numpy . En este apéndice trataremos ambos problemas y expondremos soluciones para los dos aspectos mencionados. A.1 Introducción a las secuencias en Python En Python un objeto de tipo secuencia está formado por un conjunto de elementos u objetos. A estos elementos se puede acceder de forma independiente por un índice. A continuación, se describen los objetos tipo secuencia mutables1 que se pueden utilizar como cromosomas de los individuos en un algoritmo genético en deap: ■Listas: Las listas son una secuencia de objetos cada uno de distinto tipo. Son objetos nativos de Python . El índice de los elementos es numérico y empieza en el cero. ■Diccionarios: Los diccionarios son una secuencia muy particular, ya que los índices no son numéricos. Los índices se definen mediante objetos inmutables, normalmente de tipo cadena. Cada elemento y cada índice pueden ser de distinto tipo y son nativos de Python . ■arrays : En esta secuencia todos los elementos tienen que ser del mismo tipo. El tipo se indica mediante el atributo typecode . Están definidas en el módulo nativo array 2. El índice de los elementos es numérico y empieza en el cero. ■ndarray : Son secuencias tipo vector definidas en la librería numpy 3. En estas secuencias todos los elementos deben ser del mismo tipo. El tipo de los objetos se define con el atributo dtype. Si este parámetro no se especifica, por defecto considera el tipo flotante. El índice de los elementos es numérico y empieza en el cero. A continuación, se muestran algunos ejemplos de cómo crear secuencias de cada tipo en Python: Resultado A.1. Definición de una lista en Python. Resultado A.2. Definición de un diccionario en Python. Resultado A.3. Definición de arrays del módulo array. Resultado A.4. Definición de arrays de numpy. Cualquiera de estas cuatro secuencias puede ser útil para definir el cromosoma de un individuo en un problema de optimización. No obstante, en el libro -por simplicidad- se utilizan solo tres tipos: listas y arrays tanto de numpy como del módulo array. A continuación, se muestra la diferencia entre estos tres tipos en la operación de slicing o troceado. Dicha diferencia supondrá un problema a superar en el caso de los arrays de numpy. A.2 Slicing en secuencias y operadores genéticos de deap La operación de slicing consiste en acceder a la vez a varios elementos de una secuencia mediante los índices. De forma genérica, en una secuencia en Python la sintaxis para realizar la operación es: secuencia[i:j], accediendo a los elementos [i, j). La diferencia principal entre las secuencias es el objeto que devuelve la operación de slicing. En particular, la gran diferencia reside en los arrays de numpy, ya que estos devuelven una vista de los elementos del array original. Veamos qué significa esto mediante un ejemplo: Script A.5. Slicing en arrays de numpy. En primer lugar, se define el array v1 con seis elementos. A continuación, la vista1 contiene los tres primeros elementos. Dicha vista está apuntando directamente a las mismas posiciones de memoria que el array original. Por lo tanto, cualquier modificación que se haga sobre la vista afecta también al array original. Lo podemos ver en el resultado del anterior script. Resultado A.6. Resultado de la operación slicing en arrays de numpy. Cuando los elementos de la vista se han puesto a cero, se ponen también a cero las posiciones correspondientes del array original. Esto no ocurre ni en las listas ni en los arrays del módulo array. Lo podemos ver con dos ejemplos parecidos al anterior. En el siguiente script se ha creado una lista de seis elementos y se ha obtenido un "trozo"de la lista mediante slicing. En concreto, se toman los tres primeros elementos. Al crear l1_trozo se ha creado un objeto totalmente nuevo que no está enlazado de ninguna manera con l1. Por lo tanto, cualquier modificación en l1_trozo no afecta a la lista original. Script A.7. Slicing en listas. Lo podemos ver en el siguiente resultado. Resultado A.8. Resultado de la operación slicing en listas. A continuación, se muestra el mismo ejercicio con un array del módulo array. Script A.9. Slicing en arrays del módulo array. Se puede ver que, al igual que con las listas, el fragmento (“trozo") seleccionado del array no está relacionado de ninguna forma con el original. Resultado A.10. Resultado de la operación slicing en arrays del módulo array. Dicho comportamiento de los arrays de numpy hace que ciertos operadores genéticos implementados en el módulo tools de deap no funcionen correctamente. Por lo tanto, si al crear las plantillas de los individuos se hereda de numpy.ndarray hay que tener cuidado a la hora de utilizar los operadores genéticos del módulo tools. Por ejemplo, los operadores de cruce de un punto, dos puntos y cruce ordenado no funcionarían correctamente. Existen otros operadores en los que no existe ningún problema, por ejemplo cruce tipo uniforme, blend, cruce polinomial o mutación Gaussiana. En general, cuando se realice un intercambio de genes mediante slicing tendremos que adaptar las operaciones genéticas. En los operadores genéticos que utilicen operaciones matemáticas entre los progenitores, no tendremos ningún problema. Para realizar la operación de slicing sin enlazar el resultado con el array de numpy original, debemos utilizar el método copy como se muestra a continuación. Script A.11. Operación de slicing en arrays de numpy desenlazando el resultado del original. Como resultado, se puede ver que ahora la copia y el original no están enlazados. Resultado A.12. Resultado del slicing en arrays de numpy con el método copy. Por lo tanto, vemos que es muy fácil adaptar la operación de slicing en arrays de numpy para que funcione como en las otras dos secuencias. Así, si queremos utilizar los operadores genéticos de la librería deap que utilizan slicing, lo único que tenemos que hacer es adaptar el código fuente original. Veamos dicho procedimiento con un ejemplo: el cruce de un punto. El siguiente código es exactamente igual que el código fuente original4, con la diferencia de que se ha utilizado el método copy para el intercambio de elementos entre los arrays. Este procedimiento se puede realizar para el resto de operadores que presenten el mismo problema. Script A.13. Operador de cruce de un punto adaptado para arrays de numpy. Observando el resultado, se puede ver que los arrays v1 y v2 se han cruzado correctamente. Resultado A.14. Resultado de la operación de cruce adaptada para arrays de numpy. A.3 Operador de comparación en secuencias Otro aspecto importante en el que cambia el funcionamiento de las distintas secuencias es cuando se realizan operaciones de comparación. Por ejemplo, imaginemos que utilizamos el operador > para comparar si una secuencia es mayor que otra. En las operaciones de comparación entre listas, la operación se realiza comparando cada par de elementos de las listas, empezando desde la primera posición de cada lista, hasta que la operación devuelve verdadero en alguna de las comparaciones. Veamos dicho funcionamiento con un ejemplo: Script A.15. Operaciones de comparación con listas. El resultado de dicho ejemplo se muestra a continuación. Resultado A.16. Resultado de la operación de comparación entre listas. Podemos ver que el resultado es verdadero, ya que el segundo elemento de l2 es mayor que segundo elemento de l1. Es importante tener en cuenta que en la comparación entre listas, estas no tienen que ser del mismo tamaño. La operación funcionará de la misma forma para listas con distintos tamaños. Pasemos ahora a comparar arrays del módulo array; utilizamos los mismos elementos que en el ejemplo anterior de las listas para comparar el resultado. Script A.17. Operaciones de comparación con arrays del módulo array. Se puede observar que el resultado es el mismo que en el caso de las listas. Resultado A.18. Resultado de la operación de comparación entre arrays. Por lo tanto, la operación de comparación funciona exactamente igual para los arrays del módulo array. Por último, pasemos a realizar el mismo ejercicio pero, en este caso, utilizando objetos de tipo ndarray de numpy. Script A.19. Operaciones de comparación con ndarrays de numpy. En este caso, el resultado es totalmente distinto, ya que la operación de comparación devuelve un array del mismo tamaño que los originales, indicando el resultado de la comparación por cada par de elementos. Este funcionamiento es debido a que las operaciones de comparación en numpy están definidas como funciones universales5. Por lo tanto, queda claro que, en este caso, los arrays deben ser del mismo tamaño. Resultado A.20. Resultado de la operación de comparación entre ndarrays de numpy. La pregunta ahora es, ¿cómo afecta este distinto comportamiento de las secuencias en deap? La respuesta es que afecta en los objetos de tipo HallofFame y ParetoFront, ya que estos objetos deben realizar comparaciones entre los fitness de los individuos para actualizarse. Debido al funcionamiento interno de deap, las funciones de comparación deben devolver un solo resultado True o False. Así, para que estos objetos funcionen correctamente con arrays de numpy, se debe proporcionar una función en el atributo similar. A continuación, se muestra cómo hacerlo en cada caso. Para el caso de los objetos HallofFame: La función np.array_equal6 tiene un funcionamiento similar a la comparación de listas mediante el operador ==. Es decir, devuelve True si ambos arrays son iguales en longitud y los elementos son también iguales. En el caso del objeto ParetoFront, se debe proceder de la siguiente forma: En la página oficial de deap se puede encontrar más información sobre la diferencia de funcionamiento en términos de rendimiento entre los arrays de numpy y los arrays del módulo array78. _________________ 1Las secuencias inmutables como las tuplas no se consideran, ya que su inmutabilidad las hace inservibles para los cromosomas de los individuos. 2https://docs.python.org/3/library/array.html 3https://docs.scipy.org/doc/numpy/reference/generated/numpy.ndarray.html 4En Spyder, el código fuente original se puede consultar pulsado el botón control y haciendo clic en la función correspondiente. No obstante, siempre se puede consultar el código fuente en github en https://github.com/deap/deap/ 5https://docs.scipy.org/doc/numpy/reference/ufuncs.html 6https://docs.scipy.org/doc/numpy/reference/generated/numpy.array_equal.html 7https://deap.readthedocs.io/en/master/tutorials/advanced/numpy.html 8https://deap.readthedocs.io/en/master/examples/ga_onemax_numpy.html En este apéndice se aborda cómo trabajar con múltiples procesadores con deap1. La idea principal es distribuir la carga computacional de los algoritmos genéticos entre varios procesadores. Para ello, la evaluación de cada individuo se puede hacer en un procesador distinto. Hay que tener en cuenta que el procesamiento paralelo solamente tiene sentido si la función de evaluación es relativamente costosa; lo veremos a continuación mediante un ejemplo. Así, debemos tener siempre presente que si la función de evaluación la podemos evaluar de manera muy rápida, por ejemplo en cuestión de microsegundos, no nos merece la pena ejecutar el algoritmo genético con procesamiento paralelo. Es más, como se demostrará a continuación mediante un ejemplo, en estos casos el funcionamiento del algoritmo genético será incluso más lento. Para ilustrar el procesamiento paralelo de un algoritmo genético en deap, vamos a utilizar el problema del capítulo 2, esto es, el problema del viajero o TSP. B.1 Procesamiento paralelo con el módulo multiprocessing En este caso vamos utilizar el módulo multiprocessing2. Este es un módulo nativo de Python que se utiliza para lanzar distintos procesos. Para aquellos lectores que desconozcan el funcionamiento de los procesos, debemos indicar que cuando utilizamos múltiples procesos, cada uno tiene recursos independientes. Es decir, los procesos no comparten memoria. Esto no ocurre con los hilos o threads, en los que sí se comparte memoria. Por lo tanto, cuando lanzamos un script en Python utilizando el módulo multiprocessing, es como si cada proceso se ejecutará en un intérprete de Python distinto. Con respecto al algoritmo genético, cada individuo se ejecutará en un procesador distinto. Por lo tanto, si tenemos N procesadores, se podrían evaluar N individuos en paralelo. Ya veremos que, desgraciadamente, esto no es siempre así. A continuación, detallamos las principales líneas de código que debemos añadir al código completo del Capítulo 2 para poder ejecutarlo con procesamiento paralelo. En primer lugar, debemos importar el módulo multiprocessing: En este caso, también se importa el módulo time3 para poder medir el tiempo de ejecución del algoritmo. Así, podremos ver la diferencia entre ejecutar el algoritmo genético en un solo procesador o utilizando múltiples procesadores. En segundo lugar, debemos realizar algunos cambios en el main. En t1 medimos el tiempo de comienzo del algoritmo. Con el objeto pool indicamos en cuántos procesadores vamos a dividir la carga. El atributo processes indica el número de procesos que se distribuirán entre los procesadores disponibles. En este ejemplo se ha utilizado el valor de 4 porque el portátil que se está utilizando para realizar las pruebas dispone de cuatro procesadores4. A continuación, se registra en la caja de herramientas la función map que llama a la función pool.map. La función pool.map funciona como la función nativa de Python map5, pero utilizando múltiples procesadores. Al registrar esta función, estamos cambiando el funcionamiento interno del algoritmo genético. Los algoritmos genéticos implementados en deap utilizan la función map de Python para evaluar los individuos de la población. Al registrar una nueva función map basada en procesamiento paralelo, estamos sustituyendo la función map que utiliza el algoritmo internamente. Es importante destacar que estas dos líneas de código siempre se tienen que ejecutar desde el paraguas de: En caso contrario, obtendremos un error de ejecución. Por último, en t2 medimos el tiempo al terminar el algoritmo genético. Calculando la diferencia entre t2 y t1 podemos calcular el tiempo que ha tardado el algoritmo genético. En este caso el resultado se muestra a continuación6: El tiempo de ejecución obtenido con el código que se ejecutó en el Capítulo 2: Se puede apreciar que en este problema no merece la pena paralelizar la ejecución, ya que el algoritmo genético tarda más. El problema es que la función de evaluación se ejecuta muy rápido. De hecho, en este caso la función de evaluación tarda menos de un microsegundo en ejecutarse. En general, el procesamiento paralelo introduce tiempos de espera para que se terminen de ejecutar los procesos en los distintos procesadores, además de tiempos de planificación. Por lo tanto, si la función objetivo se ejecuta muy rápido, dichos tiempos de gestión del procesamiento paralelo impactarán negativamente en la eficiencia del algoritmo. Para ver la utilidad del procesamiento paralelo vamos a realizar una pequeña modificación en la función objetivo. A continuación, se muestra la nueva función de evaluación. Se puede comprobar que lo único que se ha incluido es un pequeño retraso de 1 ms antes de devolver el resultado. Es decir, ahora la función de evaluación tarda aproximadamente 1 ms en ejecutarse. Para introducir el retraso se ha utilizado la función time.sleep, indicando que el retraso que se quiere añadir es de 0.001 s. Durante ese tiempo el programa se queda en ese punto esperando a que transcurra dicho tiempo (no realiza nada). Si ejecutamos el algoritmo genético de nuevo sin procesamiento paralelo, el resultado es el siguiente: Vemos un incremento significativo con respecto al tiempo de ejecución. Era esperable, ya que hemos añadido un retraso importante en la función objetivo. A continuación, ejecutamos el algoritmo genético utilizando procesamiento paralelo. Vemos que ahora sí hemos mejorado significativamente los resultados. No obstante, aunque el computador que estamos utilizando dispone de cuatro procesadores, el rendimiento no es cuatro veces superior al del caso no paralelizado. Como hemos comentado, paralelizar la ejecución tiene unos costes de gestión que reducen la eficiencia del método. En resumen, antes de lanzarnos al procesamiento paralelo, que puede parecer muy atractivo, hay que considerar si se va a sacar beneficio por ello. Como consejos: 1. Se recomienda medir el tiempo de ejecución de la función objetivo. 2. Si la función de evaluación se ejecuta rápidamente (tiempos de ejecución menores de milisegundos), no se recomienda paralelizar el algoritmo genético. 3. Si la función de evaluación tarda del orden de los milisegundos o más, sí es recomendable paralelizar el algoritmo genético. Por último, cabe indicar que el campo del procesamiento paralelo es muy amplio. Por lo que en este apéndice solo se pretende poner de manifiesto que se puede realizar procesamiento paralelo con deap en Python. B.2 Procesamiento paralelo con el módulo Scoop Otra forma de realizar procesamiento paralelo con deap es utilizar el módulo scoop7. En este caso los cambios que debemos hacer son dos: ■Debemos registrar en la caja de herramienta la función map del submódulo futures de scoop : ■Debemos llamar al script de Python directamente desde el intérprete: Por último, en este caso todo lo referente al algoritmo genético debe estar definido en la función main. Para finalizar este apéndice, nos gustaría indicar algunas referencias que se pueden consultar sobre la paralelización del algoritmos genéticos (Alba y Tomassini, 2002) (Alba, 2005). _________________ 1https://deap.readthedocs.io/en/master/tutorials/basic/part4.html 2https://docs.python.org/3.6/library/multiprocessing.html 3https://docs.python.org/3.6/library/time.html 4Intel Core i7 @ 1.8 GHz 5https://docs.python.org/3.6/library/functions.html#map 6Hay que tener en cuenta que dicho tiempo dependerá del procesador utilizado. 7https://github.com/soravux/scoop/ cxBlend Operador de cruce borroso cxOnePoint Operador de cruce en un punto cxOrdered Operador de cruce ordenado cxPartiallyMatched Operador de cruce parcialmente emparejado cxSimulatedBinary Operador de cruce binario simulado cxTwoPoint Operador de cruce en dos puntos cxUniform Operador de cruce uniforme eaMuPlusLambda Algoritmo genético µ + λ eaSimple Algoritmo genético simple mutFlipBit Operador de mutación mediante inversión de bit mutFlipBitAs Operador de mutación mediante inversión de bit asimétrica mutGausBounded Operador de mutación Gaussiana acotada mutGaussian Operador de mutación Gaussiana mutPolynomialBounded Operador de mutación polinomial con límites mutShuffleIndexes Operador de mutación mediante mezcla de índices mutTriangular Operador de mutación triangular (personalizado) NSGA-II Algoritmo genético NSGA-II selRoulette Operador de selección por ruleta selTournament Operador de selección por torneo Abdoun, O., Abouchabaka, J., y Tajani, C. (2012). Analyzing the performance of mutation operators to solve the travelling salesman problem. CoRR, abs/1203.3099. Alba, E. (2005). Parallel metaheuristics: a new class of algorithms, volume 47. John Wiley & Sons. Alba, E. y Tomassini, M. (2002). Parallelism and evolutionary algorithms. IEEE transactions on evolutionary computation, 6(5):443–462. Alvarado-Barrios, L., del Nozal, Á. R., Valerino, J. B., Vera, I. G., y MartínezRamos, J. L. (2020). Stochastic unit commitment in microgrids: Influence of the load forecasting error and the availability of energy storage. Renewable Energy, 146:2060–2069. Alvarado-Barrios, L., Rodríguez del Nozal, A., Tapia, A., Martínez-Ramos, J. L., y Reina, D. G. (2019). An evolutionary computational approach for the problem of unit commitment and economic dispatch in microgrids under several operation modes. Energies, 12(11). Arzamendia, M., Espartza, I., Reina, D. G., Toral, S. L., y Gregor, D. (2019a). Comparison of eulerian and hamiltonian circuits for evolutionary-based path planning of an autonomous surface vehicle for monitoring ypacarai lake. Journal of Ambient Intelligence and Humanized Computing, 10(4):1495–1507. Arzamendia, M., Gregor, D., Reina, D. G., y Toral, S. L. (2019b). An evolutionary approach to constrained path planning of an autonomous surface vehicle for maximizing the covered area of ypacarai lake. Soft Comput., 23(5):1723–1734. Arzamendia, M., Gregor, D., Reina, D. G., Toral, S. L., y Gregor, R. (2016). Evolutionary path planning of an autonomous surface vehicle for water quality monitoring. In 2016 9th International Conference on Developments in eSystems Engineering (DeSE), pages 245–250. IEEE. Arzamendia, M., Reina, D. G., Toral, S., Gregor, D., Asimakopoulou, E., y Bessis, N. (2019). Intelligent online learning strategy for an autonomous surface vehicle in lake environments using evolutionary computation. IEEE Intelligent Transportation Systems Magazine, 11(4):110–125. Arzamendia, M., Reina, D. G., Toral, S. L., Gregor, D., y Tawfik, H. (2018). Evolutionary computation for solving path planning of an autonomous surface vehicle using eulerian graphs. In 2018 IEEE Congress on Evolutionary Computation (CEC), pages 1–8. IEEE. Bacco, M., Ferro, E., y Gotta, A. (2014). Uavs in wsns for agricultural applications: An analysis of the two-ray radio propagation model. In SENSORS, 2014 IEEE, pages 130–133. IEEE. Bechikh, S., Datta, R., y Gupta, A. (2016). Recent advances in evolutionary multi-objective optimization, volume 20. Springer. Beyer, H. y Schwefel, H. (2002). Evolution strategies–a comprehensive introduction. Natural computing, 1(1):3–52. Camacho, E. F. y Alba, C. B. (2013). Model predictive control. Springer Science & Business Media. Chmielewski, D. J., Palmer, T., y Manousiouthakis, V. (2002). On the theory of optimal sensor placement. AIChE journal, 48(5):1001–1012. Coello, C. A. (2006). Evolutionary multi-objective optimization: a historical view of the field. IEEE computational intelligence magazine, 1(1):28–36. Coello, C. A. et al. (2007). Evolutionary algorithms for solving multi-objective problems, volume 5. Springer. Darwin, C. (1859). On the Origin of Species by Means of Natural Selection Or the Preservation of Favoured Races in the Struggle for Life. H. Milford; Oxford University Press. Deb, K. et al. (1995). Simulated binary crossover for continuous search space. Complex systems, 9(2):115–148. Deb, K. y Jain, H. (2014). An evolutionary many-objective optimization algorithm using reference-point-based nondominated sorting approach, part i: Solving problems with box constraints. IEEE Transactions on Evolutionary Computation, 18(4):577–601. Deb, K., Pratap, A., Agarwal, S., y Meyarivan, T. (2002). A fast and elitist multiobjective genetic algorithm: Nsga-ii. IEEE Transactions on Evolutionary Computation, 6(2):182–197. Deshmukh, M. y Deshmukh, S. (2008). Modeling of hybrid renewable energy systems. Renewable and sustainable energy reviews, 12(1):235–249. Edmonds, J. y Johnson, E. L. (1973). Matching, euler tours and the chinese postman. Math. Program., 5(1):88–124. Eldeeb, H., Arafa, M., y Saidahmed, M. T. F. (2017). Optimal placement of access points for indoor positioning using a genetic algorithm. In 2017 12th International Conference on Computer Engineering and Systems (ICCES), pages 306–313. IEEE. Eshelman, L. J. y Schaffer, J. D. (1993). Real-coded genetic algorithms and interval-schemata. In Foundations of genetic algorithms, volume 2, pages 187– 202. Elsevier. Fortin, F. et al. (2012). Deap: Evolutionary algorithms made easy. Journal of Machine Learning Research, 13(70):2171–2175. Fortnow, L. (2009). The status of the p versus np problem. Communications of the ACM, 52(9):78–86. Ghosh, A. y Das, S. K. (2008). Coverage and connectivity issues in wireless sensor networks: A survey. Pervasive and Mobile Computing, 4(3):303–334. Goldberg, D. E. (2006). Genetic algorithms. Pearson Education India. Goldberg, D. E. y Deb, K. (1991). A comparative analysis of selection schemes used in genetic algorithms. In Foundations of genetic algorithms, volume 1, pages 69–93. Elsevier. Goldberg, D. E. et al. (1985). Alleles, loci, and the traveling salesman problem. In Proceedings of an international conference on genetic algorithms and their applications, volume 154, pages 154–159. Lawrence Erlbaum, Hillsdale, NJ. Goldberg, D. E. et al. (1989). Messy genetic algorithms: Motivation, analysis, and first results. Complex systems, 3(5):493–530. Herrera, F., Lozano, M., y Sánchez, A. M. (2003). A taxonomy for the crossover operator for real-coded genetic algorithms: An experimental study. International Journal of Intelligent Systems, 18(3):309–338. Holland, J. (1965). Some practical aspects of adaptive systems theory,". Electronic Information Handling, pages 209–217. Holland, J. H. (1962). Outline for a logical theory of adaptive systems. J. ACM, 9:297–314. Holland, J. H. et al. (1992). Adaptation in natural and artificial systems: an introductory analysis with applications to biology, control, and artificial intelligence. MIT press. Ishibuchi, H., Akedo, N., y Nojima, Y. (2014). Behavior of multiobjective evolutionary algorithms on many-objective knapsack problems. IEEE Transactions on Evolutionary Computation, 19(2):264–283. Ishibuchi, H., Tsukamoto, N., y Nojima, Y. (2008). Evolutionary many-objective optimization: A short review. In 2008 IEEE Congress on Evolutionary Computation (IEEE World Congress on Computational Intelligence), pages 2419–2426. IEEE. Kennedy, J. y Eberhart, R. (1995). Particle swarm optimization. In Proceedings of ICNN’95-International Conference on Neural Networks, volume 4, pages 1942–1948. IEEE. Khan, A. A. et al. (2016). A compendium of optimization objectives, constraints, tools and algorithms for energy management in microgrids. Renewable and Sustainable Energy Reviews, 58:1664–1683. Kim, K., Murray, A. T., y Xiao, N. (2008). A multiobjective evolutionary algorithm for surveillance sensor placement. Environment and Planning B: Planning and Design, 35(5):935–948. Koza, J. R. (1992). Genetic Programming: On the Programming of Computers by Means of Natural Selection. MIT Press, Cambridge, MA, USA. Lasnier, F. y Ang, T. (1990). Photovoltaic engineering handarticle. Li, B. et al. (2015a). Many-objective evolutionary algorithms: A survey. ACM Computing Surveys (CSUR), 48(1):1–35. Li, H. et al. (2015b). A genetic algorithm-based hybrid optimization approach for microgrid energy management. In 2015 IEEE International Conference on Cyber Technology in Automation, Control, and Intelligent Systems (CYBER), pages 1474–1478. IEEE. Likas, A., Vlassis, N., y Verbeek, J. J. (2003). The global k-means clustering algorithm. Pattern recognition, 36(2):451–461. Lones, M. (2011). Sean luke: essentials of metaheuristics. Nemati, M., Braun, M., y Tenbohlen, S. (2018). Optimization of unit commitment and economic dispatch in microgrids based on genetic algorithm and mixed integer linear programming. Applied energy, 210:944–963. Oda, T. et al. (2013). Wmn–ga: a simulation system for wmns and its evaluation considering selection operators. Journal of Ambient Intelligence and Humanized Computing, 4(3):323–330. Rajabioun, R. (2011). Cuckoo optimization algorithm. Applied soft computing, 11(8):5508–5518. Reina, D. G., Camp, T., Munjal, A., y Toral, S. L. (2018a). Evolutionary deployment and local search-based movements of 0th responders in disaster scenarios. Future Generation Computer Systems, 88:61–78. Reina, D. G. et al. (2016a). A survey on the application of evolutionary algorithms for mobile multihop ad hoc network optimization problems. International Journal of Distributed Sensor Networks, 12(2):2082496. Reina, D. G., Marin, S. L. T., Bessis, N., Barrero, F., y Asimakopoulou, E. (2013). An evolutionary computation approach for optimizing connectivity in disaster response scenarios. Applied Soft Computing, 13(2):833–845. Reina, D. G., Tawfik, H., y Toral, S. L. (2018b). Multi-subpopulation evolutionary algorithms for coverage deployment of uav-networks. Ad Hoc Networks, 68:16–32. Reina, D. G., Toral, S. L., Johnson, P., y Barrero, F. (2012). An evolutionary computation approach for designing mobile ad hoc networks. Expert systems with applications, 39(8):6838–6845. Reina, D. G., Toral, S. L., y Tawfik, H. (2016b). Uavs deployment in disaster scenarios based on global and local search optimization algorithms. In 2016 9th International Conference on Developments in eSystems Engineering (DeSE), pages 197–202. IEEE. Rodríguez del Nozal, A., Reina, D. G., Alvarado-Barrios, L., Tapia, A., y Escaño, J. M. (2019). A mpc strategy for the optimal management of microgrids based on evolutionary optimization. Electronics, 8(11):1371. Rodríguez del Nozal, A., Tapia, A., Alvarado-Barrios, L., y Reina, D. G. (2020). Application of genetic algorithms for unit commitment and economic dispatch problems in microgrids. In Nature Inspired Computing for Data Science, pages 139–167. Springer. Ser, J. D. et al. (2019). Bio-inspired computation: Where we stand and what’s next. Swarm and Evolutionary Computation, 48:220 – 250. Smith, J. (2012). Book title, volume 3 of 2. Publisher, City, 1 edition. Tapia, A. (2019). Optimization strategies of micro-hydro power systems to supply remote isolated communities. PhD thesis, Universidad Loyola Andalucía. Tapia, A., Millán, P., y Gómez-Estern, F. (2018). Integer programming to optimize micro-hydro power plants for generic river profiles. Renewable Energy, 126:905–914. Tapia, A., Reina, D. G., del Nozal, A. R., y Millán, P. (2020a). Application of genetic algorithms for designing micro-hydro power plants in rural isolated areas —a case study in san miguelito, honduras. In Nature Inspired Computing for Data Science, pages 169–200. Springer. Tapia, A., Reina, D. G., y Millán, P. (2019). An evolutionary computational approach for designing micro hydro power plants. Energies, 12(5):878. Tapia, A., Reina, D. G., y Millán, P. (2020b). Optimized micro-hydro power plants layout design using messy genetic algorithms. Expert Systems with Applications, page 113539. Ter-Sarkisov, A. y Marsland, S. (2011). Convergence properties of two (µ+λ) evolutionary algorithms on onemax and royal roads test functions. arXiv preprint arXiv:1108.4080. Thake, J. (2000). The micro-hydro pelton turbine manual: Design, manufacture and installation for small-scale hydropower. Technical report, ITDG publishing. Tsai, Y. (2008). Sensing coverage for randomly distributed wireless sensor networks in shadowed environments. IEEE Transactions on Vehicular Technology, 57(1):556–564. Van Soest, A. K. y Casius, L. R. (2003). The merits of a parallel genetic algorithm in solving hard optimization problems. J. Biomech. Eng., 125(1):141– 146. Van Veldhuizen, D. A. y Lamont, G. B. (1998). Evolutionary computation and convergence to a pareto front. In Late breaking papers at the genetic programming 1998 conference, pages 221–228. Citeseer. Črepinšek, M., Liu, S. H., y Mernik, M. (2013). Exploration and exploitation in evolutionary algorithms: A survey. ACM Comput. Surv., 45(3). Wang, B. (2011). Coverage problems in sensor networks: A survey. ACM Computing Surveys (CSUR), 43(4):1–53. Wang, R. (2004). A genetic algorithm for subset sum problem. Neurocomputing, 57:463–468. Yang, X. et al. (2008). Firefly algorithm. Nature-inspired metaheuristic algorithms, 20:79–90. Zames, G. et al. (1981). Genetic algorithms in search, optimization and machine learning. Information Technology Journal, 3(1):301–302.