Analisi di sensitività e pricing
Risolvere un problema di programmazione lineare produce molto più della soluzione ottima. Il solver restituisce anche, gratuitamente, le variabili duali dei vincoli e i costi ridotti delle variabili, e queste due quantità rispondono alle domande che un decisore si pone subito dopo aver visto la soluzione: quale risorsa conviene aumentare? Quanto dovrebbe salire il prezzo di un prodotto non conveniente perché lo diventi? Quale nuovo prodotto vale la pena aggiungere al catalogo? Il laboratorio risponde prima nel modo ingenuo, risolvendo molti problemi modificati, e poi mostra che la teoria della dualità dà le stesse risposte con una sola risoluzione.
1. Il problema del product mix#
Una fabbrica produce 4 tipi di profumo miscelando 5 ingredienti. Indichiamo con l’insieme dei profumi e con quello degli ingredienti. Il prezzo di vendita di un litro di profumo è :
| Profumo | 1 | 2 | 3 | 4 |
|---|---|---|---|---|
| Prezzo | 300 | 255 | 260 | 390 |
Un litro di profumo richiede una quantità (in litri) dell’ingrediente :
| Ingrediente Profumo | 1 | 2 | 3 | 4 |
|---|---|---|---|---|
| 1 | 0.01 | 0.05 | 0.07 | 0.04 |
| 2 | 0.34 | 0.45 | 0.36 | 0.51 |
| 3 | 0.08 | 0.06 | 0.12 | 0.12 |
| 4 | 0.55 | 0.35 | 0.29 | 0.32 |
| 5 | 0.02 | 0.09 | 0.16 | 0.01 |
Di ogni ingrediente è disponibile una quantità finita :
| Ingrediente | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| Disponibilità | 30 | 400 | 90 | 450 | 70 |
- Determinare le quantità da produrre per massimizzare il ricavo totale.
- Che cosa permetterebbe di aumentare il ricavo senza cambiare i prezzi né le composizioni ?
- Individuare i vincoli più restrittivi modificando uno alla volta i parametri .
- Tutti i profumi vengono prodotti? Se uno non lo è, di quanto bisogna aumentare il suo prezzo perché convenga produrlo?
1.1 Formulazione#
Una sola classe di variabili, litri del profumo , e un vincolo di disponibilità per ingrediente:
ovvero, in forma compatta, con il vettore dei prezzi, la matrice delle composizioni e il vettore delle disponibilità. (Il notebook scrive per un refuso nella forma compatta; il problema è di massimo, come dice la forma estesa e come fa il codice.) Le variabili sono continue: i litri si possono frazionare.
1.2 Una funzione che risolve il problema#
Poiché il problema verrà risolto molte volte con dati diversi, conviene incapsularlo in una funzione che riceve , , e restituisce il modello, la soluzione e il valore ottimo. Il notebook lascia da completare le quattro righe del modello:
import mip
c = [300, 255, 260, 390]
b = [30, 400, 90, 450, 70]
A = \
[[0.01, 0.05, 0.07, 0.04],
[0.34, 0.45, 0.36, 0.51],
[0.08, 0.06, 0.12, 0.12],
[0.55, 0.35, 0.29, 0.32],
[0.02, 0.09, 0.16, 0.01]]
def solve_productmix(A, b, c):
"""Risolve max{cx : Ax <= b, x >= 0} con A, b, c passati come argomenti"""
n = len(c) # numero di variabili
k = len(b) # numero di vincoli
m = mip.Model()
x = [m.add_var() for j in range(n)]
for i in range(k): # un vincolo per ingrediente
m.add_constr(mip.xsum(A[i][j] * x[j] for j in range(n)) <= b[i])
m.objective = mip.maximize(mip.xsum(c[j] * x[j] for j in range(n)))
m.optimize()
return (m, [x[j].x for j in range(n)], m.objective_value)
_, solution, objective = solve_productmix(A, b, c)
print("Solution:", solution)
print("Objective: {0:10.2f}".format(objective))L’underscore _ è il nome convenzionale per un valore restituito che non interessa (qui il modello). Output:
Solution: [573.115003808073, 89.74358974359002, 0.0, 323.0515359228229]
Objective: 320809.22
Si producono 573.1 litri del profumo 1, 89.7 del 2, 323.1 del 4 e niente del profumo 3, con ricavo 320809.22. Osserviamo che, con A[i][j], la riga i è l’ingrediente e la colonna j il profumo, e che gli indici Python partono da zero: il profumo 3 della tabella è solution[2].
2. Aumentare le risorse: la variazione di #
Task 2. Se prezzi e composizioni non si toccano, l’unica leva è la disponibilità . Aumentandola del 10% in tutte le componenti il ricavo cresce esattamente del 10%:
b2 = [b[i]*1.1 for i in range(len(b))]
_, solution2, objective2 = solve_productmix(A, b2, c)
print("Solution:", solution2)
print(f"Objective: {objective2}, increase: {((objective2 - objective) / objective) * 100: 6.2f}%")Solution: [630.4265041888804, 98.71794871794896, 0.0, 355.3566895151053]
Objective: 352890.1370906322, increase: 10.00%
Il risultato è esatto, non approssimato: se tutti i termini di destra vengono moltiplicati per 1.1, la soluzione moltiplicata per 1.1 resta ammissibile e ottima, e con essa l’obiettivo. Ogni componente della soluzione è infatti cresciuta del 10% (573.1 diventa 630.4).
3. Quale vincolo è più restrittivo: le variabili duali#
Task 3. Per capire quale risorsa «stringe» di più conviene modificare le disponibilità una alla volta e osservare la variazione dell’obiettivo. Il notebook aumenta ogni di 0.1 e riottimizza:
for i in range(len(b)):
b3 = b[:] # [:] crea una copia: modificare b3 non deve toccare b
b3[i] += .1
_, sol, obj = solve_productmix(A, b3, c)
print(f"Objective: {obj:10.2f}, Increase: {obj - objective:8.2f}, {(obj - objective)/objective * 100:5.2f}%")Objective: 320809.22, Increase: 0.00, 0.00%
Objective: 320828.41, Increase: 19.19, 0.01%
Objective: 321020.96, Increase: 211.75, 0.07%
Objective: 320821.10, Increase: 11.88, 0.00%
Objective: 320809.22, Increase: 0.00, 0.00%
Due letture. Gli ingredienti 1 e 5 non producono alcuna variazione: i loro vincoli non sono attivi all’ottimo (la soluzione ne usa rispettivamente 23.14 litri su 30 e 22.77 su 70), quindi averne di più non serve. Gli ingredienti 2, 3 e 4 sono invece saturi, e fra questi il terzo è di gran lunga il più restrittivo: un decilitro in più vale 211.75 di ricavo, contro 19.19 e 11.88 degli altri due.
Perché aumentare e non diminuire? Perché il problema è di massimo e vogliamo sapere quanto guadagneremmo con più risorse. In un problema di minimo si farebbe il contrario: si ridurrebbe la risorsa per vedere di quanto scende il costo.
3.1 Non serve risolvere cinque problemi#
La stessa informazione è già contenuta nella soluzione del problema originale. A ogni vincolo di un LP corrisponde una variabile duale, il cui valore all’ottimo misura la variazione dell’obiettivo per un aumento unitario del termine di destra. In Python-MIP si legge dall’attributo .pi di ogni vincolo, e la lista dei vincoli del modello è m.constrs:
m, sol, obj = solve_productmix(A, b, c)
print("Dual values:", [con.pi for con in m.constrs])Dual values: [-0.0, 191.92688499619217, 2117.479055597866, 118.81188118811873, -0.0]
Sono esattamente le variazioni misurate prima, moltiplicate per 10 (là l’incremento era 0.1, qui è unitario): , , . Le variabili duali dei vincoli non attivi sono zero.
La variabile duale associata al vincolo di un LP è il tasso di variazione del valore ottimo rispetto al termine di destra : aumentando di una piccola quantità , l’ottimo cambia di . In economia si chiama prezzo ombra (shadow price) della risorsa : è il massimo che converrebbe pagare per un’unità in più. Per un vincolo non attivo all’ottimo vale zero.
Il prezzo ombra vale localmente: aumentando molto una risorsa la soluzione cambia base e il tasso cambia. Per l’ingrediente 3 il tasso 2117.48 vale fra e ; oltre 94.1 litri l’ingrediente 3 smette di essere scarso, il suo vincolo diventa inattivo e il ricavo si ferma a 329528.25. Sotto i 73 litri il tasso è ancora più alto, perché la risorsa è ancora più preziosa.
Il prezzo ombra di una risorsa è quanto vale, in termini di obiettivo, un’unità in più di quella risorsa nella situazione attuale. Se la risorsa avanza il suo prezzo ombra è zero; se manca è positivo, e più manca più è alto. Il solver lo calcola comunque, insieme alla soluzione: basta leggerlo.
4. Un prodotto non conveniente: i costi ridotti#
Task 4. Il profumo 3 non viene prodotto: . A che prezzo comincerebbe a convenire? L’approccio ingenuo aumenta di 5 alla volta finché diventa positivo:
c_new = c[:]
sol = solution[:]
while sol[2] <= 1e-5: # sol[2] è x_3 (indici da zero)
c_new[2] += 5
_, sol, obj = solve_productmix(A, b, c_new)
print(f"With c_3 = {c_new[2]}: produce {sol[2]} liters of 3")
print(f"We need to set the price c_3 to {c_new[2]}")With c_3 = 265: produce 0.0 liters of 3
With c_3 = 270: produce 0.0 liters of 3
...
With c_3 = 355: produce 0.0 liters of 3
With c_3 = 360: produce 116.60624892111176 liters of 3
We need to set the price c_3 to 360
Il confronto sol[2] <= 1e-5 al posto di == 0 protegge dagli arrotondamenti del solver. Il metodo funziona ma non è esatto: sappiamo solo che la soglia sta fra 355 e 360, e ha richiesto venti risoluzioni.
4.1 Il costo ridotto#
Anche questa informazione è già nel modello risolto. Il costo ridotto di una variabile si legge dall’attributo .rc; le variabili del modello sono nella lista m.vars, e la terza è m.vars[2]:
print(m.vars[2].rc) # -97.64661081492756Aggiungendo 97.6466 al prezzo originale si ottiene , che sta proprio fra 355 e 360. La verifica fine del notebook prova due prezzi vicinissimi:
c_test = c[:]
c_test[2] = c[2] + 97.64
_, sol, obj = solve_productmix(A, b, c_test)
print(f"before threshold: x_3 = {sol[2]}") # 0.0
c_test[2] = c[2] + 97.65
_, sol, obj = solve_productmix(A, b, c_test)
print(f"after threshold: x_3 = {sol[2]}") # 116.60624892111176Sotto la soglia non si produce, sopra si producono d’un colpo 116.6 litri: la soluzione salta da un vertice all’altro del poliedro.
Dati i valori ottimi delle variabili duali, il costo ridotto della variabile è la differenza fra il prezzo del prodotto e il valore, ai prezzi ombra, delle risorse che un’unità di consuma. In un problema di massimo una variabile a zero ha , e è l’aumento di necessario perché entri in soluzione; una variabile positiva ha costo ridotto nullo.
Il calcolo per il profumo 3, con i duali e la colonna : le risorse consumate valgono , e . Il profumo 3 vale 260 ma «brucia» ingredienti che, usati altrove, rendono 357.65: finché il suo prezzo non copre quel valore non conviene. È lo stesso numero restituito da .rc, che il pacchetto ottiene dalla formula, non per tentativi.
I profumi 1, 2 e 4, prodotti all’ottimo, hanno costo ridotto zero (a meno di arrotondamenti): il loro prezzo eguaglia esattamente il valore delle risorse che consumano.
5. Pricing: risolvere con un sottoinsieme di colonne#
La seconda parte del laboratorio allarga il catalogo: la fabbrica ha ricette per profumi, , con gli stessi 5 ingredienti e le stesse disponibilità. La matrice è ora e ha 40 componenti; i primi quattro profumi coincidono con quelli della prima parte. (Da qui in avanti gli indici seguono la convenzione Python, da 0.)
- Risolvere il problema con i soli primi quattro profumi, ritrovando la soluzione della prima parte.
- Potendo aggiungere un profumo ai primi quattro, quale conviene aggiungere per aumentare il ricavo, anche di poco?
- Ripetere il punto 2 dopo aver aggiunto la colonna scelta: quale sarà la sesta variabile?
- Risolvere con tutte le 40 colonne e riportare il ricavo. Prima di risolvere: sarà maggiore o minore? Perché?
Il vettore dei prezzi è
c = np.array([300, 255, 260, 390, 243, 93, 310, 117, 286, 267,
250, 276, 324, 262, 216, 310, 285, 266, 280, 190,
241, 301, 271, 252, 326, 279, 310, 278, 250, 248,
319, 281, 287, 279, 299, 274, 305, 209, 250, 296])
b = np.array([30, 400, 90, 450, 70])e la matrice delle composizioni è riportata nel notebook (5 righe di 40 valori). L’istruzione print(f"A has size {A.shape}, ...") verifica le dimensioni: (5, 40), (5,), (40,).
5.1 Una funzione con l’insieme delle colonne come argomento#
Task 1. Poiché risolveremo il problema con sottoinsiemi diversi di profumi, la funzione riceve anche la lista indices delle colonne da includere. Le variabili diventano un dizionario indicizzato dai profumi scelti, così x[p] ha sempre il numero «vero» del profumo:
import mip
I = range(5) # ingredienti
def solve_productmix_indices(A, b, c, indices):
m = mip.Model()
P = [p for p in range(len(c)) if p in indices] # solo i profumi richiesti
x = {p: m.add_var() for p in P}
for i in I:
m.add_constr(mip.xsum(A[i][p]*x[p] for p in P) <= b[i])
m.objective = mip.maximize(mip.xsum(c[p]*x[p] for p in P))
m.optimize()
return (m, [x[p].x for p in P], m.objective_value)
_, solution, objective = solve_productmix_indices(A, b, c, [0,1,2,3])
print("Solution:", solution)
print("Objective: {0:10.2f}".format(objective))Con [0,1,2,3] si riottiene e ricavo 320809.22: nulla è cambiato, sono gli stessi dati.
5.2 Quale colonna aggiungere: prima per tentativi#
Task 2. Il modo ingenuo: per ogni da 4 a 39 si risolve il problema con le colonne [0,1,2,3,p] e si guarda la variazione dell’obiettivo. Trentasei LP.
for p in range(4, 40):
m, sol2, obj2 = solve_productmix_indices(A, b, c, [0,1,2,3,p])
rndsol2 = [round(s, ndigits=1) for s in sol2]
print(f"Added perfume {p:2d} --> {obj2:10.2f} (diff: {obj2 - objective:8.2f}). Sol: {rndsol2}")Solo quattro profumi migliorano il ricavo; per tutti gli altri l’obiettivo resta 320809.22 e la nuova variabile vale zero.
| Profumo aggiunto | Ricavo | Variazione | Soluzione |
|---|---|---|---|
| 14 | 322431.83 | +1622.61 | (589.1, 0, 0, 350.2, 42.2) |
| 17 | 321414.32 | +605.10 | (608.4, 0, 0, 333.2, 33.7) |
| 23 | 327650.17 | +6840.95 | (490.7, 0, 0, 416.9, 70.7) |
| 35 | 328466.26 | +7657.04 | (300.6, 0, 0, 460.1, 214.7) |
Tre osservazioni del notebook. Ogni volta che entra un nuovo profumo, il profumo 1 esce dalla produzione (da 89.7 a 0): il nuovo prodotto usa meglio gli stessi ingredienti. Il profumo 3 aumenta sempre; il profumo 0 a volte aumenta e a volte diminuisce. Il ricavo non può mai diminuire aggiungendo una colonna: la vecchia soluzione, con la nuova variabile a zero, resta ammissibile.
5.3 Quale colonna aggiungere: con i costi ridotti#
Anche qui trentasei risoluzioni erano superflue. Il costo ridotto di una colonna si calcola dalla formula usando i duali della soluzione corrente, anche se la colonna non fa parte del modello: basta conoscere e la colonna . Il vincolo duale associato a è ; se i duali correnti lo violano, cioè se
allora aggiungere la colonna migliora l’obiettivo; se la colonna non serve.
m, solution, objective = solve_productmix_indices(A, b, c, [0, 1, 2, 3])
dual = [c.pi for c in m.constrs]
for p in range(4, 40):
rc = c[p] - sum(dual[i]*A[i][p] for i in I)
print(f"Reduced cost for {p:2d} = {rc:9.2f}", end='')
if rc > 0:
print(" <======")
else:
print("")I duali sono gli stessi della prima parte, , e i costi ridotti positivi sono esattamente quattro: profumo 14 (38.48), 17 (17.98), 23 (96.75) e 35 (40.27). Sono le stesse quattro colonne trovate per tentativi, con una sola risoluzione al posto di trentasei.
Il testo del notebook dice «se il profumo non va aggiunto, altrimenti sì», ma il codice segnala le colonne con rc > 0, ed è il codice ad avere ragione per un problema di massimo: una colonna entra se il suo prezzo supera il valore delle risorse che consuma, cioè se . La frase del testo vale per la convenzione opposta (problema di minimo, o costo ridotto definito con il segno cambiato). Con la definizione usata qui, il costo ridotto del profumo 3 nella prima parte era : negativo, quindi il profumo non conveniva.
Il costo ridotto dice se una colonna migliora l’obiettivo, non di quanto: il profumo 35 ha costo ridotto 40.27, minore del 96.75 del profumo 23, ma la sua introduzione fa crescere il ricavo di più (7657 contro 6841). Il costo ridotto è il miglioramento per unità della nuova variabile; quanto la variabile potrà crescere dipende dalle risorse, e lo si scopre solo riottimizzando.
5.4 La sesta colonna#
Task 3. Aggiunto il profumo 14 (la scelta del notebook: non necessariamente quella con il costo ridotto massimo), si riottimizza e si ricalcolano i costi ridotti con i nuovi duali:
m, solution, objective = solve_productmix_indices(A, b, c, [0, 1, 2, 3, 14])
print(f"New objective: {objective}; solution = {solution}")
dual = [c.pi for c in m.constrs]
for p in range(4, 40):
rc = c[p] - sum(dual[i]*A[i][p] for i in I)
...Il nuovo ricavo è 322431.83 con ; i duali diventano . Il costo ridotto del profumo 14, ora in soluzione con valore positivo, è zero, come dev’essere per una variabile in base. Restano tre colonne con costo ridotto positivo: 12 (32.02), 23 (73.80) e 35 (34.93). Osserviamo che il profumo 12, prima non conveniente (), lo è diventato perché i prezzi ombra sono cambiati, e che il 17 non lo è più. Aggiungere il 14 non ha esaurito il potenziale di miglioramento.
5.5 Tutte le colonne#
Task 4. Con tutte e 40 le colonne il ricavo non può che essere maggiore o uguale: ogni soluzione ammissibile del problema ridotto lo è anche di quello completo, che quindi ha un ottimo almeno altrettanto buono. È l’argomento visto al task 2, applicato a molte colonne insieme.
m, solution, objective = solve_productmix_indices(A, b, c, range(40))
print(f"With all perfumes, objective: {objective}")
for p in range(40):
if solution[p] > 0:
print(f"{p:3d}: {solution[p]:10.2f}")With all perfumes, objective: 337301.13572587486
0: 121.50
3: 573.16
21: 71.43
23: 221.50
Il ricavo sale a circa 337k producendo solo i profumi 0, 3, 21 e 23. Il profumo 14, aggiunto al passo precedente, è stato soppiantato da 21 e 23; il profumo 1 non si produce più. La sequenza di aggiunte non è quindi monotona: una colonna che entra può uscire quando ne arrivano altre, e le combinazioni finali restano combinatorie.
5.6 Verso la generazione di colonne#
La domanda naturale è se il procedimento «calcola i costi ridotti, aggiungi una colonna conveniente, riottimizza» possa essere reso automatico e portare all’ottimo del problema completo senza mai caricarlo tutto. La risposta è sì, ed è l’idea della generazione di colonne (column generation): si parte da poche colonne, a ogni iterazione si cerca la colonna con costo ridotto massimo (il problema di pricing) e la si aggiunge; quando nessuna colonna ha costo ridotto positivo, la soluzione corrente è ottima anche per il problema completo, perché tutte le variabili assenti soddisfano il criterio di ottimalità.
Sull’istanza dei 40 profumi, scegliendo a ogni passo la colonna di costo ridotto massimo:
| Passo | Colonne nel modello | Ricavo | Colonna con massimo |
|---|---|---|---|
| 0 | 0, 1, 2, 3 | 320809.22 | 23 () |
| 1 | 0, 1, 2, 3, 23 | 327650.17 | 12 () |
| 2 | 0, 1, 2, 3, 23, 12 | 336215.30 | 21 () |
| 3 | 0, 1, 2, 3, 23, 12, 21 | 337301.14 | nessuna: tutti |
Dopo tre aggiunte il ricavo è 337301.14, lo stesso del problema con tutte le 40 colonne, e il criterio di arresto lo certifica senza aver mai costruito le altre 33. Il metodo diventa decisivo quando le colonne sono troppe per essere elencate, come nei problemi di taglio o di scheduling dove ogni colonna è un pattern.
Duali e costi ridotti sono il «listino prezzi» che la soluzione ottima attribuisce alle risorse. Con quel listino si valuta qualunque prodotto, anche uno mai messo nel modello: se il suo prezzo di vendita supera il costo delle risorse ai prezzi ombra, conviene inserirlo; altrimenti no. La generazione di colonne non fa altro che ripetere questa valutazione finché il listino non dice «basta».
6. Riepilogo e glossario#
| Domanda | Modo ingenuo | Modo esatto in Python-MIP |
|---|---|---|
| quale risorsa conviene aumentare? | perturbare ogni e riottimizzare | [con.pi for con in m.constrs] |
| di quanto deve salire perché ? | aumentare a passi e riottimizzare | abs(m.vars[j].rc) |
| quale nuova colonna aggiungere? | risolvere un LP per ogni candidata | con i duali correnti, colonna in modello o no |
| il ricavo con più colonne? | risolvere | non può diminuire: ogni soluzione del problema ridotto resta ammissibile |
| Termine | Significato |
|---|---|
| vincolo attivo (binding) | soddisfatto con uguaglianza all’ottimo; la risorsa è tutta usata |
variabile duale, shadow price, .pi |
variazione dell’ottimo per unità di ; zero se il vincolo non è attivo |
costo ridotto, .rc |
; zero per le variabili in base, per quelle a zero (problema di massimo) |
| base | insieme delle variabili positive nella soluzione di vertice corrente |
| pricing | calcolo dei costi ridotti delle colonne assenti per decidere quale aggiungere |
| generazione di colonne | risolvere con poche colonne, aggiungere quelle a costo ridotto positivo, ripetere fino a che non ne restano |