Práctica 1 de Inteligencia Artificial

Planificación de horarios

Noviembre 2005.



Introducción

El objetivo de esta práctica es implementar un programa en JAVA que simula la asignación del uso de recursos de un supercomputador. El supercomputador tiene 168 horas para ejecutar varios trabajos. Un trabajo tiene un tamaño entre una y cinco horas y además una tolerancia con que la hora de inicio real puede apartarse de la hora de inicio. El rango para cada trabajo puede ser diferente y no tiene por que ser simétrico, por eso el trabajo puede empezar en cualquier hora entre la tolerancia. Además los trabajos tienen que terminar en una hora válida.

El programa tiene que intentar asignar el máximo número de trabajos posible dentro del horario. Para esto se usan los algoritmos Hill-Climbing y Simulated Annealing, que ya están implementados en el paquete AIMA. El ejercicio es crear formas de representación del estado, operadores junto con sus restricciones, la manera en que se generarán sucesores y las funciones heurísticas.

Para comparar los posibles métodos hay varios experimentos, que combinan los diferentes operadores, estados inciales, funciones heurísticas y parámetros de cada algoritmo. Cada parte se explica con más detalles en las siguientes secciones.

Implementación

Representación

Trabajo

Cada trabajo guarda 4 datos:

Además, hemos ampliado esta clase con dos datos extra:

Horario

En cada horario grabamos la lista de trabajos que contiene. Es un ArrayList de objetos de tipo Trabajo, y puede tener de 0 a 168 elementos. Para simplificar las búsquedas, mantenemos el array ordenado por hora de inicio real.

Además, para que sea más eficiente comprobar si un trabajo está o no en el horario, mantenemos un array de horas libres, que contiene, para cada hora (de 0 a 167):

Todos los horarios son válidos y contienen trabajos que no se solapan.

Lista de trabajos disponibles

En la clase HorarioTrabajo (programa principal) guardamos la lista de todos los trabajos a poner, que es la entrada del programa.

Usamos un ArrayList llamado todosTrabajos, que contendrá probablemente más de 168. Nosotros llenamos esta lista con trabajos aleatorios mediante la función llenarLista(), que escoge datos al azar y les asigna un identificador distinto a cada uno.

Para saber si un trabajo ya está puesto o no dentro de un horario determinado, la información la guardamos dentro de cada horario (pero no en esta clase). Por tanto, esta lista no se modifica durante la ejecución del algoritmo.

Estado inicial

Hemos usado básicamente, dos tipos de estado inicial:

  1. Horario vacío
  2. Horario lleno con trabajos al azar

Un horario vacío da más libertad para poner buenos trabajos, y es apropiado si los operadores usados no permiten corregir los trabajos ya puestos (por ejemplo, si sólo podemos poner y quitar trabajos). Lo malo es que tarda más en llenarse.

Un horario ya lleno está más cerca de la solución, pero puede pasar que para llegar al óptimo haya que deshacerlo un poco (quitando trabajos), y algunos algoritmos se negarán a hacer esto porque les llevaría a situaciones peores (ej. Hill Climbing). Esto se evita usando otros operadores más sofisticados.

Para llenar el horario, lo que hacemos es intentar colocar cada trabajo de la lista de trabajos disponibles. Si alguno no se puede colocar a la hora propuesta, lo dejamos y pasamos al siguiente. Así queda un horario bastante lleno (ej: 51 trabajos cuando hay 200 disponibles).

Estado final

En este problema no queda claro cuál es el estado final al que se quiere llegar.

Al principio consideramos que una solución era un horario con algún trabajo, pero eso haría que casi cualquier horario fuera aceptado.

Al final hemos usado como definición de estado final el horario que tiene 168 trabajos, ya que si conseguimos eso, no hay ningún otro horario mejor. Es muy poco probable que lleguemos a esta solución, pero al menos el algoritmo intentará acercarse.

Operadores

Para moverse de un estado a otro hace falta aplicar un operador. Pero como un operador no se puede usar siempre en cualquier situación, junto con cada uno decidimos sus restricciones especiales.

poner

El operador poner añade un trabajo nuevo al horario, en una posición fija. Sólo se puede poner un trabajo nuevo si su sitio está dentro del horario y libre. Por eso se tiene que comprobar que la hora de inicio es mayor que 0 y la hora de fin menor o igual que 168, que el desplazamiento (la diferencia entre la hora de inicio y la hora de inicio real) está permitido, que el trabajo no está ya en el horario, y que las horas a ocupar están libres. Si todo esto es cierto, el trabajo nuevo puede ponerse en el horario. Se pone el trabajo en el horario en la posición correcta (manteniendo la lista ordenada según la hora de inicio real), y se cambian las horas libres, que pasan a estar ocupadas.

quitar

El operador quitar quita un trabajo puesto del horario. Pero antes se tiene que comprobar que el trabajo está puesto en el trabajo. Después de haber quitado el trabajo se tienen que marcar las horas ocupadas como horas libres.

ajustar

El operador ajustar cambia la posición de un trabajo puesto en el horario. Por eso las restricciones son casi las mismas que las de poner. El trabajo tiene que estar puesto y la posición nueva tiene que está dentro del horario y el desplazamiento tiene que estar permitido. Además las horas antes o después el trabajo puesto tienen que estar libres para ajustar. Después del cambio se cambia la información en el trabajo y las horas en la lista de horas libres.

intercambiar

El operador intercambiar es una combinación de los operadores quitar y poner. El primero quita el trabajo del horario, y el segundo pone otro posiblemente distinto, quizás en otro sitio. Las acciones y las restricciones de este operador son las mismas que en los operadores poner y quitar, por supuesto. El operador es sólo para simplificar el proceso: se puede mejorar el horario con sólo un sucesor en vez de dos (uno para quitar y otro para poner).

Heurísticas

En el programa hay funciones heurísticas diferentes para comparar los sucesores. El objetivo es optimizar la cantidad de trabajos en el horario de 168 horas. Para este objetivo hay tres parámetros en el horario que influyen en la calidad: El número de trabajos, los tamaños de los trabajos y la posición en el horario. El número de trabajos es lo más importante, por eso la heurística más fácil es la uno:

1. f(n) = - n (n = el número de trabajos)

Normalmente es un valor bajo mejor que un valor alto. Por eso la función heurística es -n. Los tamaños de los trabajos son importantes, también. Una posibilidad para compararlos es usando el número de horas libres. Un horario de x trabajos con y horas libres es mejor que un horario con x trabajos y z<y horas libres porque en el primer horario se pueden poner más trabajos nuevos.

Pero no sólo el número de horas libres influye en la calidad del horario: las posiciones de los trabajos puestos, también. Un espacio grande es mejor que muchos descansos cortos. La longitud media de los períodos de horas libres es una posibilidad para comprobar esto, pero hay otras mejores. La media no es unívoca, hay situaciones con la misma media pero con diferentes longitudes. Sin embargo la media es muy fácil y rápida de calcular. La función heurística número dos es una combinación de las tres cosas:

2. f(n,s,hl) = - ( 168^2 * n + 168 * s + hl ) (s = longitud media de los períodos de horas libres, hl = número de horas libres)

El factor 168 es el resultado de la dependencia de las variables. El valor de n es entre 0 y 168, y el de s y hl también. Un horario con sólo un trabajo y 167 horas libres es mejor que un horario sin trabajos. Por eso la importancia de un trabajo tiene que ser más grande que la de un horario libre. Paralelo a eso, la importancia de la longitud de los períodos tiene que ser más grande que el número de horas libres.

Pero hay una dependencia muy grande entre la longitud media de los períodos de horas libres y el número de horas libres: una longitud media más grande implica más horas libres. Por eso el número de horas libres es sólo una información más concreta; la longitud es sólo otra versión para representar el número de horas libres. Para simplificar la función heurística número dos hay una función tres:

3. f(n,s) = - ( 168 * n + hl )

Y una función cuatro:

4. f(n,hl) = - ( 168 * n + s )

El factor 168 es el resultado de la misma dependencia de los variables.

Resultados

Experimentos

est. ini. ops. trab. disp. algo. heu. suc. p. suc. máx. suc. f. trab. nodos tmp. comentarios
vacío {pq} 200 sa 2 1163 1163 72 72 73 1,4s sólo se aplica {p}, núm. trabajos creciente siempre
vacío {pqai} 200 sa 2 1163 ~2500 ~250 82 308 25s trabajos creciente poco a poco; se aplica sólo {pia} pero 'a' demasiado (le lleva al mismo estado}
vacío {pqai} 200 h 2 1163 5148 192 89 90 10,5s sólo se aplica {p}
vacío {pq} 200 h 2 1163 1163 89 89 90 2s
lleno {pq} 200 sa 2 96 96 70 70 20 0,8s empieza con 51 trabajos, trabajos siempre creciente
lleno {pq} 200 h 2 96 96 70 70 20 0,7s trabajos siempre creciente
lleno {pqai} 200 h 2 299 309 229 79 44 4s no se aplica {q}; cantidad de sucesores es siempre casi lo mismo
lleno {pqai} 200 sa 2 299 939 217 84 332 29s no se aplica {q}, muchas veces no hay un cambio en el número de trabajos
lleno {pqa} 200 sa 2 216 216 159 72 277 2s no se aplica {q}, muchas veces se aplica {a} y no hay un cambio en el número de trabajo
lleno {pa} 200 sa 2 165 165 81 74 495 2,5s
lleno {pai} 200 sa 2 248 640 115 85 458 39s
lleno {pqa} 200 h 2 216 216 157 71 23 1s
lleno {pa} 200 h 2 165 165 86 71 23 1s
lleno {pai} 200 h 2 248 248 150 79 44 4s
lleno {pqai} 200 h 1 299 299 202 71 21 2,3s sólo se aplica {p}, trabajos siempre creciente
lleno {pqai} 200 h 3 299 320 245 77 38 4s trabajos siempre creciente, no se aplica {q}
lleno {pqai} 200 h 4 299 299 202 71 21 2,3s sólo se aplica {p}, trabajos siempre creciente
lleno {pqai} 200 sa 1 299 1218 215 78 496 37s se aplica {q} alguna vez
lleno {pqai} 200 sa 3 299 740 249 81 366 32s no se aplica {q}
lleno {pqai} 200 sa 4 299 648 217 80 369 33s no se aplica {q}
lleno {pqai} 100 sa 2 220 447 158 58 264 7s no se aplica {q}, empieza con 41 trabajos
lleno {pqai} 500 sa 2 418 3492 440 111 281 1,3m no se aplica {q}, empieza con 65 trabajos
lleno {pqai} 1000 sa 2 634 8179 969 120 277 4m no se aplica {q}, empieza con 70 trabajos
lleno {pqai} 100 h 2 220 237 159 56 27 1,3s no se aplica {q}, empieza con 41 trabajos
lleno {pqai} 500 h 2 418 991 299 124 92 34s no se aplica {q}, empieza con 65 trabajos
lleno {pqai} 1000 h 2 634 1900 386 152 117 2m no se aplica {q}, se empieza con 70 trabajos
lleno {pai} 200 sa 2 299 1030 352 88 2555 4m (10000, 100, 20, 0.005)
lleno {pai} 200 sa 2 299 1064 197 86 583 55s (2000, 100, 20, 0.005)
lleno {pai} 200 sa 2 299 1120 215 82 336 28s (1000, 100, 20, 0.005)
lleno {pai} 200 sa 2 299 1024 910 57 51 4,3s (100, 100, 20, 0.005)
lleno {pai} 200 sa 2 299 973 206 83 562 50s (2000, 50, 20, 0.005)
lleno {pai} 200 sa 2 299 1447 196 87 593 53s (2000, 200, 20, 0.005)
lleno {pai} 200 sa 2 299 994 206 87 552 49s (2000, 300, 20, 0.005)
lleno {pai} 200 sa 2 299 1360 209 86 556 52s (2000, 100, 10, 0.005)
lleno {pai} 200 sa 2 299 1013 209 85 613 55s (2000, 100, 50, 0.005)
lleno {pai} 200 sa 2 299 1296 196 85 665 1m (2000, 100, 500, 0.005)
lleno {pai} 200 sa 2 299 612 204 86 677 1m (2000, 100, 20, 0.00005)
lleno {pai} 200 sa 2 299 769 206 86 526 47s (2000, 100, 20, 0.05)
lleno {pai} 200 sa 2 299 968 198 85 432 38s (2000, 100, 20, 0.5)
lleno {pai} 200 sa 2 299 1287 1083 65 84 7s (2000, 100, 20, 5), el número de sucesores creciente
lleno {pai} 200 sa 2 299 776 569 59 55 5s (2000, 100, 20, 50)

Diferentes estados iniciales

En general, empezar con un horario ya lleno (con trabajos al azar) funciona mejor que empezar con uno vacío, porque ya tenemos mucha parte del trabajo hecha. Si los operadores permiten corregir un horario (ajustar e intercambiar), entonces quizás se puede encontrar una solución mejor. En cambio, sólo con poner y quitar no se puede mejorar mucho un horario aleatorio; hace falta un horario vacío para tener más libertad al colocar los trabajos en el sitio más apropiado.

Un horario aleatorio tiene tendencia a no poderse deshacer fácilmente, ya que el operador quitar, necesario para hacer rectificaciones, siempre lleva a un estado mucho peor.

Diferentes conjuntos de operadores

Considerando n como el número de trabajos ya puestos, y N como el número de trabajos disponibles por poner, cada operador genera el siguiente número de sucesores:

Esto es porque, por ejemplo para poner, hay que decidir dos cosas: qué trabajo coger de la lista de trabajos disponibles, y con qué desplazamiento ponerlo (de -5 a +5). intercambiar necesita saber el trabajo antiguo (uno de entre los n), y el sustituto (N posibilidades). En realidad, no todos estos sucesores son válidos, ya que no siempre son aplicables. Se usan funciones como sePuedePoner() para saber cuándo lo son.

De aquí se deduce que intercambiar es el operador más costoso, seguido por poner, y luego por ajustar y quitar. Pero en nuestras pruebas hemos visto que quitar (el menos costoso) casi no se aplica, por varios motivos:

Hemos probado los soguientes conjuntos de los operadores:

  1. {poner, quitar}
  2. {poner, quitar, ajustar, intercambiar}
  3. {poner, quitar, ajustar}
  4. {poner, ajustar}
  5. {poner, ajustar, intercambiar}

El último conjunto es el mejor porque con los operadores ajustar e intercambiar se puede mejorar el horario lleno. El operador quitar se aplica casi nunca, por eso el conjunto con todos los operadores es peor que el último. Con el segundo conjunto el algoritmo hace más sucesores pero no usa los sucesores adicionales.

El cuarto conjunto no es tan bueno como el último porque falta la posibilidad de coregir trabajos ya puestos en el horario. El tercer conjunto tiene el mismo problema. Por cierto tiene el operador quitar pero se aplica casi nunca.

El primer conjunto es el peor. En realidad es como si hubiera sólo el operador poner. Pero con un horario vacío el conjunto es bastante bueno en combinación con una función heurística buena. En esto caso el algoritmo puede elegir los trabajos mejores de la lista de trabajos disponibles y no necesita corregir el horario. Los operadores ajustar e intercambiar importan más con un horario lleno que con uno vacío.

Diferentes funciones heurísticas

Todas las funciones heurísticas dan prioridad al número de trabajos, ya que es lo que hay que maximizar en este problema.

Las heurísticas (ya explicadas en la parte de representación) son:

  1. f(n) = - n
  2. f(n,s,hl) = - ( 168^2 * n + 168 * s + hl )
  3. f(n,s) = - ( 168 * n + hl )
  4. f(n,hl) = - ( 168 * n + s )

Donde n es el número de trabajos, s la longitud media de los períodos de horas libres, y hl el número de horas libres (información que complementa a s). En todas interviene n como factor principal.

La heurística 1 es la más sencilla pero la que peor funciona, ya que hace que sólo se elijan horarios que tienen más trabajos que el actual; por tanto, sólo se aplica el operador poner (porque es el único que puede añadir un trabajo a un horario).

El resultado es bastante malo, sobre todo en Hill Climbing, que no permite empeorar. Se comprueba cómo se aplica continuamente poner con cada uno de los trabajos disponibles (sin fijarse en otros criterios), y no se aplica ningún otro operador. Hace algo parecido a nuestra función horarioAleatorio(), que intenta poner todos los trabajos, sólo que esta vez se prueba con todos los posibles valores de desplazamiento.

La función 2 es la más sofisticada, y la que da mejores resultados (ver tabla). También es la que tiene en cuenta más magnitudes. Las heurísticas 3 y 4 no son tan buenas como la 2, ya que sólo incorporan uno de los valores mientras que la 2 tiene los dos.

Elegir bien la heurística es importante en todos los casos. Incluso si sólo estamos trabajando con un operador, poner, hay diferencia entre poner un trabajo en cualquier sitio libre y ponerlo en el sitio más apropiado, donde aproveche bien el espacio. La heurística también importa en los dos algoritmos.

Diferente cantidad de trabajos disponibles

El resultado de los experimentos es muy claro: Más trabajos disponibles es mejor para la solución, pero hacen que cueste más de obtener. Esto pasa siempre, tanto con un estado inicial de un horario vacío o lleno y con cada función heurística y cada algoritmo. Naturalmente con más trabajos disponibles el algoritmo tiene más posibilidades para elegir el trabajo más apropriado.

Diferentes parámetros de Simulated Annealing

Hay cuatro parámetros para ajustar el algoritmo Simulated Annealing: steps, stiter, k y lambda. Sus valores por defecto son 10000, 100, 20, 0.005 respectivamente. Los más importantes son el primero, el tercero y el último.

El primer parámetro sirve para poner un límite a la ejecución, y para que sea útil tiene que depender del número de trabajos disponibles. Con muy pocos trabajos, el límite puede ser bajo y por eso la ejecución será muy rápida. Con muchos trabajos, hay que aumentar el límite para que se encuentre una solución buena.

El tercero (k) hace que se generen muchos sucesores cuando éste se aumenta, y por eso la ejecución es más lenta, y entonces el límite establecido puede hacer que se pare antes de encontrar una solución buena. Hemos dejado el valor por defecto porque ya da buenos resultados.

La lambda se comporta de forma inversa: al aumentarla excesivamente, el programa acaba muy pronto, pero con un resultado bastante malo. En cambio, cuando la reducimos mucho (casi 0), el programa encuentra una solución buena, pero tarda demasiado. Un valor intermedio, como 0.005, también consigue la solución buena, pero más rápido.

El segundo parámetro (stiter, número de iteraciones por paso de temperatura) no influye mucho, pero con valores pequeños no da buenos resultados.

Diferentes algoritmos

El algoritmo Simulated Annealing es mejor con pocos trabajos disponibles. La ejecución es larga, pero el número de trabajos puestos en el horario es más alto. Con más trabajos disponibles el algoritmo Simulated Annealing es peor que el otro pero depende mucho de los parámetros del algoritmo: con un límite bajo de pasos en general la solución es peor, porque se para demasiado pronto (cuando supera el límite de pasos). Con límites más altos la solución es mejor que Hill-Climbing, pero el número de sucesores y el número de nodos expandidos es grande.

Además, el tiempo de ejecución depende mucho del número de trabajos disponibles, con una relación exponencial. Cuando hay muchos, a Simulated Annealing cada vez le cuesta más encontrar una solución mejor que la que ya tiene, y al final sólo se mueve entre horarios que tienen el mismo número de trabajos (a veces aumenta, pero al final este crecimiento es muy lento).

Por eso los resultados de nuestros experimentos con una cantidad de trabajos disponibles de 500 ó 1000 no son muy buenos, porque los límites de Simulated Annealing son demasiado pequeños. Pero para demostrar las diferencias entre las soluciones con pocos trabajos disponibles y muchos, el resultado es suficiente.

Valoración final

Hemos visto que: