Primi modelli con Python-MIP
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.
1. Il primo modello: due variabili, un vincolo#
Consideriamo il problema
Prima di scrivere codice conviene capire la soluzione a occhio. Le due variabili hanno lo stesso peso nell’obiettivo, ma «costa» il doppio nel vincolo: ogni unità di consuma 2 unità di capacità, ogni unità di ne consuma una. Conviene quindi mettere tutto su : , , 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 mip1.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 . 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.0print(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)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.
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 |
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 è x[0]. Per scrivere la somma 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 ; 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 è . 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.0La soluzione prende gli oggetti 1, 2 e 4 (peso , valore ). 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 con peso 10, oppure . 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.
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 , con indice , e l’insieme dei componenti , con indice .
Parametri. Sono tutte le informazioni numeriche del testo, e solo quelle:
- la disponibilità del componente , il vettore ;
- il fabbisogno del componente per produrre un’unità del modello , la matrice della tabella;
- il prezzo di vendita del modello , il vettore .
Variabili decisionali. = numero di cellulari del modello da produrre. Non si producono 2,3 cellulari: è intera e non negativa. Per convenzione le lettere iniziali dell’alfabeto () si riservano ai parametri e quelle finali () alle variabili.
Modello. Si massimizza il ricavo, somma dei prezzi per le quantità, rispettando la disponibilità di ogni componente:
Il «per ogni » genera un vincolo per componente. Scritto per esteso, con i numeri della tabella, il modello è
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.
Nei notebook l’ultimo vincolo del modello è scritto . Dalla tabella, però, il ricetrasmettitore satellitare è usato solo da M1 (1 per M1, 0 per M2): il vincolo corretto è , 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 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.0La soluzione ottima produce 8 unità di M1 e 1 di M2, con ricavo . Vale la pena verificarla sui vincoli: i display usati sono su 10 e la memoria su 18, entrambi saturi; fotocamere su 12, CPU su 21, termiche 8 su 9. Sono display e memoria a limitare la produzione.
Osserviamo che l’ottimo intero coincide qui con il vertice del poligono: il rilassamento continuo del problema ha già soluzione intera . 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
il modello si scrive
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 , A[j][i] l’elemento di riga e colonna .
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 «» del modello, e mip.xsum(A[j][i]*x[i] for i in range(n_model)) è la traduzione di . 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 , i modelli) e quale invece è fissato dal vincolo (qui , il componente). La soluzione è la stessa, , perché è lo stesso modello scritto in un altro modo.
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.
- Natura delle variabili dimenticata. Senza
var_typela variabile è continua: il solver può restituire 8,5 cellulari. Nel knapsack, senzamip.BINARY, la soluzione riempirebbe lo zaino con frazioni di oggetti. - Matrice trascritta male. Nella versione parametrica l’ultima riga di
Aè stata discussa a lungo in aula, perché la formula del notebook e la tabella non concordano (si veda la nota sul refuso più sopra). Conviene sempre rileggereAriga per riga contro la tabella del testo, che è la fonte dei dati, e non contro una formula intermedia. - Celle non rilanciate. Dopo aver corretto un dato bisogna rieseguire la cella dei dati e poi tutte le celle successive, altrimenti il modello continua a usare i valori vecchi (si veda il troubleshooting del capitolo 0).
- Completamento automatico. Colab suggerisce codice mentre si scrive; è comodo, ma il suggerimento non è sempre quello giusto. Ogni riga proposta va letta prima di accettarla.
- Uguaglianza nei vincoli. Un vincolo di uguaglianza si scrive con
==, non con=.
6. Riepilogo#
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 di elementi | range(n) oppure una lista |
| parametro , matrice | liste c[i], A[j][i] |
| variabile continua | x = [m.add_var() for i in range(n)] |
| intera / binaria | var_type=mip.INTEGER / mip.BINARY |
mip.xsum(c[i]*x[i] for i in range(n)) |
|
| vincolo | for j in range(k): m.add_constr(... <= b[j]) |
| / | m.objective = mip.maximize(...) / mip.minimize(...) |
| risolvere | m.optimize() |
| valore di , 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.