FOR · Laboratori

Primi modelli con Python-MIP

Lab 1: un LP, un knapsack e la fabbrica di cellulari, in forma esplicita e parametrica
≈ 11 min di lettura · 2489 parole

Il pacchetto Python-MIP (modulo mip, documentazione su python-mip.com) permette di creare, manipolare e risolvere modelli con vincoli lineari e variabili continue, intere o binarie. Tutti i problemi che incontreremo nei laboratori hanno la stessa struttura: una funzione obiettivo, un insieme di vincoli e la natura delle variabili. Questo capitolo mostra come ciascuno di questi tre pezzi si traduce in una riga di codice, prima su esempi minimi e poi su un problema di produzione modellato da zero.

formulazione matematicaDescrizionetesto del problemaInsiemiI, J, V, AParametric, A, b, dVariabilix, f (natura)Modelloobiettivo + vincoliCodice Python-MIPadd_var, add_constr, objective, optimizeSoluzionex.x, objective_valuetraduzionesolver

1. Il primo modello: due variabili, un vincolo#

Consideriamo il problema

maxx1+x2s.t.2x1+x210x1,x20\begin{array}{ll} \max & x_1 + x_2\\ \text{s.t.} & 2 x_1 + x_2 \le 10\\ & x_1, x_2 \ge 0 \end{array}

Prima di scrivere codice conviene capire la soluzione a occhio. Le due variabili hanno lo stesso peso nell’obiettivo, ma x1x_1 «costa» il doppio nel vincolo: ogni unità di x1x_1 consuma 2 unità di capacità, ogni unità di x2x_2 ne consuma una. Conviene quindi mettere tutto su x2x_2: x1=0x_1 = 0, x2=10x_2 = 10, valore dell’obiettivo 10. Il problema è banale di proposito, per concentrarsi sulla sintassi.

1.1 Installazione e importazione#

Ogni notebook comincia installando e importando il modulo:

!pip install mip
import mip

1.2 Il modello passo per passo#

Si crea un oggetto modello con il costruttore mip.Model(). Il nome (m nei notebook) è arbitrario; a questo oggetto si aggiungono poi variabili, vincoli e obiettivo.

m = mip.Model()

x1 = m.add_var()
x2 = m.add_var()

add_var() senza argomenti crea una variabile continua e non negativa: il limite inferiore predefinito è 0, quello superiore ++\infty. Osserviamo che dopo queste righe non è stato risolto nulla: print(x1) mostra un oggetto variabile, print(m) un modello vuoto.

Il vincolo si aggiunge con add_constr, scrivendolo come espressione algebrica delle variabili; l’obiettivo si assegna all’attributo objective, avvolto in mip.maximize o mip.minimize:

m.add_constr(2*x1 + x2 <= 10)
m.objective = mip.maximize(x1 + x2)

I vincoli possono essere molti, l’obiettivo è uno solo: per questo i vincoli si aggiungono con un metodo mentre l’obiettivo si assegna a un attributo. Infine si chiama il solver e si leggono i risultati:

m.optimize()
print('solution:', x1.x, ',', x2.x)      # solution: 0.0 , 10.0
print('objective:', m.objective_value)   # 10.0
La variabile non è il suo valore

print(x1) stampa l’oggetto, non il numero. Il valore nella soluzione ottima si legge con l’attributo .x: x1.x. Analogamente il valore dell’obiettivo è m.objective_value. Dimenticare il .x è l’errore più frequente della prima lezione.

Il programma completo, nell’ordine che useremo sempre:

import mip

m = mip.Model()

x1 = m.add_var()
x2 = m.add_var()

m.objective = mip.maximize(x1 + x2)
m.add_constr(2*x1 + x2 <= 10)

m.optimize()

print('solution:', x1.x, ',', x2.x)
m = mip.Model()modello vuotom.add_var(...)una variabile, o una lista / dizionariom.add_constr(...)uno o più vincoli (ciclo for)m.objective = mip.maximize(...)oppure mip.minimizem.optimize()chiama il solver CBCx.x, m.objective_valuelettura dei risultati

2. Un knapsack: variabili binarie e forma vettoriale#

Il secondo esempio è un problema di knapsack (zaino): ci sono oggetti con un valore e un peso, uno zaino con una capacità massima, e si vuole massimizzare il valore trasportato senza superare la capacità. La decisione per ogni oggetto è «lo prendo o no», quindi le variabili sono binarie.

max3x1+4x2+7x3+5x4s.t.4x1+5x2+6x3+4x413x1,x2,x3,x4{0,1}\begin{array}{lll} \max & 3 x_1 + 4 x_2 + 7 x_3 + 5 x_4\\ \text{s.t.} & 4 x_1 + 5 x_2 + 6 x_3 + 4 x_4 \le 13\\ & x_1, x_2, x_3, x_4 \in \{0,1\} \end{array}

2.1 La natura delle variabili#

La differenza rispetto al primo esempio è tutta nel parametro var_type di add_var:

Natura Argomento Note
continua var_type=mip.CONTINUOUS valore predefinito: si può omettere
intera var_type=mip.INTEGER
binaria var_type=mip.BINARY equivale a intera con 0x10 \le x \le 1

In tutti i casi la variabile è non negativa per default. Si può anche dare un nome alla variabile con name='x1', utile quando si stampa il modello.

2.2 Dati, variabili in lista e xsum#

Con quattro variabili scrivere x1 = m.add_var(...), x2 = ... e così via è già noioso; con centinaia diventa impraticabile. La soluzione è vettorizzare: si definiscono i dati come liste e si creano le variabili con una list comprehension, nell’ordine dati, modello, variabili, vincoli, obiettivo.

import mip

# dati: due vettori (valore e peso) e la capacità
value = [3, 4, 7, 5]
weight = [4, 5, 6, 4]
max_weight = 13

m = mip.Model()

x = [m.add_var(var_type=mip.BINARY) for i in range(4)]

Ora x è una lista di quattro variabili, x[0], …, x[3]: gli indici partono da zero, come sempre in Python, quindi la variabile matematica x1x_1 è x[0]. Per scrivere la somma iwixi\sum_i w_i x_i si usa mip.xsum, con dentro un ciclo for in una riga:

m.add_constr(mip.xsum(weight[i] * x[i] for i in range(4)) <= max_weight)

m.objective = mip.maximize(mip.xsum(value[i] * x[i] for i in range(4)))

L’espressione weight[i] * x[i] for i in range(4) genera i quattro prodotti wixiw_i x_i; xsum li somma in un’unica espressione lineare, che viene poi confrontata con max_weight. Per esempio con weight = [4, 5, 6, 4] la somma è 4x0+5x1+6x2+4x34x_0 + 5x_1 + 6x_2 + 4x_3. Il modulo accetta anche l’operatore += al posto di add_constr: m += mip.xsum(...) <= max_weight aggiunge il vincolo al modello, e nei notebook le due forme si alternano.

m.optimize()
print([x[i].x for i in range(4)])      # [1.0, 1.0, 0.0, 1.0]
print(m.objective_value)               # 12.0

La soluzione prende gli oggetti 1, 2 e 4 (peso 4+5+4=134 + 5 + 4 = 13, valore 3+4+5=123 + 4 + 5 = 12). L’oggetto 3, il più prezioso, resta fuori: con i suoi 6 di peso lascerebbe spazio per un solo altro oggetto e il valore massimo sarebbe 7+5=127 + 5 = 12 con peso 10, oppure 7+4=117 + 4 = 11. A parità di valore il solver riporta una delle soluzioni ottime. Si può stampare la soluzione in due modi equivalenti: ciclando sugli indici, [x[i].x for i in range(4)], oppure ciclando direttamente sulle variabili, [v.x for v in x].

Versione non parametrica, per confronto

Lo stesso modello scritto variabile per variabile. Funziona, ma non scala.

import mip

m = mip.Model(sense=mip.MAXIMIZE)

x1 = m.add_var(var_type=mip.BINARY)
x2 = m.add_var(var_type=mip.BINARY)
x3 = m.add_var(var_type=mip.BINARY)
x4 = m.add_var(var_type=mip.BINARY)

m.add_constr(4*x1 + 5*x2 + 6*x3 + 4*x4 <= 13)
m.objective = mip.maximize(3*x1 + 4*x2 + 7*x3 + 5*x4)

m.optimize()
print("Solution:", x1.x, "", x2.x, "", x3.x, "", x4.x)
# print("Solution: {0}, {1}, {2:.8f}, {3}".format(x1.x, x2.x, x3.x, x4.x))

L’argomento sense=mip.MAXIMIZE nel costruttore dichiara il verso dell’ottimizzazione; è ridondante quando si usa mip.maximize sull’obiettivo, e i notebook lo lasciano per completezza.

3. La fabbrica di cellulari: dal testo al modello#

Da qui in avanti i laboratori seguono sempre lo stesso schema: una descrizione del problema, la sua modellazione matematica, la traduzione in Python-MIP e la soluzione.

Il problema

La XYZ mobile corporation produce due modelli di cellulare, M1 e M2, a partire da un magazzino di componenti. Entrambi usano i componenti base (display, memoria, fotocamera, CPU); solo M1 ha due extra, una fotocamera termica FLIR e un ricetrasmettitore satellitare. La disponibilità di ogni componente e il fabbisogno per unità prodotta sono:

Componente M1 M2 Disponibilità
Display 1 2 10
Memoria 2 2 18
Fotocamera 1 3 12
CPU 2 3 21
Fotocamera termica 1 0 9
Rx/tx satellitare 1 0 10

Il prezzo di vendita è 110 per M1 e 130 per M2. Quanti esemplari di ciascun modello produrre per massimizzare il ricavo?

Un modo per leggere la tabella: guardando solo i display si possono produrre al più 10 M1 e nessun M2, oppure 5 M2 e nessun M1. Ogni riga impone un vincolo analogo, e la soluzione deve rispettarli tutti insieme.

3.1 I quattro passi della modellazione#

Conviene seguire sempre la stessa scaletta: insiemi, parametri, variabili decisionali, modello.

Insiemi. Due: l’insieme dei modelli di cellulare I={1,2}I = \{1, 2\}, con indice ii, e l’insieme dei componenti J={1,,6}J = \{1, \ldots, 6\}, con indice jj.

Parametri. Sono tutte le informazioni numeriche del testo, e solo quelle:

Variabili decisionali. xix_i = numero di cellulari del modello ii da produrre. Non si producono 2,3 cellulari: xix_i è intera e non negativa. Per convenzione le lettere iniziali dell’alfabeto (a,b,ca, b, c) si riservano ai parametri e quelle finali (x,y,zx, y, z) alle variabili.

Modello. Si massimizza il ricavo, somma dei prezzi per le quantità, rispettando la disponibilità di ogni componente:

maxiIcixis.t.iIajixibjjJxiZ+iI\begin{array}{lll} \max & \displaystyle\sum_{i \in I} c_i x_i\\ \text{s.t.} & \displaystyle\sum_{i \in I} a_{ji} x_i \le b_j & \forall j \in J\\ & x_i \in \mathbb{Z}_+ & \forall i \in I \end{array}

Il «per ogni jJj \in J» genera un vincolo per componente. Scritto per esteso, con i numeri della tabella, il modello è

max110x1+130x2s.t.x1+2x210(display)2x1+2x218(memoria)x1+3x212(fotocamera)2x1+3x221(CPU)x19(fotocamera termica)x110(rx/tx satellitare)x1,x2Z+.\begin{array}{llll} \max & 110 x_1 + 130 x_2\\ \text{s.t.} & x_1 + 2 x_2 & \le 10&\quad\text{(display)}\\ & 2 x_1 + 2 x_2 & \le 18&\quad\text{(memoria)}\\ & x_1 + 3 x_2 & \le 12&\quad\text{(fotocamera)}\\ & 2 x_1 + 3 x_2 & \le 21&\quad\text{(CPU)}\\ & x_1 & \le 9 &\quad\text{(fotocamera termica)}\\ & x_1 & \le 10&\quad\text{(rx/tx satellitare)}\\ & x_1, x_2 \in \mathbb Z_+. \end{array}

Le due scritture sono equivalenti: la forma con le sommatorie è quella parametrica, che si legge allo stesso modo con due prodotti o con duecento; la forma esplicita è comoda solo quando il modello è piccolo.

Un refuso nel testo originale

Nei notebook l’ultimo vincolo del modello è scritto x210x_2 \le 10. Dalla tabella, però, il ricetrasmettitore satellitare è usato solo da M1 (1 per M1, 0 per M2): il vincolo corretto è x110x_1 \le 10, ed è quello riportato qui sopra. Il codice delle soluzioni è coerente con la tabella (x1 <= 10, ultima riga di A uguale a [1, 0]); è la formula in LaTeX\LaTeX a essere sbagliata. In ogni caso il vincolo non è attivo all’ottimo, quindi la soluzione non cambia in nessuna delle due letture.

3.2 Traduzione esplicita#

La traduzione riga per riga del modello esplicito. Le variabili sono intere, quindi var_type=mip.INTEGER; l’esercizio del notebook lascia da completare proprio questo argomento e i vincoli.

import mip

m = mip.Model()

x1 = m.add_var(var_type=mip.INTEGER)
x2 = m.add_var(var_type=mip.INTEGER)

m.objective = mip.maximize(110*x1 + 130*x2)

m.add_constr(  x1 + 2*x2 <= 10)
m.add_constr(2*x1 + 2*x2 <= 18)
m.add_constr(  x1 + 3*x2 <= 12)
m.add_constr(2*x1 + 3*x2 <= 21)
m.add_constr(  x1        <=  9)
m.add_constr(  x1        <= 10)

m.optimize()

print(x1.x, x2.x)        # 8.0 1.0

La soluzione ottima produce 8 unità di M1 e 1 di M2, con ricavo 1108+1301=1010110 \cdot 8 + 130 \cdot 1 = 1010. Vale la pena verificarla sui vincoli: i display usati sono 8+2=108 + 2 = 10 su 10 e la memoria 16+2=1816 + 2 = 18 su 18, entrambi saturi; fotocamere 8+3=118 + 3 = 11 su 12, CPU 16+3=1916 + 3 = 19 su 21, termiche 8 su 9. Sono display e memoria a limitare la produzione.

La regione ammissibile della fabbrica di cellulari. I sei vincoli delimitano il poligono; i punti sono le soluzioni intere ammissibili, la stella l’ottimo (8, 1). La retta tratteggiata è la curva di livello 110x_1 + 130x_2 = 1010: nessun punto intero ammissibile sta sopra di essa.

Osserviamo che l’ottimo intero coincide qui con il vertice del poligono: il rilassamento continuo del problema ha già soluzione intera (8,1)(8, 1). Non è la regola, ed è il motivo per cui la natura delle variabili va sempre dichiarata.

4. La forma parametrica#

Lo stesso problema in forma matriciale: definiti

x=(x1x2),c=(110130),A=(122213231010),b=(10181221910),x = \begin{pmatrix}x_1\\x_2\end{pmatrix},\qquad c = \begin{pmatrix}110\\130\end{pmatrix},\qquad A = \begin{pmatrix}1&2\\2&2\\1&3\\2&3\\1&0\\1&0\end{pmatrix},\qquad b = \begin{pmatrix}10\\18\\12\\21\\9\\10\end{pmatrix},

il modello si scrive

maxcxs.t.AxbxZ+2\begin{array}{lll} \max & c^\top x\\ \text{s.t.} & A x \le b\\ & x \in \mathbb Z_+^2 \end{array}

che è la forma standard con cui si presenta ogni problema di programmazione lineare. In codice, i dati stanno in liste separate dal modello, così potrebbero anche essere letti da un file. Una matrice è semplicemente una lista di liste: A[j] è la riga jj, A[j][i] l’elemento di riga jj e colonna ii.

import mip

n_model = 2
n_component = 6

c = [110, 130]

A = [[1,2],
     [2,2],
     [1,3],
     [2,3],
     [1,0],
     [1,0]]

b = [10,18,12,21,9,10]

Le variabili si creano in una riga, anche se sono solo due; l’obiettivo è una xsum sui modelli; i vincoli si generano con un ciclo for sui componenti, e dentro ognuno la somma corre sui modelli:

m = mip.Model()
x = [m.add_var(var_type=mip.INTEGER) for i in range(n_model)]

m.objective = mip.maximize(mip.xsum(c[i]*x[i] for i in range(n_model)))

for j in range(n_component):
    m += mip.xsum(A[j][i]*x[i] for i in range(n_model)) <= b[j]

m.optimize()
print([x[i].x for i in range(n_model)])     # [8.0, 1.0]

Il ciclo for j in range(n_component) è la traduzione letterale del «jJ\forall j \in J» del modello, e mip.xsum(A[j][i]*x[i] for i in range(n_model)) è la traduzione di iIajixi\sum_{i \in I} a_{ji} x_i. Un modo per non sbagliare gli indici è partire dal termine di destra: si scrive prima <= b[j], poi la somma che deve rispettarlo, e ci si chiede su quale indice corre la somma (qui ii, i modelli) e quale invece è fissato dal vincolo (qui jj, il componente). La soluzione è la stessa, (8,1)(8, 1), perché è lo stesso modello scritto in un altro modo.

In parole semplici

Il modello parametrico separa i dati dal modello. Cambiare un prezzo o aggiungere un prodotto significa toccare una lista, non riscrivere i vincoli. Nel codice ogni pezzo della formulazione ha una controparte fissa: gli insiemi diventano range(n), i parametri liste, il «per ogni» un for, la sommatoria una xsum.

Il programma completo in forma parametrica
import mip

n_model = 2
n_component = 6

c = [110, 130]

A = [[1,2],
     [2,2],
     [1,3],
     [2,3],
     [1,0],
     [1,0]]

b = [10,18,12,21,9,10]

m = mip.Model()
x = [m.add_var(var_type=mip.INTEGER) for i in range(n_model)]

m.objective = mip.maximize(mip.xsum(c[i]*x[i] for i in range(n_model)))

for j in range(n_component):
    m.add_constr(mip.xsum(A[j][i]*x[i] for i in range(n_model)) <= b[j])

m.optimize()

print([x[i].x for i in range(n_model)])

5. Errori tipici della prima lezione#

Durante il laboratorio sono emersi alcuni inciampi ricorrenti, tutti facili da evitare una volta nominati.

Il messaggio da portare via è duplice. Da un lato la scaletta di modellazione: insiemi, parametri, variabili decisionali, modello (obiettivo, vincoli, natura delle variabili). Dall’altro la traduzione meccanica in Python-MIP:

Nel modello In Python-MIP
insieme II di nn elementi range(n) oppure una lista
parametro cic_i, matrice ajia_{ji} liste c[i], A[j][i]
variabile xi0x_i \ge 0 continua x = [m.add_var() for i in range(n)]
xix_i intera / binaria var_type=mip.INTEGER / mip.BINARY
icixi\sum_{i} c_i x_i mip.xsum(c[i]*x[i] for i in range(n))
vincolo j\forall j for j in range(k): m.add_constr(... <= b[j])
max\max / min\min m.objective = mip.maximize(...) / mip.minimize(...)
risolvere m.optimize()
valore di xix_i, dell’obiettivo x[i].x, m.objective_value

Nel prossimo capitolo la stessa scaletta si applica a problemi su grafi, dove insiemi e variabili sono indicizzati da coppie di nodi e le liste lasciano il posto ai dizionari.

Elaborato dai tre notebook del Lab 1 (firstMIP, cellphone, cellphone-parametric) e dalla registrazione della prima lezione di laboratorio.