Localizzazione di hub con costo massimo
Classe: MILP · Legami: attivazione aggregata, variabile di massimo · Script: python/fam08_4_hub.py
Difficoltà: ★★★★☆ · Tempo: 45–60 min
Problema 8.4
\(n \in \mathbb{Z}_{\ge 1}\) terminali, ciascuno da connettere a esattamente un hub; \(m \in \mathbb{Z}_{\ge 1}\) hub, ciascuno con capacità \(k \in \mathbb{Z}_{\ge 1}\) terminali e costo di attivazione \(f_j \in \mathbb{Q}_{\ge 0}\). \(c_{ij} \in \mathbb{Q}_{\ge 0}\) è il costo di connettere il terminale \(i\) all'hub \(j\). Si minimizza la somma dei costi di attivazione e del costo di connessione massimo di ciascun hub.
Il problema a parole. Decidiamo quali hub attivare e a quale hub connettere ciascun terminale. L'obiettivo: attivazione più, per ciascun hub, il costo di connessione più alto (non la somma). I vincoli: ogni terminale a esattamente un hub; un hub non attivato non serve nessuno, uno attivato serve al più \(k\).
Modello
Variabili decisionali. \(n\,m\) binarie \(x_{ij}\), \(m\) binarie \(y_j\) (hub attivato), \(m\) continue non negative \(z_j\) (costo massimo dell'hub \(j\)).
- l'obiettivo minimizza costi di attivazione più costo massimo per hub;
- il primo vincolo assegna ogni terminale a un hub (\(n\) vincoli);
- il secondo lega assegnamento e attivazione, in forma aggregata, e impone la capacità (\(m\) vincoli);
- il terzo lega assegnamento e variabile di massimo (\(n\,m\) vincoli).
Primo legame: attivazione aggregata. Se un terminale è connesso all'hub \(j\), \(j\) deve essere attivato; dalla contronominale, un hub non attivato non serve nessuno. Entrambi imposti direttamente dal secondo vincolo. Il verso opposto — un hub attivato serve almeno un terminale — non è imposto dai vincoli: \(y_j = 1\) con tutte le \(x_{ij} = 0\) è ammissibile. Segue dall'ottimalità, con una forza che dipende dal segno di \(f_j\): se \(f_j > 0\), spegnere un hub vuoto riduce strettamente il costo, quindi in ogni ottimo nessun hub attivato resta vuoto; se \(f_j = 0\) — che l'enunciato ammette, avendo dichiarato \(f_j \in \mathbb{Q}_{\ge 0}\) — lo scambio non migliora e la conclusione corretta è la più debole «esiste un ottimo in cui gli hub vuoti sono spenti». Sull'istanza \(f = (5,6,7)\) vale la versione forte. Come nel problema 7.2.
Secondo legame: variabile di massimo. Se il terminale \(i\) è connesso a \(j\), \(z_j \ge c_{ij}\): imposto direttamente. All'ottimo, \(z_j = \max_{i:x_{ij}=1} c_{ij}\) esattamente, perché l'obiettivo minimizza \(z_j\) e nessun altro vincolo la coinvolge. Come nel problema 7.4.
Il modello in gurobipy
mod = gp.Model("hub_max")
x = mod.addVars(n, m, vtype=GRB.BINARY, name="x")
y = mod.addVars(m, vtype=GRB.BINARY, name="y")
z = mod.addVars(m, name="z")
mod.setObjective(gp.quicksum(f[j] * y[j] for j in range(m)) + z.sum(), GRB.MINIMIZE)
mod.addConstrs((gp.quicksum(x[i, j] for j in range(m)) == 1 for i in range(n)), name="assegnamento")
mod.addConstrs((-gp.quicksum(x[i, j] for i in range(n)) + k * y[j] >= 0 for j in range(m)), name="attivazione")
mod.addConstrs((-c[i][j] * x[i, j] + z[j] >= 0 for i in range(n) for j in range(m)), name="massimo")
L'istanza
\(n=3\) terminali, \(m=3\) hub, \(k=2\):
| \(c_{ij}\) | \(j=1\) | \(j=2\) | \(j=3\) |
|---|---|---|---|
| \(i=1\) | 5 | 10 | 2 |
| \(i=2\) | 5 | 4 | 6 |
| \(i=3\) | 5 | 4 | 6 |
| \(j=1\) | \(j=2\) | \(j=3\) | |
|---|---|---|---|
| \(f_j\) | 5 | 6 | 7 |
Il modello scritto sui dati dell'istanza:
Euristica costruttiva: il bound primale
Un next-fit (bin packing): un hub alla volta, fino a \(k\) terminali —
la stessa euristica generica dello scheduling, riusata da
euristiche.py. Terminale 1 e 2 sull'hub 1 (pieno), terminale 3 sull'hub
2. Costi massimi: \(z_1=\max(5,5)=5\), \(z_2=4\). Valore \(5+6+5+4=20\):
\(z(\mathit{MILP}) \le \mathit{UB} = 20\).
Rilassamento LP e duale: il bound duale
Il duale del rilassamento lineare, una variabile per vincolo del primale:
Lo stesso duale, scritto sui dati dell'istanza:
Con \(\bar\gamma_{ij}=0\) e \(\bar\beta_j = f_j/k\) (il massimo ammesso), il vincolo su \(\alpha_i\) vale per ogni hub \(j\), non solo il più conveniente: \(\bar\alpha_i = \min_j \bar\beta_j\).
di valore \(3\cdot5/2=15/2\). Per la dualità debole, \(\mathit{LB}=15/2 \le z(\mathit{LP}) \le z(\mathit{MILP}) \le \mathit{UB}=20\).
Un tranello frequente
Il vincolo su \(\alpha_i\) vale per ogni hub \(j\): fissare \(\bar\gamma_{ij}=0\) solo per gli hub «non convenienti» non basta a liberare \(\alpha_i\) da quel vincolo. \(\alpha_i\) resta limitato dal minimo su tutti gli hub, non da uno solo.
Quello che dice il solver. \(z(\mathit{LP})=25/2\), \(z(\mathit{LP}^+)=1015/78\approx13{,}0\). \(z(\mathit{MILP})=19\), con gli hub 1 e 3 attivati (non 1 e 2): il terminale 1 da solo sull'hub 3 (il più economico per lui), i terminali 2 e 3 sull'hub 1. Gap euristica \(5{,}3\%\).
| \(UB\) | \(LB\) (duale) | \(z(\mathit{LP})\) | \(z(\mathit{LP}^+)\) | \(z(\mathit{MILP})\) | gap dell'euristica |
|---|---|---|---|---|---|
| 20 | \(15/2\) | \(25/2\) | \(1015/78\) | 19 | \(5{,}3\%\) |

Considerazioni aggiuntive
- \(x_{ij} \le y_j\) (disaggregato) è implicato dal vincolo aggregato di attivazione sui punti interi, non nel rilassamento: aggiungerlo non cambia \(z(\mathit{MILP})\) e alza \(z(\mathit{LP}^+)\) da \(1015/78\) a \(79/6\) (domanda 8.4.1).
- Con \(M_j=\max_i c_{ij}\), \(z_j \le M_j y_j\) non è una disuguaglianza valida (il modello ammette \(z_j>0\) con \(y_j=0\)), ma è un vincolo che preserva l'ottimalità: minimizzando \(z_j\), l'ottimo la annulla comunque quando \(y_j=0\).
Domande di modellazione aggiuntive
8.4.1 — Link di attivazione disaggregato
Si aggiungano al modello i link disaggregati \(x_{ij} \le y_j\). Cambia l'ottimo? Cambia il rilassamento? E che cosa succede se, invece di aggiungerli, si sostituisce con essi il vincolo aggregato?
Una variante svolta: connessione vietata
Il terminale 1 non può essere connesso all'hub 2 (un vincolo di sicurezza).
Si fissa la variabile a zero con il vincolo lineare
(un vincolo lineare). Sull'istanza l'ottimo del problema 8.4 non usa già \(x_{12}\) (il terminale 1 è connesso all'hub 3), quindi il vincolo aggiuntivo non è vincolante e l'ottimo resta \(19\).
Vietare una connessione significa togliere una colonna dal primale, quindi togliere un vincolo dal duale: \(\alpha_1\) non deve più reggere il confronto con l'hub proibito e può salire. Gli si dà un \(\gamma\) su ciascuno degli hub che gli restano — il budget di ogni hub è \(1\) e nessun altro terminale lo usa — e il bound cresce. È il caso in cui un vincolo in più nel primale migliora il certificato, invece di lasciarlo fermo.
| valore | che cos'è | |
|---|---|---|
| \(\mathit{UB}\) | \(20\) | soluzione euristica |
| \(\mathit{LB}\) | \(\frac{21}{2}\) | certificato duale costruito a mano |
| \(z(\mathit{LP})\) | \(\frac{25}{2}\) | rilassamento senza i bound |
| \(z(\mathit{LP}^+)\) | \(\frac{79}{6}\) | rilassamento con i bound |
| \(z(\mathit{MILP})\) | \(19\) | ottimo del MILP |
Codice
Script completo —
python/fam08_4_hub.py
(riproducibile con python3 python/fam08_4_hub.py dalla cartella
python/, richiama next_fit da euristiche.py). Notebook —
notebooks/fam08_4_hub.ipynb
— che si apre in Colab dal badge in cima alla pagina.
Mostra lo script completo — python/fam08_4_hub.py (249 righe)
"""Problema 8.4 -- Localizzazione di hub con costo di connessione massimo.
Due link: attivazione (aggregata, come nello scheduling 7.2) e variabile di
massimo z_j = max_i {c_ij : x_ij = 1} (stesso schema del tempo di lavorazione 7.4).
L'euristica
next-fit è quella generica di euristiche.py: gli hub sono le "macchine" (capacità
k) e i terminali i "lavori" (tempo unitario, indipendente dalla macchina).
"""
import gurobipy as gp
import pandas as pd
from gurobipy import GRB
from euristiche import matrice, next_fit
from mip import (ammissibile, dualita_forte, due_rilassamenti, frazione,
nuovo_modello, registra_bound, rilassamenti, rilassamento, risolvi,
stampa_soluzione, valuta)
from stile import intestazione, plt, salva_dati, salva_figura
from esteso import salva_modello
R = range
# ---------- 1. MODELLO E ISTANZA ----------
intestazione("4. Localizzazione di hub: attivazione e costo di connessione massimo")
c4 = [[5, 10, 2], [5, 4, 6], [5, 4, 6]] # costo di connessione terminale i -> hub j
f4 = [5, 6, 7] # costo di attivazione hub j
k4 = 2 # capacità di ciascun hub
n, m = 3, 3
salva_dati(pd.DataFrame([{"terminale": i + 1, "hub": j + 1, "c": c4[i][j]}
for i in R(n) for j in R(m)]), "fam08_4_costi")
salva_dati(pd.DataFrame({"hub": R(1, m + 1), "f": f4}), "fam08_4_attivazione")
def modello_4(c, f, k):
n, m = len(c), len(f)
mod = nuovo_modello("hub_max")
x = mod.addVars(n, m, vtype=GRB.BINARY, name="x")
y = mod.addVars(m, vtype=GRB.BINARY, name="y")
z = mod.addVars(m, name="z")
mod.setObjective(gp.quicksum(f[j] * y[j] for j in R(m)) + z.sum(), GRB.MINIMIZE)
mod.addConstrs((gp.quicksum(x[i, j] for j in R(m)) == 1 for i in R(n)), name="assegnamento")
mod.addConstrs((-gp.quicksum(x[i, j] for i in R(n)) + k * y[j] >= 0 for j in R(m)), name="attivazione")
mod.addConstrs((-c[i][j] * x[i, j] + z[j] >= 0 for i in R(n) for j in R(m)), name="massimo")
return mod, x, y, z
def duale_4(c, f, k):
"""max sum_i alpha_i; alpha_i - beta_j - c_ij gamma_ij <= 0; k beta_j <= f_j;
sum_i gamma_ij <= 1; alpha libero, beta,gamma >= 0."""
n, m = len(c), len(f)
dl = nuovo_modello("duale_hub")
alpha = dl.addVars(n, lb=-GRB.INFINITY, name="alpha")
beta = dl.addVars(m, name="beta")
gamma = dl.addVars(n, m, name="gamma")
dl.setObjective(alpha.sum(), GRB.MAXIMIZE)
dl.addConstrs((alpha[i] - beta[j] - c[i][j] * gamma[i, j] <= 0 for i in R(n) for j in R(m)), name="rc_x")
dl.addConstrs((k * beta[j] <= f[j] for j in R(m)), name="rc_y")
dl.addConstrs((gp.quicksum(gamma[i, j] for i in R(n)) <= 1 for j in R(m)), name="rc_z")
return dl
m4, x4, y4, z4 = modello_4(c4, f4, k4)
salva_modello(m4, "fam08_4_primale")
# ---------- 2. IL RILASSAMENTO LP ----------
zlp4, zlp4r, _ = rilassamenti(m4)
# ---------- 3. IL DUALE DEL RILASSAMENTO (LOWER BOUND) ----------
d4 = duale_4(c4, f4, k4)
salva_modello(d4, "fam08_4_duale")
beta_mano = [f4[j] / k4 for j in R(m)] # il massimo ammesso da k*beta_j <= f_j
alpha_mano = min(beta_mano) # deve reggere per OGNI hub j, non solo il più conveniente
mano = {f"gamma[{i},{j}]": 0.0 for i in R(n) for j in R(m)}
mano.update({f"beta[{j}]": beta_mano[j] for j in R(m)})
mano.update({f"alpha[{i}]": alpha_mano for i in R(n)})
lb4, viol = valuta(d4, mano)
assert viol <= 1e-9, viol
print(f"Soluzione duale a mano: gamma = 0, beta_j = f_j/k = {[frazione(b) for b in beta_mano]}, "
f"alpha_i = min_j beta_j = {frazione(alpha_mano)} -> lb = {frazione(lb4)}")
dualita_forte(d4, zlp4)
# ---------- 4. EURISTICA COSTRUTTIVA (UPPER BOUND) ----------
print("Euristica next-fit: si riempiono gli hub uno alla volta fino a k terminali,")
print("poi si passa al successivo (stessa euristica generica dei problemi di scheduling).")
t4 = matrice([1] * n, m) # tempo unitario per ogni terminale, indipendente dall'hub
a4 = [k4] * m # capacità residua di ciascun hub
esito4 = next_fit(t4, a4)
esito4.traccia.stampa()
assert esito4.ok
ye = esito4.y
ze = [0.0] * m
for j in R(m):
if ye[j]:
ze[j] = max(c4[i][j] for i in R(n) if esito4.x.get((i, j)) == 1)
ub4 = sum(f4[j] * ye[j] for j in R(m)) + sum(ze)
print(f" y = {ye}, z = {ze} -> ub = {frazione(ub4)}")
# ---------- 5. SOLUZIONE OTTIMA DEL MILP ----------
z4v = risolvi(m4)
print("Soluzione ottima del MILP:")
stampa_soluzione(m4, solo_non_nulle=True)
riga = registra_bound("4 hub", ub4, lb4, zlp4, zlp4r, z4v, senso="min")
salva_dati(pd.DataFrame([riga]), "fam08_4_bound")
# ---------- 6. DOMANDE DI MODELLAZIONE AGGIUNTIVE ----------
varianti = {}
def variante(nome, mod):
z = risolvi(mod)
print(f" {nome:70s} z = {frazione(z)}")
return z
# 4a: si AGGIUNGONO i link disaggregati x_ij <= y_j al vincolo aggregato
mod, x, y, z = modello_4(c4, f4, k4)
mod.addConstrs((x[i, j] <= y[j] for i in R(n) for j in R(m)), name="attivazione_disaggregata")
varianti["4a"] = variante("4a. Link disaggregati AGGIUNTI a quello aggregato (x_ij <= y_j)", mod)
zlp_4a, _, _ = rilassamento(mod, rafforzato=True)
zlp_base, _, _ = rilassamento(modello_4(c4, f4, k4)[0], rafforzato=True)
print(f" rilassamento: z(LP+) passa da {frazione(zlp_base)} a {frazione(zlp_4a)}: i link")
print(" disaggregati sono disuguaglianze valide implicate dal vincolo aggregato sui")
print(" punti interi, ma non dal rilassamento, e lo rafforzano.")
# 4a-bis: il tranello. Se si SOSTITUISCE il vincolo aggregato con i soli link
# disaggregati, si perde anche la capacita' k: il modello non e' piu' quello del
# problema. Va tenuta esplicitamente, oppure si parla di aggiunta e non di sostituzione.
mod, x, y, z = modello_4(c4, f4, k4)
mod.update()
mod.remove([cc for cc in mod.getConstrs() if cc.ConstrName.startswith("attivazione")])
mod.update()
mod.addConstrs((x[i, j] <= y[j] for i in R(n) for j in R(m)), name="solo_disaggregato")
varianti["4a_senza_capacita"] = variante(
"4a'. SOSTITUENDO l'aggregato con i soli link disaggregati (capacita' persa)", mod)
mod, x, y, z = modello_4(c4, f4, k4)
mod.update()
mod.remove([cc for cc in mod.getConstrs() if cc.ConstrName.startswith("attivazione")])
mod.update()
mod.addConstrs((x[i, j] <= y[j] for i in R(n) for j in R(m)), name="solo_disaggregato")
mod.addConstrs((gp.quicksum(x[i, j] for i in R(n)) <= k4 for j in R(m)), name="capacita")
varianti["4a_con_capacita"] = variante(
"4a''. Sostituzione corretta: link disaggregati + capacita' separata", mod)
assert varianti["4a_senza_capacita"] < varianti["4a"], "senza capacita' l'ottimo scende"
assert varianti["4a_con_capacita"] == varianti["4a"], "con la capacita' l'ottimo non cambia"
# 4b: il terminale 1 non può essere connesso all'hub 2
mod, x, y, z = modello_4(c4, f4, k4)
mod.addConstr(x[0, 1] == 0, name="terminale1_non_hub2")
varianti["4b"] = variante("4b. Il terminale 1 non può connettersi all'hub 2 (x_12 = 0)", mod)
salva_dati(pd.DataFrame({"variante": list(varianti), "z": list(varianti.values())}), "fam08_4_varianti")
# ---------- 7. IL SANDWICH SULLA VARIANTE 4b ----------
intestazione("4b. Il sandwich sulla variante: il terminale 1 non puo' usare l'hub 2")
VIETATA = (0, 1) # (terminale, hub) proibito
def modello_4b(c, f, k, vietata=VIETATA):
mod_, xx, yy, zz = modello_4(c, f, k)
mod_.addConstr(xx[vietata] == 0, name="connessione_vietata")
return mod_, xx, yy, zz
def duale_4b(c, f, k, vietata=VIETATA):
"""Vietare una connessione vuol dire togliere una colonna dal primale,
quindi togliere il vincolo corrispondente dal duale: alpha_1 non deve piu'
reggere il confronto con l'hub 2, e puo' salire."""
nn, mm = len(c), len(f)
dl = nuovo_modello("duale_hub_4b")
alpha = dl.addVars(nn, lb=-GRB.INFINITY, name="alpha")
beta = dl.addVars(mm, name="beta")
gamma = dl.addVars(nn, mm, name="gamma")
dl.setObjective(alpha.sum(), GRB.MAXIMIZE)
dl.addConstrs((alpha[i] - beta[j] - c[i][j] * gamma[i, j] <= 0
for i in R(nn) for j in R(mm) if (i, j) != vietata), name="rc_x")
dl.addConstrs((k * beta[j] <= f[j] for j in R(mm)), name="rc_y")
dl.addConstrs((gp.quicksum(gamma[i, j] for i in R(nn)) <= 1 for j in R(mm)), name="rc_z")
return dl
m4b, x4b, y4b, z4b = modello_4b(c4, f4, k4)
salva_modello(m4b, "fam08_4b_primale")
# -- euristica ammissibile: la base, spostando il terminale vietato --
print("Euristica costruttiva: si parte dalla soluzione del problema base e, se il terminale")
print("vietato sta sull'hub proibito, lo si sposta sull'hub capiente di costo piu' basso.")
ass_4b = {i: j for (i, j), v in esito4.x.items() if v == 1}
ti, hj = VIETATA
if ass_4b.get(ti) == hj:
carichi = {j: sum(1 for q, jj in ass_4b.items() if jj == j and q != ti) for j in R(m)}
nuovo_hub = min((j for j in R(m) if j != hj and carichi[j] < k4), key=lambda j: c4[ti][j])
print(f" il terminale {ti + 1} era sull'hub {hj + 1}: si sposta sull'hub {nuovo_hub + 1}")
ass_4b[ti] = nuovo_hub
else:
print(f" il terminale {ti + 1} non era sull'hub {hj + 1}: niente da riparare")
y_4b = [1 if any(j == q for q in ass_4b.values()) else 0 for j in R(m)]
z_4b = [max([c4[i][j] for i, q in ass_4b.items() if q == j] + [0.0]) for j in R(m)]
ub4b = sum(f4[j] * y_4b[j] for j in R(m)) + sum(z_4b)
sol_4b = ({f"x[{i},{j}]": (1 if ass_4b[i] == j else 0) for i in R(n) for j in R(m)}
| {f"y[{j}]": y_4b[j] for j in R(m)} | {f"z[{j}]": z_4b[j] for j in R(m)})
assert ammissibile(m4b, sol_4b), "la soluzione euristica della variante deve essere ammissibile"
print(f" ub = {frazione(ub4b)}")
# -- certificato duale: senza quella colonna, alpha_1 sale --
d4b = duale_4b(c4, f4, k4)
salva_modello(d4b, "fam08_4b_duale")
beta_4b = {j: f4[j] / k4 for j in R(m)}
# il terminale vietato puo' spendere un gamma su ogni hub che gli resta: ogni hub
# ha budget 1 e nessun altro terminale lo usa nella ricetta
mano_4b = {f"beta[{j}]": beta_4b[j] for j in R(m)}
mano_4b.update({f"gamma[{i},{j}]": 0.0 for i in R(n) for j in R(m)})
mano_4b.update({f"gamma[{ti},{j}]": 1.0 for j in R(m) if j != hj})
alpha_vietato = min(beta_4b[j] + c4[ti][j] for j in R(m) if j != hj)
alpha_altri = min(beta_4b.values())
mano_4b.update({f"alpha[{i}]": (alpha_vietato if i == ti else alpha_altri) for i in R(n)})
lb4b, viol_4b = valuta(d4b, mano_4b)
assert viol_4b <= 1e-9, viol_4b
print("Soluzione duale a mano: beta_j = f_j/k come nel problema base. Per i terminali")
print(" liberi alpha_i = min_j beta_j. Il terminale vietato, invece, non deve piu' reggere")
print(" il confronto con l'hub proibito: gli si da' un gamma su ciascuno degli hub che gli")
print(" restano (il budget di ogni hub e' 1 e nessun altro lo usa), e allora")
print(f" alpha_{ti + 1} = min_(j != {hj + 1}) (beta_j + c_{ti + 1}j) = {frazione(alpha_vietato)}")
print(f" invece di {frazione(alpha_altri)}.")
print(f" -> lb = {frazione(lb4b)} (la ricetta del problema base darebbe "
f"{frazione(n * alpha_altri)})")
zlp4b, zlp4br, _ = due_rilassamenti(m4b, d4b)
z4b_val = risolvi(m4b)
riga_4b = registra_bound("4b connessione vietata", ub4b, lb4b, zlp4b, zlp4br, z4b_val)
salva_dati(pd.DataFrame([riga_4b]), "fam08_4b_bound")
assert lb4b <= zlp4b <= z4b_val <= ub4b + 1e-9
# ---------- 8. FIGURE ----------
fig, ax = plt.subplots(figsize=(6.4, 3.2))
colori = ["#16324A", "#0E7490", "#CA6F1E"]
for j in R(m):
if y4[j].X > 0.5:
assegnati = [i + 1 for i in R(n) if x4[i, j].X > 0.5]
ax.barh(j, z4[j].X, color=colori[j % 3], label=f"hub {j + 1}: terminali {assegnati}")
ax.set_yticks(R(m))
ax.set_yticklabels([f"hub {j + 1}" for j in R(m)])
ax.set_xlabel("costo di connessione massimo $z_j$")
ax.set_title(f"Soluzione ottima (z = {frazione(z4v)})")
ax.legend(fontsize=7, loc="lower right")
salva_figura(fig, "cap08_hub_ottimo")
print("Fine.")