Optimización Entera Mixta No Lineal (MINLP) con R y Pyomo: Un ejemplo práctico Jorge Ayuso Rejas V Jornadas de Usuarios de R Etopia-Centro de Arte y Tecnologı́a, Zaragoza 12 y 13 de Diciembre de 2013 Introducción Lo expuesto en las siguientes páginas es un resumen del trabajo que he ido realizando los últimos meses como analista en Conento (www.conento.com). Estábamos interesados en mejorar la recomendación que hacemos a nuestros clientes sobre cómo distribuir su presupuesto en una campaña de publicidad. Es importante tomar una buena decisión a la hora de repartir el presupuesto entre los distintos medios disponibles y tener en cuenta las caracterı́sticas de cada anunciante. Para ello planteamos un problema de optimización usando la información conseguida de nuestros modelos econométricos en donde recogemos las caracterı́sticas de cada anunciante. En la primera sección se introducen las curvas de aportes y se plantea el problema de optimización a resolver. En la sección dos se explica la implementación del problema en R , haciendo uso del software Pyomo (Hart et al., 2012) un sustituto libre de AMPL* escrito en Python. Para terminar, en la última sección veremos un ejemplo práctico con una interfaz amigable para el usuario final gracias al paquete shiny (RStudio and Inc., 2013). * AMPL es un lenguaje de modelado algebraico para programación matemática http://www.ampl. com. 1 1. Optimización de Presupuesto Encontrar la mejor forma de repartir un presupuesto de comunicación para conseguir maximizar las ventas de una empresa/negocio es un ejemplo clásico de optimización. En esta sección plantearemos el problema a optimizar bajo ciertas hipótesis y simplificaciones para centrarnos en la implementación (sección 2). Supongamos que una empresa quiere repartir el presupuesto de una semana (se podrı́a hacer para otros periodos de tiempo) destinado a publicidad en los siguientes medios: Exterior, Online, Prensa, Radio, Revistas y Televisión. Para llevar acabo la optimización es necesario una función o funciones objetivos que maximizar. En nuestro caso esas funciones serán las curvas de aportes que nos indicarán para cada medio e inversión qué aportes conseguimos (los aportes pueden ser cualquier serie de negocio del anunciante: altas, contactos, ventas. . . ). 1.1. Curvas de aportes Para cada medio “i” definimos su curva de aporte como : fi (xi ) = eAi −Bi /xi donde Ai , Bi > 0 parámetros a definir y xi es la inversión semanal en euros en el medio i. A continuación veamos algunas propiedades de estas curvas: Las curvas son diferenciables. Existe un cambio de curvatura en xi = convexa. 1 B. 2 i Para valores mayores la curva es Tienen una ası́ntota horizontal en lı́mx→∞ eAi −Bi /xi = eAi . Estas tres propiedas son buenas a la hora de usar los algoritmos de optimización. En el siguiente gráfico se muestra las curvas de nuestro problema, donde los parámetros Ai , Bi de cada medio se han conseguido en un estudio previo. 2 Curvas de aportes Televisión Prensa 750 Aportes Online 500 Radio 250 Exterior 0 0€ 200.000€ 400.000€ 600.000€ 800.000€ Inversión Observamos como cada curva tiene distintas ası́ntotas, siendo la más alta la televisión. Además podemos ver también qué curva devuelve mejores aportes para cada inversión. Por ejemplo para una inversión menor que 200.000e, online es el medio con mayor retorno. Para valores mayores empieza a ser más rentable la prensa y para valores grandes de inversión el mejor medio es la televisión. 1.2. Planteamiento del problema Una vez definidas las curvas, estamos en condiciones de definir el problema de optimización. Vamos a definir dos versiones del problema, la primera versión será un problema de optimización no lineal (NLP). Definición (Problema 1) Maximizar: Aportes := X eAi −Bi /xi i∈medios sujeto a: P i∈medios xi ≤ Presupuesto Total mini ≤ xi ≤ maxi ∀i ∈ Medios Donde como antes xi es la inversión semanal en euros en el medio i y Ai , Bi , mini y maxi parámetros del problema mayores que cero. En este primer problema estamos obligando que la inversión de cada medio sea siempre mayor o igual que un mı́nimo definido. Este mı́nimo se define ya que aunque 3 matemáticamente pueda tener sentido invertir una cantidad pequeña, en la realidad no es posible hacer inversiones arbitrariamente pequeñas en ciertos medios. También estamos fijando un máximo de inversión aunque al existir una ası́ntota horizontal no harı́a falta. Definimos ahora una segunda versión del problema, donde vamos a permitir que la inversión en cada medio sea también cero. Definición (Problema 2) Maximizar: X Aportes := eAi −Bi /xi i∈medios sujeto a: P i∈medios xi ≤ Presupuesto Total xi ∈ [mini , maxi ] ∪ {0} ∀i ∈ Medios En este caso el dominio de cada variable a optimizar es discontinuo, a la hora de resolverlo esto puede ser una dificultad. Ası́ que se suele usar variables auxiliares para solventarlo. De este modo el problema 2 podrı́amos re-formularlo de la siguiente manera: Definición (Problema 20 ) Maximizar: Aportes := X wi eAi −Bi /xi i∈medios sujeto a: P i∈medios wi xi ≤ Presupuesto Total wi ∈ {0, 1} ∀i ∈ Medios mini ≤ xi ≤ maxi ∀i ∈ Medios Donde hemos introducido una variable binarias para cada medio, las cuales nos indicarán si existe inversión o no en el medio. De este modo tenemos un problema de optimización entera mixta no lineal, más conocido por sus siglas en inglés MINLP (Mixed-Integer Nonlinear Programming). 4 2. Implementación del problema Uno de los primeros pasos cuando queremos conocer como realizar algún análisis en R es revisar la página web de “Task Views” (http://cran.r-project.org/web/ views) donde tenemos resumido los principales paquetes divididos por temas de interés. En este caso nos centramos en el de optimización (http://cran.r-project.org/ web/views/Optimization.html). Podemos encontrar varios paquetes para resolver el problema 1 (NLP). Uno de los que parece más completo es el paquete nloptr (Johnson, 2013). Pero no se encontró ninguno para resolver el problema 2 (MINLP). Buscando algoritmos/software libres para resolver problemas MINLP encontramos estos tres: Bonmin (Bonami and Lee, 2007). Couenne (Belotti, 2009). Minotaur (Leyffer et al., 2011). Los tres tienen en común, además de ser de código abierto, que pueden ser utilizados desde AMPL gracias a la librerı́a ASL: “AMPL Solver Library” (Gay, 1997). Aunque AMPL no es libre (la librerı́a ASL sı́ lo es) existe una versión estudiante con la cual podemos hacer pruebas aunque no podrı́amos usarlo para uso comercial (para ello tendrı́amos que comprar una licencia de AMPL). Los tres algoritmos también se pueden utilizar directamente desde código C ası́ que se podrı́a implementar una conexión desde R. Es más, los dos primeros algoritmos pertenecen al proyecto COIN-OR (http://www.coin-or.org/), y usan como optimizador para la parte NLP Ipopt (Wächter and Biegler, 2006). Ipopt ya tiene implementada una interfaz con R gracias al paquete ipoptr (Ypma, 2010). También existe un proyecto de un interfaz para el optimizador Bonmin (https://r-forge.r-project.org/scm/viewvc. php/pkg/Rbonmin/?root=rino) pero parece abandonado a dı́a de hoy. Ası́ que se puede probar los optimizadores con AMPL (más fácil que hacer las pruebas directamente en C) sabiendo que se podrı́a después abandonar AMPL y usar sólo software libre. 2.1. Implementación del problema con AMPL Una de las ventajas que nos ofrecen AMPL es poder definir el problema de manera abstracta y más tarde introducir los datos. Además la sintaxis es bastante sencilla y muy intuitiva para definir problemas de optimización. Veamos un ejemplo del problema 2 implementado en AMPL: 5 set datos ordered; param param param param param MIN {datos} >= 0; MAX {datos} >= 0; A {datos} >= 0; B {datos} >= 0; Presupuesto >= 0; var w {j in datos} binary; var x {j in datos} <= MAX[j], >= MIN[j]; maximize cost: sum {j in datos} w[j] * exp(A[j]-B[j]/x[j]); subject to c1: sum {j in datos} w[j]*x[j] <= Presupuesto ; option solver bonmin; Después generamos un fichero con los datos (podemos cambiar los parámetros sin tener que cambiar el modelo) y AMPL se encarga de definir el problema final y de las derivadas si son necesarias. A continuación se muestra el formato que necesitamos para introducir los parámetros: param: datos : "A" "B" "MIN" "MAX" := "Televisión" 7.091114787 270163.8599 183359.5768 421967.2358 "Online" 6.571585223 62164.76381 38488.03122 110009.0971 "Prensa" 6.779516794 105752.5395 71820.20725 165047.6584 "Radio" 6.18270522 64579.77595 43419.72799 102131.3559 "Exterior" 4.168857373 26693.37017 20854.44233 34971.98656; param Presupuesto := 1050000 ; Una vez hechas las primeras pruebas y comprobado que los algoritmos funcionaban bien para el problema presentado. Se buscó si existı́a alguna alternativa libre de AMPL (y ası́ evitar el programa en C para conectar R y los optimizadores). De este modo llegamos al software Pyomo (Hart et al., 2012) que pertenece al proyecto Coopr. 2.2. Implementación del problema con R y Pyomo Pyomo tiene una sintaxis muy parecida que AMPL y al estar escrito en Python tenemos una mayor flexibilidad a la hora de definir nuestro problema. Además al disponer de la librerı́a ASL puede conectar con los tres optimizadores fácilmente. Veamos el mismo problema pero esta vez implementado en Pyomo: 6 # Imports from coopr.pyomo import * # *********************************** model = AbstractModel() # *********************************** model.datos = Set() # *********************************** model.MIN = Param(model.datos, within=NonNegativeReals) model.MAX = Param(model.datos, within=NonNegativeReals) model.A = Param(model.datos, within=NonNegativeReals) model.B = Param(model.datos, within=NonNegativeReals) model.Presupuesto = Param(within=NonNegativeReals) # *********************************** def Level_bounds(model, i): return (model.MIN[i], model.MAX[i]) model.x = Var(model.datos, bounds=Level_bounds) model.w = Var(model.datos, within=Binary) # *********************************** def Total_Cost_rule(model): return sum([model.w[j] * exp(model.A[j]-model.B[j]/model.x[j]) \ for j in model.datos]) model.Total_Cost = Objective(rule=Total_Cost_rule,sense=maximize) # *********************************** def Demand_rule(model): return sum([model.w[i] * model.x[i] for i in model.datos]) <= model.Presupuesto model.Demand = Constraint(rule=Demand_rule) Observamos como la sintaxis es similar, además el mismo formato usado para introducir los datos en AMPL es valido para Pyomo. Ası́ que construimos una función en R para exportar los distintos datos a este formato. Podemos definir cuatro tipos de datos distintos: Parámetros individuales, por ejemplo el presupuesto. Parámetros con un ı́ndice, podemos definir a la vez un ı́ndice (set en el lenguaje de Pyomo) y algunos parámetros asociados a este ı́ndice. En nuestro caso el ı́ndice serı́an los medios y los parámetros asociados el mı́nimo, máximo, A y B. Parámetro con dos ı́ndices (matrices), no lo estamos usando por ahora pero es posible definir una matriz de datos para dos ı́ndices definidos. Índice sin parámetros, útil por ejemplo para poder usar matrices de parámetros. 7 Veamos una sencilla función para exportar datos desde R al formato adecuado: data_ampl<-function(x,name=NULL,tipo=NULL){ if( is.null(name) ) stop("Introduza un nombre para el conjunto de datos") prueba<- capture.output(write.table(x)) if(is.null(tipo)){ # Por defecto se construyen parámetros con un ı́ndice prueba[1]<-paste("param:",name,":",prueba[1],":=") }else{ if(tipo=="matriz"){ prueba[1]<-paste("param ",name,"(tr) :",prueba[1],":=") }else{ if(tipo=="set"){ prueba[1]<-paste("set",name,":=",prueba[1]) }else{ if(tipo=="param"){ prueba<-paste("param",name,":=",x) }else{ stop("Tipo introducido no definido") } } } } prueba[length(prueba)]<-paste(prueba[length(prueba)],";",sep="") prueba } datos ## ## ## ## ## ## A B MIN MAX Televisión 7.091 270164 183360 421967 Online 6.572 62165 38488 110009 Prensa 6.780 105753 71820 165048 Radio 6.183 64580 43420 102131 Exterior 4.169 26693 20854 34972 data_ampl(datos,name="datos") ## ## ## ## ## ## [1] [2] [3] [4] [5] [6] "param: datos : \"A\" \"B\" \"MIN\" \"MAX\" :=" "\"Televisión\" 7.091114787 270163.8599 183359.5768 421967.2358" "\"Online\" 6.571585223 62164.76381 38488.03122 110009.0971" "\"Prensa\" 6.779516794 105752.5395 71820.20725 165047.6584" "\"Radio\" 6.18270522 64579.77595 43419.72799 102131.3559" "\"Exterior\" 4.168857373 26693.37017 20854.44233 34971.98656;" Una vez tenemos los datos en el formato adecuado para Pyomo y hemos guardado 8 la definición del modelo en un fichero llamado modelo.py en el directorio de trabajo de R. Podemos ejecutar el modelo de la siguiente manera: write(c(data_ampl(datos,name="datos"), data_ampl(570000,name="Presupuesto",tipo="param")), "datos.dat") # NOTA: Bonmin y Pyomo tienen que estar configurado en el PATH # del sistema o incluir ruta completa system("pyomo modelo.py datos.dat --solver bonmin",intern=TRUE)[1:13] ## ## ## ## ## ## ## ## ## ## ## ## ## [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] [11] [12] [13] "[ "[ "[ "[ "[ " " " " " " "[ "[ 0.00] Setting up Pyomo environment" 0.01] Applying Pyomo preprocessing actions" 0.02] Creating model" 0.08] Applying solver" 0.95] Processing results" Number of solutions: 1" Solution Information" Gap: <undefined>" Status: optimal" Function Value: 1429.08707683" Solver results file: results.json" 0.96] Applying Pyomo postprocessing actions" 0.96] Pyomo Finished" Podemos ver como resuelve el problema y además guarda los resultados en un archivo “json”. Ası́ que es fácil recuperar los resultados con la función fromJSON del paquete RJSONIO (Lang, 2013). En el anexo se muestra como Pyomo interpreta los datos y crea la función a optimizar. 9 3. Ejemplo Final Una vez nos es posible resolver el problema planteado es hora de dar soluciones. Como no todo el mundo esta familiarizado con R, hemos desarrollado un pequeño interfaz web gracias al paquete shiny (RStudio and Inc., 2013). Gracias a shiny logramos sin grandes esfuerzos una aplicación web donde el usuario final puede probar varios escenarios y conseguir el reparto óptimo para cada uno de ellos. En http://jornadas-r.conento.com se ha publicado una versión libre donde previamente hemos cargado los datos usados en el ejemplo de estas páginas. Además los código están disponibles en el github de conento http://github.com/Conento/ Optimizador_MINLP. Si tenemos instalado pyomo, bonmin e ipopt en el path del sistema bastarı́a con ejecutar shiny::runGitHub(’Optimizador MINLP’,’Conento’), para más detalles visitar el github. El funcionamiento de la aplicación es muy sencillo para el usuario final. Veamos los pasos a seguir: 1. Introducir el presupuesto total en euros a repartir en una semana y si queremos permitir inversiones de cero (Problema 2) o por lo contrario queremos invertir en todos los medios (Problema 1). 2. Eligir los medios de nuestra campaña a optimizar. En muchos casos no queremos de ninguna manera invertir en un medio ası́ que podemos elimiar los medios que queramos. 3. En la pestaña de optimización damos al botón “Ejecutar Optimización”. 4. Comprobamos los resultados de la optimización y podemos descargar los resultados en excel pulsando en “Descargar resultados de la optimización” Como observamos el procedimiento es muy sencillo. Internamente simplemente recogemos la información del usuario: Presupuesto, Permitir inversiones cero y medios en los que invertir. Y con estas tres opciones usamos la función que definimos data ampl para pasar los datos a pyomo. Veamos las lı́neas del código más importantes (el resto del código se puede ver en el github): 10 # zoom es una matriz con los datos A,B, MIN y MAX de # los medios que ha seleccionado el usuario. # presu es el presupuesto introducido por el usuario. # input$permitir es una variable lógica que nos indica # si permite inversiones cero o no. aux<-c(data_ampl(zoom,name="datos"), data_ampl(presu,name="Presupuesto",tipo="param")) file<- as.numeric(Sys.time()) write(aux,paste(file,".dat",sep="")) if( isolate(input$permitir)){ system(paste("pyomo bonmin.py ",file, ".dat --solver bonmin --save-results ",file, ".json > ",file,".txt",sep="")) }else{ system(paste("pyomo ipopt.py ",file, ".dat --solver ipopt --save-results ",file, ".json > ",file,".txt",sep="")) } salida<-fromJSON(content=paste(file,".json",sep="")) unlink(paste(file,"*",sep="") ) De esta manera hemos pasado al optimizador los datos del problema con el escenario del usuario y hemos recuperado la información en la variable salida. Después tratamos la salida y hacemos uso de los paquetes rCharts (Vaidyanathan, 2013) y googleVis (Gesmann and de Castillo, 2011) para conseguir el gráfico de reparto y la tabla respectivamente. 11 4. Conclusiones En estas páginas hemos visto como conectar R con el software Pyomo y resolver ası́ el problema planteado. Además aprovechando el gran ecosistema que nos proporciona R realizamos una aplicación potente y muy sencilla para el usuario final. Esto último lo conseguimos gracias a paquetes como shiny, rCharts o googleVis. Una vez comprendido el funcionamiento podemos extenderlo de manera fácil. Por ejemplo, optimizar varias semanas teniendo en cuenta el efecto de la prolongación de la publicidad más conocido por el término en inglés Advertising Adstock (Broadbent, 1979). También optimizar problemas más complicados ya que gracias a Pyomo tenemos gran flexibilidad para definir problemas usando python. Para el futuro queda pendiente empaquetar esta idea en un paquete de R incluyendo Pyomo y algunos optimizadores para ası́ conseguir una distribución final más fácil para el usuario de R. 12 5. Bibliografı́a Belotti, P. (2009). Couenne: a user’s manual. Department of Mathematical Sciences, Clemson University, Clemson, SC, available at http://www. coin-or. org/Couenne/couenneusermanual. pdf, accessed April, 23:2012. Bonami, P. and Lee, J. (2007). Bonmin users’ manual. accessed November, 4:2008. Broadbent, S. (1979). One way tv advertisements work. Journal of the Market Research. Gay, D. M. (1997). Hooking your solver to ampl. Technical report, Technical Report 93-10, AT&T Bell Laboratories, Murray Hill, NJ, 1993, revised. Gesmann, M. and de Castillo, D. (2011). googlevis: Interface between r and the google visualisation api. The R Journal, 3(2):40–44. Hart, W., Laird, C., Watson, J.-P., and Woodruff, D. (2012). Pyomo - Optimization Modeling in Python, volume 67 of Springer Optimization and Its Applications. Springer. Johnson, S. G. (2013). The nlopt nonlinear-optimization package. Lang, D. T. (2013). RJSONIO: Serialize R objects to JSON, JavaScript Object Notation. R package version 1.0-3. Leyffer, S., Linderoth, J., Luedtke, J., Mahajan, A., and Munson, T. (2011). Minotaur, a toolkit for solving mixed-integer nonlinear optimization problems. R Core Team (2013). R: A Language and Environment for Statistical Computing. R Foundation for Statistical Computing, Vienna, Austria. RStudio and Inc. (2013). shiny: Web Application Framework for R. R package version 0.7.0. Vaidyanathan, R. (2013). rCharts: Interactive Charts using Polycharts.js. R package version 0.3.51. Wächter, A. and Biegler, L. T. (2006). On the implementation of an interior-point filter line-search algorithm for large-scale nonlinear programming. Mathematical programming, 106(1):25–57. Ypma, J. (2010). Introduction to ipoptr: an r interface to ipopt. 13 Anexo Interpretación de los datos introducidos a Pyomo: 1 Set Declarations datos : Dim=0 Dimen=1 Size=5 Domain=None Ordered=False Model=unknown ['Exterior', 'Online', 'Prensa', 'Radio', 'Televisión'] Bounds=None 0 RangeSet Declarations 5 Param Declarations A : Size=5 Domain=NonNegativeReals Exterior : 4.168857373 Online : 6.571585223 Prensa : 6.779516794 Radio : 6.18270522 Televisión : 7.091114787 B : Size=5 Domain=NonNegativeReals Exterior : 26693.37017 Online : 62164.76381 Prensa : 105752.5395 Radio : 64579.77595 Televisión : 270163.8599 MAX : Size=5 Domain=NonNegativeReals Exterior : 34971.98656 Online : 110009.0971 Prensa : 165047.6584 Radio : 102131.3559 Televisión : 421967.2358 MIN : Size=5 Domain=NonNegativeReals Exterior : 20854.44233 Online : 38488.03122 Prensa : 71820.20725 Radio : 43419.72799 Televisión : 183359.5768 Presupuesto : Size=1 Domain=NonNegativeReals 1050000 2 Var Declarations w : Size=5 Domain=Binary Key : Initial Value : Lower Bound : Upper Bound : Current Value: Fixed: Stale Exterior : None : 0 : 1 : None : False : True Online : None : 0 : 1 : None : False : True 14 Prensa : None : 0 : 1 : None : False : True Radio : None : 0 : 1 : None : False : True Televisión : None : 0 : 1 : None : False : True x : Size=5 Domain=Reals Key : Initial Value : Lower Bound : Upper Bound : Current Value: Fixed: Stale Exterior : None : 20854.44233 : 34971.98656 : None : False : True Online : None : 38488.03122 : 110009.0971 : None : False : True Prensa : None : 71820.20725 : 165047.6584 : None : False : True Radio : None : 43419.72799 : 102131.3559 : None : False : True Televisión : None : 183359.5768 : 421967.2358 : None : False : True 1 Objective Declarations Total_Cost : Size=1 sum( prod( num=( w[Exterior] , exp( sum( 4.168857373 , -1 * prod( num=( 26693.37017 ) , denom=( x[Exterior] ) ) ) ) ) ) , prod( num=( w[Radio] , exp( sum( 6.18270522 , -1 * prod( num=( 64579.77595 ) , denom=( x[Radio] ) ) ) ) ) ) , prod( num=( w[Prensa] , exp( sum( 6.779516794 , -1 *prod( num=( 105752.5395 ) , denom=( x[Prensa] ) ) ) ) ) ) , prod( num=( w[Televisión] , exp( sum( 7.091114787 , -1 * prod( num=( 270163.8599 ) , denom=( x[Televisión] ) ) ) )) ) , prod( num=( w[Online] , exp( sum( 6.571585223 , -1 * prod( num=( 62164.76381 ) , denom=( x[Online] ) ) ) ) ) ) ) 1 Constraint Declarations Demand : Size=1 -Inf <= sum( prod( num=( w[Exterior] , x[Exterior] ) ) , prod( num=( w[Radio] , x[Radio] ) ) , prod( num=( w[Prensa] , x[Prensa] ) ) , prod( num=( w[Televisión] , x[Televisión] ) ) , prod( num=( w[Online] , x[Online] ) ) ) <= Presupuesto 0 Block Declarations 15