Librería de Funciones abstractas para la construcción de Algoritmos Genéticos con Programación Funcional Jorge Mario García Usuga, Cesar Augusto Acosta Minoli, Efraín Alberto Hoyos Resumen Este artículo tiene por objeto, presentar el potencial que ofrece la programación funcional como alternativa para el diseño e implementación de algoritmos genéticos (A.G.), aprovechando las bondades de este paradigma de programación. Se logró mostrar que es posible implementar una librería de funciones abstractas para la construcción de algoritmos genéticos, generando un código claro, simple, corto y evitando el uso de arreglos e índices en comparación con los lenguajes imperativos. El estudio realizado con una serie de problemas clásicos en la literatura de A.G. mostró el buen desempeño de la librería, como también su utilidad con fines académicos, experimentales y educativos en la búsqueda de soluciones a problemas complejos. Palabras Claves: Programación Funcional, Algoritmos Genéticos, Lenguajes funcionales. Diseño e Implementación, Abstract The goal of this paper is to show the functional programming potential as a good choice to design and implementing Genetic Algorithms, with all the features and advantages that this paradigm can offer. We find that functional languages can be useful to develop an Abstract Functions Library to help programmers to solve problems with Genetic Algorithms in a easy way due to its syntactic similarity to mathematics. Good results are obtained when we use the library to solve some benchmark problems. The library in this proposal can be used for academic, educational and experimental purposes as well. Keywords: Functional programming, Genetic Algorithms, design and implementation, functional language. Introducción Desde sus comienzos, la computación blanda ha tomado el camino de la programación imperativa para la implementación de sus algoritmos y métodos de cálculo en la búsqueda de soluciones a problemas complejos. Sin embargo, la programación funcional presenta una nueva perspectiva en el campo de la computación blanda y específicamente en los Algoritmos Genéticos (A.G.) Los A.G. se han convertido en una poderosa herramienta para la búsqueda y optimización de soluciones en problemas de tipo NP1, sobretodo en el área de la planeación de redes eléctricas y de Grupo GEDES universidad del Quindío. Maestría en Enseñanza de la Matemática, Línea de Profundización en Matemática Discreta, Universidad Tecnológica de Pereira. 2 presupuestos. Precisamente una de las características más importantes de los A.G es la posibilidad de encontrar no sólo una solución, sino un conjunto de buenas soluciones aproximadas y completamente diferentes. Muchos de los intentos de llevar programas hechos en lenguajes imperativos a lenguajes funcionales como Haskell, Mozart-oz o Lisp han fracasado, debido a que los programadores recaen en el error de tratar de implementar índices y arreglos propios del modelo imperativo, en un sistema completamente diferente como lo es, el modelo funcional. El manejo de las listas en Haskell ofrece una alternativa para superar este tipo de inconvenientes. El presente artículo tiene como fin mostrar que es posible construir una librería de funciones abstractas para la construcción de algoritmos genéticos en Haskell sin recurrir al uso de arreglos e índices. El objetivo es aprovechar las potencialidades que ofrece la programación funcional como el manejo de listas, llamados recursivos, evaluación perezosa y funciones de alto orden para el desarrollo de software con un código corto, seguro, de fácil comprensión y mantenimiento. En la primera sección se pretende refinar los conceptos necesarios para comprender la filosofía de la programación funcional. En la sección siguiente, se presenta una discusión sobre algoritmos genéticos, sus operadores y la manera como se implementan funciones abstractas para sus operadores en Haskell, con el fin de facilitar la construcción para problemas particulares. Finalmente, se hace un análisis del desempeño de la librería, a través de un estudio con una serie de problemas de la literatura en A.G., teniendo en cuenta aspectos como la población, la taza de mutación y el método de cruzamiento entre otros, mostrando de esta forma, las posibilidades que la programación funcional ofrece en el diseño e implementación de algoritmos en general. Haskell: un lenguaje de programación funcional La gran mayoría de los lenguajes de programación existentes en la actualidad, se pueden clasificar en dos grandes ramas según la filosofía que los acoge: Los lenguajes imperativos, entre estos C, Pascal y Fortran y los lenguajes declarativos como Prolog, Oz-Mozart y Lisp. Se dice que un lenguaje es imperativo si consiste de una secuencia de comandos e instrucciones de asignación las cuales son ejecutadas de manera estricta, una después de otra. Por su parte, el modelo declarativo se interesa más por el qué debe ser computado, en vez del cómo debe ser computado; esta filosofía 1 Clase de Problemas no solubles en tiempo polinomial. 3 cambia las secuencias de comandos e instrucciones por declaraciones y definiciones dejando que la máquina se ocupe del resto; por este motivo los lenguajes declarativos son considerados como lenguajes de alto nivel. Haskell es un lenguaje funcional puro ya que su única forma de calcular la realiza a través de la evaluación de funciones; la computación la realiza a través de definiciones en vez de hacerlo por asignaciones como en los lenguajes imperativos. No existen estructuras cíclicas, como por ejemplo, el for y el do-while de C y no existen las variables en el sentido de mutabilidad, ni asignaciones destructivas, por ejemplo en Haskell es imposible hacer “x:=x+1”, como usualmente alguien lo haría en Pascal. Según Richard Bird (BIRD,2000), para comprender esta visión debemos tener en cuenta que: “La programación desde una perspectiva funcional, consiste en construir definiciones y en usar el computador para evaluar expresiones, el papel del programador consiste en definir una función que permita resolver un problema dado". A continuación se muestran algunas características que hacen de este un lenguaje moderno y atractivo para ser objeto de estudio: Polimorfismo de Tipos: Haskell se caracteriza por ser estaticamente tipeado, es decir, los tipos de las variables y las funciones son detectados en tiempo de compilación. El tipeado estático permite detectar muchos errores en tiempo de compilación y no en tiempo de ejecución, como suele suceder en algunos lenguajes. Además, el sistema de tipos de Haskell posee una propiedad muy importante conocida como polimorfismo. Para entender y apreciar su poder en detalle observe en la figura 1 la función long definida en Haskell: long :: [a]->Int long [] = 0 long (x:xs) = 1 + long xs Figura 1. Función que calcula la longitud de una lista La función long calcula la longitud de una lista. La primera línea indica el nombre de la función (long), en la parte derecha de (::) se observa el tipo de la función ([a]->Int). El polimorfismo radica 4 en que sin importar el tipo de la lista, la función siempre retornará un entero. Por ejemplo al evaluar cualquiera de las siguientes tres expresiones, se obtiene como respuesta 3: long [1,5,6] long [[2,3],[5,8],[9,7]] long [‘a’,’b’,’z’] En estos casos los tipos de las expresiones de entrada son respectivamente [Int], [[Int]] y [Char]. Este hecho es muy interesante ya que las funciones se pueden diseñar de forma general y, por lo tanto, pueden ser re-usadas con gran facilidad. Funciones de alto nivel: Dentro de la programación funcional algunos autores consideran que las “funciones son ciudadanos de primera clase”(BIRD,2000). Esta frase tiene un gran significado en Haskell,, ya que las funciones en sí mismas son valores, que pueden ser almacenados, pasados como argumentos a otras funciones y, también, retornan como resultado de una función. Una función que recibe funciones como argumento se denomina de alto nivel. Para ver esta propiedad se puede considerar un ejemplo clásico de la literatura en programación funcional: map :: (a ->b) -> [a] ->[b] map f [] = [] map f (x:xs) = f x: map f xs Figura 2. Función map. La función map, que se describe en la figura 2, recibe como argumentos una función de la forma (ab), una lista de tipo a ([a]) y retorna una lista de tipo b ([b]), la función map aplica una función a cada uno de los elementos de una lista. A continuación se presentan algunos ejemplos del uso de la función map: map (+3) [1,2,3] = [4,5,6] map (*3) [1,2,3] = [3,6,9] map (head) [[3,4,5],[2,5],[6,8,9]] = [3,2,6] Las funciones de alto nivel son un poderoso mecanismo de abstracción que puede mejorar sustancialmente la estructura y modularidad de muchos programas. Ajuste de Patrones y definiciones recursivas: El Ajuste de Patrones y las definiciones son otra característica fundamental dentro de los lenguajes funcionales; además, sirven como esquema de pensamiento en el momento de definir funciones. Por ejemplo, las definiciones de la función take (ver figura 3) son: 5 take take 0 :: Int -> [a] -> [a] xs = [] take (n+1) [] = [] take (n+1) (x:xs) = x: take n xs Figura 3. Función que toma los n primeros elementos de una lista. La función take toma como argumento un número entero n y una lista, y retorna los n primeros elementos de la lista en un proceso recursivo a través de sus definiciones. Nótese que las definiciones de la función take cubren todos los posibles casos, tanto para los números naturales como para la lista de entrada. En este sentido, para realizar la evaluación de la función, Haskell ajusta los valores de entrada (en este caso el número entero y la lista) a una de las definiciones de la función; si los valores no se ajustan a ninguna de ellas, se produce un error. Dado que hay dos argumentos sobre los que se lleva el ajuste de patrones, es necesario saber en qué orden se realiza dicho ajuste. Este se lleva a cabo de izquierda a derecha dentro de cada definición y de arriba hacia abajo en el conjunto de definiciones. Finalmente, observe en la tercera definición que cuando el número es diferente de cero y la lista es no vacía, esta hace un llamado recursivo de sí misma. En caso de que el número sea cero, el ajuste de patrones lo lleva a la primera definición y el proceso termina. De acuerdo con lo anterior, Haskell ofrece una serie de ventajas interesantes en el momento de desarrollar software: Está abierto hacia la enseñanza, la investigación y el desarrollo de aplicaciones, incluyendo la construcción de sistemas robustos. Es de dominio público, cualquier persona puede usarlo libremente. Presenta pocos errores en tiempo de ejecución. Permite re-usar el código gracias a la generalidad que ofrece el polimorfismo. Por lo tanto, es posible adaptarlo con facilidad a una gran cantidad de aplicaciones. Su mecanismo de evaluación perezosa y las funciones de alto orden permiten construir programas más modulares, donde cada parte (función) tiene su propio significado y es independiente del programa. Sin embargo, es imposible dar todos los créditos a Haskell pues existen algunas desventajas: 6 Ofrece al programador menos control sobre la máquina (programación de alto nivel), esto puede traer ciertos inconvenientes cuando se quiere diseñar software donde el máximo desempeño se requiere a cualquier costo (programación de bajo nivel). El recolector de basura a pesar de librar al programador del manejo de memoria tiene un costo en tiempo de ejecución, que puede repercutir en la eficiencia del programa. A pesar de todo, los autores e investigadores de Haskell (HUGHES ,1990) argumentan que el sacrificio es mínimo comparado con las ventajas del lenguaje. Los beneficios de un modelo de programación que ofrezca más soporte valen más que los modestos costos de ejecución. Algoritmos Genéticos y su implementación: Una de las tendencias mundiales para resolver problemas de optimización consiste en desarrollar técnicas basadas en la naturaleza; Ejemplo de ello son: las redes neuronales, Ant-Q y los algoritmos genéticos, entre otros. Estas técnicas utilizan métodos heurísticos no analíticos y se usan en una serie de problemas que no tienen una forma directa de resolverse o en los cuales no es necesario obtener la mejor solución sino una serie de soluciones muy próximas a esta. Los Algoritmos Genéticos son una de las técnicas más utilizadas hoy en día para tratar problemas de optimización. Según David E. Goldberg “Los Algoritmos Genéticos son algoritmos de búsqueda basados en los mecanismos de selección natural y genética natural. Combinan la supervivencia de los más compatibles entre las estructuras de cadenas, con una estructura de información ya aleatorizada, intercambiada para construir un algoritmo de búsqueda con algunas de las capacidades de innovación de la búsqueda humana”. Desde un punto de vista biológico los Algoritmos Genéticos (A.G) se basan en el modelo del Neodarwinismo, el cual plantea que los organismos de una población conviven de manera armoniosa con su medio ambiente, pero que al presentarse un problema, una situación en la cual se ven afectados de alguna manera; bien sea por un depredador, una enfermedad o un cambio climático, estos evolucionan para adaptarse a la nueva situación. Esta evolución se logra, por o ejemplo, a través de mutaciones, es decir, cambios inesperados en su estructura corporal, (piernas más largas, resistencia a una enfermedad entre otros). Esta adaptación, da como resultado la solución al problema inicial. Los individuos adaptados tendrán más posibilidades de sobrevivir que sus compañeros para las nuevas condiciones del medio. 7 Los individuos que gracias a un proceso de mutación cambian su estructura genética para adaptarse mejor a las nuevas condiciones del medio tienen la ventaja de pasar su nueva información genética a sus descendientes por medio de un proceso de cruzamiento. Este proceso permite a las especies pasar de una generación a otra, las características especiales que les permitieron sobrevivir a la situación o problema que se planteó inicialmente. Esta estrategia de la naturaleza fue modelada en un sistema computacional por John Holland entre los años 60 y 70, quien gracias a un grupo de estudiantes logró resolver algunos problemas utilizando esta nueva técnica de optimización (conocida, desde entonces, como Algoritmos Genéticos (A.G)). Veamos ahora como se puede implementar un A.G y qué detalles debemos tener en cuenta para su desarrollo. Con el Algoritmo Genético debe hacer lo siguiente: Representar adecuadamente una configuración y codificación del problema. Con frecuencia se utiliza la codificación binaria donde son fácilmente simulados los operadores genéticos de cruzamiento y mutación. Encontrar una forma adecuada para evaluar la función-objetivo o su equivalente (fitness). Así, se pueden identificar las configuraciones de mejor calidad (fitness value). Escoger una estrategia de selección de configuraciones con derecho a participar en la formación (construcción) de las configuraciones de la nueva población (nueva generación). Escoger un mecanismo que permita implementar el operador genético de cruzamiento (Crossover). Construir un mecanismo que permita implementar el operador genético de mutación. Especificar el tamaño de la población o sea el número de configuraciones en cada generación. La población y las listas como su estructura de datos La población es el conjunto de soluciones (factibles o no) del problema. Dicho conjunto, a través de un proceso de selección y con la ayuda de operadores genéticos como el de cruzamiento y mutación, se convertirán en una nueva población con mejores soluciones que su antecesora 8 mediante un proceso iterativo. A la población que aparece en cada iteración también se le denomina generación. En el momento de representar a la población y a cada uno de sus individuos haciendo uso de la programación funcional, es necesario construir una estructura de datos que haga uso de listas en vez de arreglos e índices por los siguientes motivos: Las listas son la estructura lineal más importante de Haskell, además cuenta con un gran número de funciones y operaciones para utilizarlas. La representación de una matriz indexada en Haskell es poco eficiente, ya que los valores al ser atrapados en el constructor de datos Array, son de difícil acceso y esto tiene un costo computacional. Adicionalmente, si se observa con detalle la forma en la cual se almacena la información y se operan los individuos con A.G, se hace evidente que ésta consiste en la manipulación de un conjunto de listas a las que llamamos la población, además los operadores genéticos de cruzamiento, mutación, y el mecanismo de selección, realizan todo su proceso en el hecho de manipular listas. Acciones como mutar una posición o intercambiar información entre las soluciones es simplemente intercambiar bits de información entre dos o más listas. Una lista se puede construir como un tipo de dato recursivo mediante la siguiente expresión: data Lista a=Nil | Cons a (Lista a) Esta declaración se lee: “Una lista de tipo a es Nil (Vacia) o una constante de tipo a unido con una lista de tipo a”. En Haskell el tipo polimórfico de lista se denota por [a] y la lista vacía se denota como [ ]. Algunos ejemplos del Listas son los siguientes: [1,2,3] :: [Int] [‘a’,’b’,’c’] :: [Char] [[1,2],[3,4]] :: [[Int]] Haskell ofrece un operador infijo (:) para la construcción de listas, que se encarga de unir (al lado izquierdo) un elemento a la lista (cabeza:cola), por ejemplo la lista [1,2,3] puede ser escrita como: [1,2,3]=1:(2:(3:[ ]))=1:2:3:[ ] 9 A continuación se aprecia como es posible implementar funciones para la creación de poblaciones iniciales en Haskell, las cuales son generadas de manera aleatoria. Como ejemplo observe las figuras 4 y 5: Primero se genera algunos individuos aleatorios con la siguiente función: generaIndividuoBin :: Int ->Int->[Int] generaIndividuoBin n l = take n (aleatoriosB l) Figura 4. Función que genera un individuo de tamaño n. Como se puede observar esta función toma un parámetro llamado semilla (un número que se utiliza para generar números pseudoaleatorios) y lo convierte en una lista de números binarios aleatorios de tamaño l. Recuerde que cada individuo de nuestro algoritmo se considerará como una lista de números. Ahora observe en la figura 5 cómo crear una población con n individuos: generaPoblacionBin :: Int ->Int->Int->[[Int]] generaPoblacionBin 0 _ _ = [ ] generaPoblacionBin n l p = (generaIndividuoBin l (p-1)): (generaPoblacionBin (n-1) (l) (p+1) ) Figura 5. Función que genera una población de n elementos y usa la función de la figura 4 Como se aprecia en la figura anterior, la función generaPoblacionBin toma la función que genera a un individuo y la llama indefinidamente, provocando que Haskell genere una lista infinita de sublistas, las cuales van a hacer los individuos de la población. El lenguaje funcional puede tratar fácilmente con el problema del infinito potencial gracias a su método de evaluación perezosa o no voraz, el cual sólo evalúa las partes necesarias de una expresión para obtener la respuesta, hay partes de la expresión que no son evaluadas del todo. La función objetivo La función objetivo es la encargada de evaluar a los individuos de la población y definir la calidad de cada uno de ellos, para determinar la cantidad de descendientes de cada solución. El A.G toma el nivel de desempeño de la solución en particular y determina si es muy buena o regular. En caso de ser una buena solución, el algoritmo le asigna un número determinado de oportunidades para tener descendientes. Ahora bien, en el caso de ser regular, el algoritmo le asigna un bajo número de oportunidades. 10 Sin embargo, la función objetivo es particular en cada problema debido a la naturaleza y metodología propia de cada situación. Por esta razón no se ha incluido la función objetivo dentro de la librería de A.G, y debe ser implementada independientemente de la misma. La selección Una vez calificados todos los individuos de una generación, el algoritmo debe seleccionar a los individuos más calificados, mejor adaptados al problema, para que tengan mayor oportunidad de reproducción. De esta forma se incrementa la probabilidad de tener individuos con alta calificación en el futuro. Se puede decir, entonces, que la selección es el operador genético que permite seleccionar las configuraciones de la población actual con derecho a participar en la generación de la nueva población (nueva generación). Los procesos de selección han sufrido una serie de cambios desde que fueron concebidos. Estos cambios implementan, ahora, una serie de estrategias que hacen que las nuevas poblaciones adquieran una mejor diversidad y eviten la homogenización de la misma. En la figura 7, se aprecia una forma de implementar la selección por torneo, que es un sistema muy simple y fácil de adecuar a todo tipo de problema. La selección por torneo consiste en tomar dos elementos de la población de forma aleatoria y compara sus desempeños en la función objetivo para determinar cuál es el mejor de los dos. El ganador tendrá derecho a una oportunidad de tener un descendiente. Antes de implementar el algoritmo se debe tomar de la población los dos individuos que van a competir. Para tal propósito se implementa la función sacardos: sacardos ::[[a]] ->Int->Int ->([a],[a]) sacardos x s r = (a,b) where a=x !! s b=x !! r Figura 6. Función que toma dos elementos de una lista y retorna una pareja ordenada con ellos. Como se observa en la figura 6, la función toma de la población dos individuos, los cuales son escogidos a través de los parámetros s y r, y retorna una pareja ordenada con los dos individuos seleccionados. Posteriormente se deben comparar estos dos individuos con respecto a su función de 11 evaluación y aquel que obtenga el mejor rendimiento será el escogido para hacer parte de la nueva etapa. La función de la figura 7 es la encargada de obtener las dos configuraciones y escoger al mejor individuo: torneoIn ::(Fractional a, Ord a)=> ([a]->a)->[[a]]->Int ->[a] torneoIn f (x:xs) s = if (f a) > (f b) then a else b where i= unaletorio ((length (x:xs))-1) (s+1) j= unaletorio ((length (x:xs))-1) (s+2) a= fst (sacardos (x:xs) i j) b= snd (sacardos (x:xs) i j) Figura 7. Función que selecciona el mejor individuo Esta función toma como parámetros la función objetivo llamada f, la lista x, y una semilla s. Toma dos elementos de la población de forma aleatoria y los compara a través de la función f. Finalmente devuelve al mejor evaluado. Observe que torneoIn es una función de alto nivel ya que recibe la función objetivo como argumento sin importar el tipo de problema que se esté resolviendo con A.G. Este proceso se repite hasta alcanzar el límite de población, por esta razón debemos crear una función que tome a torneoIn y la aplique el número de veces que sea necesario para llenar el tope máximo de población. La siguiente función realiza el proceso de selección de torneo a una población y tomando como parámetros la función objetivo, la población y un valor entero como semilla: torneo ::(Fractional a, Ord a) =>([a]->a)->[[a]]->Int->[[a]] torneo f [] s = [] torneo f x s =(torneoIn f x (s+2)):(torneo f x (s+1)) Figura 8. Función que genera una población de n elementos y usa la función de la figura 4 La función torneo devuelve una lista infinita con la nueva población ya seleccionada. En la implementación de este algoritmo, en una aplicación real, se debe especificar con la función take la 12 cantidad de individuos que se quieren escoger de dicha población. Otros ejemplos de algoritmos de selección son el de la ruleta y el de truncación. El cruzamiento El operador genético de cruzamiento transmite la información genética de una solución padre a sus descendientes. Para realizar este proceso, primero se toman dos configuraciones o individuos de la población, luego se selecciona un lugar aleatorio en la configuración interna de los dos individuos escogidos. Posteriormente, las secciones que se encuentran después de dicha posición son intercambiadas del primer padre al segundo, produciendo así, dos nuevos descendientes: Figura 9. Cruzamiento Estos dos nuevos descendientes son parte de la nueva generación, este tipo de cruzamiento se conoce como “cruzamiento a un punto” debido a que solo se escoge un punto aleatorio dentro de la configuración para realizar el intercambio. La figura 10 muestra como se puede implementar este algoritmo de manera funcional: cruzaUnPunto ::[a]->[a]->Int ->([a],[a]) cruzaUnPunto x y n = (p,m) where m=(fst c)++(snd d) p=(fst d)++ (snd c) d=splitAt n x c=splitAt n y Figura 10. Cruzamiento En esta función se puede apreciar la simplicidad de este operador genético en Haskell. La función splitAt, propia del lenguaje, se encarga de partir la lista que representa a los padres y la función cruzaUnPunto, también propia del lenguaje, toma como parámetros dos configuraciones padres y devuelve los dos hijos en forma de una pareja ordenada. 13 Este tipo de estrategia para realizar el cruzamiento entre soluciones no es la única, existen otros métodos como la cruza a dos puntos y multipunto. Dichas variaciones se encuentran implementadas en la librería. La mutación La mutación es el operador genético que se encarga de crear nuevas características a la población, teniendo en cuenta que dichas características no siempre son beneficiosas para obtener mejores soluciones. Este proceso se realiza tomando un individuo de manera aleatoria en la población, y luego eligiendo un componente del mismo para cambiarlo por otro valor que también se escoge de manera aleatoria: Figura 11. Mutación en un punto Al igual que con el cruzamiento, la implementación de la función de mutación es sencilla: mutacionESP ::(Fractional a)=> [a] -> a ->Int-> [a] mutacionESP [] _ _ =[] mutacionESP x r i = w++[r]++c where w=fst (splitAt (i-1) x) c=tail (snd (splitAt (i-1) x)) Figura 12. Función que muta genéticamente a un individuo. De manera similar a la función de la figura 10, se usa la función splitAt para cortar la lista en sublistas, se modifica la lista donde se debe realizar el cambio y posteriormente se unen en una nueva lista. La función devuelve el individuo escogido con la mutación ya realizada. Existen otros esquemas de mutación pero éstos se encuentran más explícitos en la librería. La incumbente La incumbente se define como la mejor solución de una generación. En la primera generación se escoge por primera vez la solución incumbente, en la segunda generación se vuelve a buscar la mejor solución y se compara con la incumbente de la primera generación, la que tenga mayor 14 desempeño se llamará la incumbente global o general. Este proceso se repite durante todo el ciclo generacional. Dicha incumbente global es el resultado final del algoritmo, es la solución que mejor desempeño tendrá frente al problema en todas las generaciones. Observe en la figura 13 cómo buscar la incumbente de manera funcional: bI bI bI ::(Fractional a, Ord a)=>[[a]]->([a]->a)->[a] [] _ = [] (x:xs) f = buscaIncumbente xs x where buscaIncumbente [] m=m buscaIncumbente (x:xs) m =if a >= b then buscaIncumbente xs m else buscaIncumbente xs x where a= f m b= f x Figura 13. Función busca la solución incumbente en una población. La función bI recibe como argumentos una lista de listas que representa a la población y una función que representa a la función objetivo. Finalmente, la función retorna como resultado al individuo con mejor desempeño. La metodología es muy simple, lo que hace la función es denotar la primera solución de población como la incumbente y compararla, posteriormente, con el segundo componente de la población para determinar cuál es el mejor, y por lo tanto la incumbente. Este proceso se repite de manera recursiva mediante la sub-función buscaIncumbente hasta evaluar todos los individuos de la población. Cabe resaltar que el proceso general se repite en cada generación y, por consiguiente, si se usan n ciclos generacionales, se obtendrán n incumbentes. Al finalizar los ciclos se escoge la mejor de las mejores configuraciones encontradas y ésta será la incumbente general. Desempeño de la librería Para estudiar el desempeño de la librería de funciones abstractas se utilizaron varios problemas de referencia que aparecen en la literatura de Algoritmos Genéticos. Estos se encuentran clasificados según su naturaleza y el tamaño de la información que procesan. Todas las pruebas se realizaron en un PC con procesador AMD-ATHLON de 850 Mhz con 256 Mb de memoria, con Windows XP como sistema operativo. El compilador para la librería en Haskell fue GHC compiler versión 5.04.3 para Windows y los tiempos fueron capturados mediante la función getClockTime, una función predefinida de Haskell. Para la comparación con un lenguaje imperativo como Pascal se usó Delphi, 15 versión 6. A continuación se describe el problema de la mochila, pero en el estudio completo se analizó el desempeño con el problema del agente viajante y el problema de las monedas entre otros. El problema de la mochila (Knapsack Problem) Ahora se implementará una aplicación con la librería de A.G. Se trata del problema de la mochila o Knapsack Problem. Para comprender el problema, suponga que ha ganado un concurso en un supermercado. El premio consiste en llenar una bolsa o mochila que tiene un volumen específico con la mayor cantidad de productos posibles de los enumerados en una lista. Ahora bien, si se desea llevar un buen premio a casa, se debe escoger con mucho cuidado lo que se va a meter en la bolsa. El concurso tiene una restricción; sólo se puede meter en la bolsa un objeto de cada producto y la bolsa o mochila tiene un volumen limitado, el cual obviamente no se puede sobrepasar, por ejemplo: uno de los productos puede ser una caja grande de servilletas, este producto ocupará mucho espacio y es relativamente barato con relación a otros productos, así que se debe buscar productos que sean pequeños pero costosos, o grandes y costosos que no sobrepasen el volumen de la bolsa; por ejemplo: si escogemos un perfume; este producto es muy pequeño y costoso, se puede llenar la bolsa con otros productos que sean del mismo tipo que el perfume. Al final, la idea es sacar una bolsa lo más costosa posible, pero que no sobrepase el volumen especificado. Planteamiento del problema Suponga que se va a resolver el problema de la mochila sin usar A.G. Para este propósito se deben considerar todas las posibles soluciones e identificar cuál de ellas es la mejor. Ahora, el problema es saber cuántas posibles soluciones existen. Como nuestro problema es para n=20, es decir, cada configuración tiene 20 casillas de 1s y 0s, por tanto: Si T es el tamaño total de la muestra y n=20, entonces T = 220= 1'048.576 posibles soluciones. Si se considera que el problema es relativamente pequeño, se tendrán una gran cantidad de soluciones para evaluar, si cambiáramos levemente el tamaño del problema para n=30 entonces: T es el tamaño total de la muestra y n=30, entonces T = 230= 1073'741.824 posibles soluciones. De manera General se tiene que para n variables el tamaño T estaría dado por T=2n Como se puede observar, resolver el problema considerando el espacio total de soluciones es una tarea ineficiente. Los A.G permiten explorar una parte de población, sin considerar el espacio total, lo que resulta una tarea eficiente y rápida, además de ofrecer un abanico de soluciones con diferentes configuraciones. 16 Si se reconsidera resolver el problema con A.G, usando n=20, es decir 20 casillas por individuo, una población de 50 individuos por ciclo y 25 ciclos generacionales, el A.G sólo exploraría 1250 elementos de la población, lo que indica que sólo exploraremos el 0.1 % de la población total. Ahora, si la población se incrementa en 200 individuos por ciclo y 30 ciclos generacionales, entonces se estará explorando 6000 individuos, lo que implica explorar sólo el 0.5% de la población total. Como ejemplo se considera los siguientes vectores de costo y volumen: Vector de costo: [10,25,36,21,2,8,70,50,60,32,58,45,23,1,1,21,23,47,6,20] Vector de volumen: [5,1,20,40,60,5,1,100,60,40,56,30,10,10,2,50,45,23,10,3]<300 Estos vectores nos indican que, por ejemplo, el producto representado en la primera casilla tiene un costo de 10 y un volumen de 5, el segundo tiene un costo de 25 y un volumen de 1. Desempeño del problema de la mochila utilizando la librería de A.G A continuación se muestra el desempeño de la librería de A.G desarrollando un algoritmo genético para el problema de la mochila. Para verificar su comportamiento y desempeño, se debe tener en cuenta que en todas las mediciones se utilizaron quince ciclos generacionales, de los cuales los cinco primeros admiten algún grado de infactibilidad, por tal motivo, los cinco primeros ciclos presentan un desempeño mejor que las restantes diez generaciones, pero teniendo en cuenta que estas soluciones encontradas en los primeros ciclos pueden llegar a ser infactibles. Recordemos que usar infactibilidad en los primeros pasos del algoritmo permite crear una mejor población con un alto índice de calidad. Es posible observar el desempeño en la figura 14. Figura 14 Desempeño de la librería con respecto a diferentes tamaños de la población 17 Conclusiones En el transcurso de la investigación, en lo referente a la revisión bibliográfica, el diseño y la implementación de la librería y el estudio del desempeño de ésta, se destacan las siguientes conclusiones: • Haskell presenta una gran cantidad de ventajas a la hora de programar A.G. En primer lugar su carácter modular permite construir funciones de orden superior que actúan de manera independiente, las cuales pueden ser mejoradas o reemplazadas si así se requiere. En segundo lugar, Haskell, por ser un lenguaje puramente funcional se acopla perfectamente a la definición formal del problema, es decir, casi que al definir matemáticamente el problema, se tiene parte de la implementación. Esto permite que nuestros algoritmos presenten un soporte matemático que los lenguajes imperativos no pueden ofrecer. • Haskell ofrece una serie de funciones y mecanismos para el manejo de listas de cualquier tipo, esta es una de las principales características con las cuales se logró implementar A.G., ya que permite reducir considerablemente el tamaño del código, haciendo más comprensible el problema a tratar. Es por estas razones, que el manejo de listas en Haskell se presenta como una herramienta interesente para programar A.G. • Haskell por ser un lenguaje de alto nivel, presenta varios inconvenientes en el cálculo numérico, que, aunque son considerables, pueden ser superados con una buena planeación y un buen manejo del estilo de programación. Uno de los principales inconvenientes es la velocidad, la cual es un poco más lenta que la de los lenguajes imperativos como Pascal. En comparación podríamos decir que Pascal toma los dos tercios de tiempo que Haskell en realizar ciertas operaciones, sin embargo Haskell es mucho más rápido en el manejo de listas. • En cuanto al desempeño de la librería y, en especial, de los problemas escogidos (El problema de la mochila, los billetes y cartero viajante), éstos se comportaron de manera muy similar a como lo sugiere la literatura. Por ejemplo, al tratar el problema con una población muy baja de 20 y 50 individuos, el algoritmo encuentra una incumbente con una función objetivo muy baja, pero si el problema se trabaja con una población superior a los 100 individuos (200,300,400 y 500), el algoritmo encuentra mejores soluciones. Sin embargo, en algún momento por más que se aumenta la población, el algoritmo se estabiliza y no presenta nuevas soluciones, por el contrario, el nivel en la función objetivo de las soluciones tiende a verse disminuido. 18 • De igual manera se puede observar que la tasa de mutación cuando es muy baja no permite encontrar buenas soluciones, ahora bien, si la tasa de mutación se mantiene entre el 3% y 5% el algoritmo logra encontrar buenas configuraciones. Por otra parte, si se aumenta la tasa de mutación, el algoritmo no converge fácilmente, por lo tanto, las soluciones que se encuentren serán de baja calidad. En otros casos es necesario tener tasas de mutación más altas o, por el contrario, mucho más bajas. Proyecciones y trabajo futuro: • El potencial de la librería como material pedagógico muestra un horizonte bastante prometedor, puesto que las características de la programación funcional pueden servir como plataforma para la iniciación tanto en los A.G como en la programación funcional. El desarrollo de materiales didácticos que mejoren la comprensión de estos temas es un aspecto prioritario que merece un estudio muy concienzudo y detallado. • Existe una serie de posibilidades para continuar con la implementación de A.G. con programación funcional. Día a día, nuevas características e innovaciones aparecen en el campo de la programación funcional, así como en los A.G. El trabajo con las mónadas de estado puede brindar nuevos caminos y formas alternativas de implementación que permitan mejorar el desempeño de los A.G construidos con Haskell. En los últimos años se han hecho nuevas herramientas para trabajar la programación distribuida, no solo en lenguajes como Haskell sino también en Mozart-oz, lo que permitiría el paralelismo, es decir, A.G que corran en paralelo en la misma o en distintas máquinas, con características de migración de población (Algoritmos Meméticos), comparación de poblaciones, e intercambio de información genética. 19 Referencias BANZHAF. W. (1999). Foundations of Genetic Algorithms. Reeves C. Morgan Kaufmann Publishers. BAUER. R.J. (1994). Genetic Algorithms and investment strategies. Wiley Finance Edition. BIRD, R. (2000). Introducción a la Programación Funcional con Haskell. Prentice Hall. BLICKLE. T y THIELE. L. (1995). A Comparation of Selection Schemes Used in Genetic Algorithms. TIK- Reports. COELLO. C. (2002). Introducción a la Computación Evolutiva. Notas de Clase-Departamento de Ingeniería Eléctrica de CINVESTAV - IPN México DF. CONTRERAS. A, MALDONADO. O. (2000). El Problema de la Satisfactibilidad. Universidad de Pamplona, Colombia. COOK. S. (2002). The P versus NP problem. Clay Mathematics Institute University of Toronto. DONOSO, Y. (2000). Desarrollo y análisis del rendimiento de la programación funcional para la solución de ecuaciones diferenciales. Revista Ingeniería y desarrollo. Universidad del Norte, Barranquilla Colombia. ESCOBAR A; GALLEGO R, ROMERO Ingeniería Electrónica UTP Colombia. R (2000). Algoritmos Genéticos. Maestría en FOGEL. David B. (1995). Evolutionary Computation. IEEE Press. GOLDBERG D. (2001). Genetic Algorithms in Search, Optimization and Machine Learning. Addison - Wesley. HOLLAND. J, (1992). Adaptation in Natural and Artificial Systems. MIT Press Edition. KURI. A, GALAVIZ. J. (1999). Algoritmos Genéticos. Centro de Investigación en Computación I.P.N. - Facultad de Ciencias U.N.A.M. LABRA, J. (1998). Introducción al Lenguaje Haskell. Universidad de Oviedo Departamento de Informática. MICHALEWICZ, Z. (1999). Genetic Algorithms + Data Structures = Evolution Programs. Springer-Verlag. SAGAN, C. (1980). Cosmos. Planeta E.D. TOLMOS, P. (2000). Introducción a los Algoritmos Genéticos y sus aplicaciones. Universidad Rey Juan Carlos, Madrid. VAN ROY P. y HARIDI S. (2002). Concepts, Techniques, and Models of Computer Programming.