Skip to content

EX 4 — Hub-and-spoke

Class: BIP · Links: covering · Script: python/ex04_hub.py
Difficulty: ★☆☆☆☆ · Time: 20–30 min

Open in Colab

One of the fifteen numerical models, of the location and covering family.

EX 4

An airline organises its network with a hub-and-spoke system: a hub serves directly every city within \(1000\) miles. The company operates on eight cities; for each of them, the cities within \(1000\) miles are:

City Cities within 1000 miles
Atlanta Atlanta, Chicago, Houston, New York
Chicago Atlanta, Chicago, New York
Denver Denver, Los Angeles
Houston Atlanta, Houston
Los Angeles Denver, Los Angeles, San Francisco
New York Atlanta, Chicago, New York
San Francisco Los Angeles, San Francisco, Seattle
Seattle San Francisco, Seattle

The minimum number of hubs is wanted, such that every city is within \(1000\) miles of at least one hub.

Model

With \(y_j = 1\) if city \(j\) becomes a hub (\(8\) binaries) and \(S_i\) the set of cities covering city \(i\):

\[ \begin{array}{rrrrrrrrr c l} \min & y_1 & +y_2 & +y_3 & +y_4 & +y_5 & +y_6 & +y_7 & +y_8 & & \\ \text{subject to} & y_1 & +y_2 & & +y_4 & & +y_6 & & & \ge & 1\\ & y_1 & +y_2 & & & & +y_6 & & & \ge & 1\\ & & & y_3 & & +y_5 & & & & \ge & 1\\ & y_1 & & & +y_4 & & & & & \ge & 1\\ & & & y_3 & & +y_5 & & +y_7 & & \ge & 1\\ & y_1 & +y_2 & & & & +y_6 & & & \ge & 1\\ & & & & & y_5 & & +y_7 & +y_8 & \ge & 1\\ & & & & & & & y_7 & +y_8 & \ge & 1\\ & y_1, & y_2, & y_3, & y_4, & y_5, & y_6, & y_7, & y_8 & \in & \{0, 1\} \end{array} \]

With \(n = 8\) cities and \(m = 8\) candidate hubs, a pure set covering: \(n\) covering constraints, all costs equal to \(1\). The relation "within \(1000\) miles" is symmetric, so \(j \in S_i \iff i \in S_j\): the matrix of the model is symmetric.

The model of the instance, written out in full:

Constructive heuristic: the primal bound

Constructive covering heuristic, with the criterion "cost per newly covered city".

  • Step 1. Ratios \(1/4\) for Atlanta, \(1/3\) for Chicago, New York and San Francisco, \(1/2\) for the others: Atlanta is chosen, covering Atlanta, Chicago, Houston and New York.
  • Step 2. Denver, Los Angeles, San Francisco and Seattle remain; ratios \(1/2\), \(1/3\), \(1/3\), \(1/2\): Los Angeles is chosen, covering Denver, Los Angeles and San Francisco.
  • Step 3. Seattle remains; ratio \(1/1\) for San Francisco and for Seattle: San Francisco is chosen.

Three hubs, so \(\mathit{UB} = 3\).

LP relaxation and dual: the dual bound

Relaxing to \(y_j \ge 0\), with \(\pi_i \ge 0\) for every covering constraint:

\[ \begin{array}{rrrrrrrrr c l} \max & \pi_1 & +\pi_2 & +\pi_3 & +\pi_4 & +\pi_5 & +\pi_6 & +\pi_7 & +\pi_8 & & \\ \text{subject to} & \pi_1 & +\pi_2 & & +\pi_4 & & +\pi_6 & & & \le & 1\\ & \pi_1 & +\pi_2 & & & & +\pi_6 & & & \le & 1\\ & & & \pi_3 & & +\pi_5 & & & & \le & 1\\ & \pi_1 & & & +\pi_4 & & & & & \le & 1\\ & & & \pi_3 & & +\pi_5 & & +\pi_7 & & \le & 1\\ & \pi_1 & +\pi_2 & & & & +\pi_6 & & & \le & 1\\ & & & & & \pi_5 & & +\pi_7 & +\pi_8 & \le & 1\\ & & & & & & & \pi_7 & +\pi_8 & \le & 1\\ & \pi_1, & \pi_2, & \pi_3, & \pi_4, & \pi_5, & \pi_6, & \pi_7, & \pi_8 & \ge & 0 \end{array} \]

Dual constructive heuristic on the cities, in order: \(\pi_i\) is raised until the first opposing dual constraint becomes tight, and the residuals are updated.

  • Atlanta is covered by four hubs, all with residual \(1\): \(\bar \pi_1 = 1\), and the residuals of Atlanta, Chicago, Houston and New York drop to zero.
  • Chicago, Houston and New York have all their hubs at residual zero: \(\bar\pi = 0\).
  • Denver is covered by Denver and Los Angeles, both with residual \(1\): \(\bar \pi_3 = 1\).
  • Los Angeles and San Francisco have one hub at residual zero: \(\bar\pi = 0\).
  • Seattle is covered by San Francisco and Seattle, both with residual \(1\): \(\bar \pi_8 = 1\).

\(\mathit{LB} = 3\).

What this bound proves, in words

The three cities that received \(\pi_i = 1\) — Atlanta, Denver, Seattle — are pairwise "far apart": no city can serve as a hub for two of them. Hence at least three hubs are needed. The dual has formalised an argument one could tell without writing it: that is the point of the constructive heuristic recipe.

\(UB\) (constructive heuristic) \(LB\) (dual by hand) \(z(\mathit{LP})\) \(z(\mathit{MILP})\) heuristic gap
3 3 3 3 \(0.0\%\)

The optimum is \(3\) hubs in Atlanta, Los Angeles and San Francisco — the same solution as the constructive heuristic. The two hand-built bounds coincide: optimality is proved without the solver.

The chosen hubs

Code

The complete script is python/ex04_hub.py; the notebook is notebooks/ex04_hub.ipynb.

Show the complete script — python/ex04_hub.py (107 lines)
"""EX 4 -- Hub-and-spoke: the minimum number of hubs covering eight cities (family 8).

A pure set covering with all costs equal to 1: the number of hubs is minimised.
The dual is the fractional packing of the customers, and the dual constructive heuristic on the
cities finds here a bound that coincides with the optimum.
"""
import gurobipy as gp
import pandas as pd
from gurobipy import GRB

from euristiche import euristica_copertura
from mip import (ammissibile, due_rilassamenti, frazione, nuovo_modello, registra_bound,
                 risolvi, valuta)
from stile import intestazione, plt, salva_dati, salva_figura
from esteso import salva_modello

R = range

# ---------- 1. MODEL AND INSTANCE ----------
intestazione("EX 4. Hub-and-spoke: the fewest hubs within 1000 miles of every city")
CITTA = ["Atlanta", "Chicago", "Denver", "Houston", "Los Angeles", "New York",
         "San Francisco", "Seattle"]
copre = [[0, 1, 3, 5], [0, 1, 5], [2, 4], [0, 3], [2, 4, 6], [0, 1, 5], [4, 6, 7], [6, 7]]
n = len(CITTA)
salva_dati(pd.DataFrame([{"city": CITTA[i], "covered_by": ", ".join(CITTA[j] for j in copre[i])}
                         for i in R(n)]), "ex04_copertura")


def modello(copre):
    n = len(copre)
    m = nuovo_modello("hub_spoke")
    y = m.addVars(n, vtype=GRB.BINARY, name="y")
    m.setObjective(y.sum(), GRB.MINIMIZE)
    m.addConstrs((gp.quicksum(y[j] for j in copre[i]) >= 1 for i in R(n)), name="cover")
    return m, y


def duale(copre):
    """max sum_i u_i;  sum_{i : j covers i} u_i <= 1 for every j;  u >= 0."""
    n = len(copre)
    d = nuovo_modello("dual_hub_spoke")
    u = d.addVars(n, name="pi")   # price of city i
    d.setObjective(u.sum(), GRB.MAXIMIZE)
    d.addConstrs((gp.quicksum(u[i] for i in R(n) if j in copre[i]) <= 1 for j in R(n)),
                 name="rc")
    return d


m, y = modello(copre)
salva_modello(m, "ex04_primale")

# ---------- 2. CONSTRUCTIVE HEURISTIC (UPPER BOUND) ----------
e = euristica_copertura([1] * n, copre)
e.traccia.stampa()
ub = e.valore
scelti = [j for j in R(n) if e.y[j]]
assert ammissibile(m, {f"y[{j}]": e.y[j] for j in R(n)})
print("  Heuristic solution: hubs in " + ", ".join(CITTA[j] for j in scelti)
      + f"   ub = {frazione(ub)}")

# ---------- 3. LP RELAXATION AND DUAL (LOWER BOUND) ----------
d = duale(copre)
salva_modello(d, "ex04_duale")
residuo = [1.0] * n
mano = {}
for i in R(n):
    incremento = min(residuo[j] for j in copre[i])
    mano[f"pi[{i}]"] = incremento
    for j in copre[i]:
        residuo[j] -= incremento
    print(f"  City {i + 1} ({CITTA[i]}): residuals of the hubs covering it "
          + ", ".join(f"{CITTA[j]} = {frazione(residuo[j] + incremento)}" for j in copre[i])
          + f"; the smallest is {frazione(incremento)}, so u_{i + 1} = {frazione(incremento)}")
lb, viol = valuta(d, mano)
assert viol <= 1e-9, viol
print(f"  Dual by hand (constructive heuristic on the cities): lb = {frazione(lb)}")
zlp, zlpr, pi = due_rilassamenti(m, d)

# ---------- 4. MILP OPTIMUM AND BOUND TABLE ----------
z = risolvi(m)
ott = [j for j in R(n) if y[j].X > 0.5]
print(f"  Optimal solution: {len(ott)} hubs in " + ", ".join(CITTA[j] for j in ott))
for i in R(n):
    assert [CITTA[j] for j in copre[i] if j in ott], CITTA[i]
print("  Every city is covered by at least one chosen hub: checked for all eight.")
riga = registra_bound("EX 4 hub-and-spoke", ub, lb, zlp, zlpr, z)
salva_dati(pd.DataFrame([riga]), "ex04_bound")
assert lb <= zlp <= z <= ub + 1e-9
if abs(lb - z) < 1e-9:
    print("  Here the hand-built dual coincides with the integer optimum: the bound closes")
    print("  the problem without the solver (three pairwise 'distant' cities are enough to")
    print("  prove that two hubs cannot suffice).")

# ---------- 5. FIGURE ----------
fig, ax = plt.subplots(figsize=(7.2, 3.4))
altezza = [len([i for i in R(n) if j in copre[i]]) for j in R(n)]
colori = ["#0E7490" if j in ott else "#F4F6F7" for j in R(n)]
ax.bar(R(n), altezza, color=colori, edgecolor="#7F8C8D", lw=0.8)
for j in R(n):
    ax.annotate(str(altezza[j]), (j, altezza[j]), ha="center", va="bottom", fontsize=9,
                color="#16324A")
ax.set_xticks(R(n))
ax.set_xticklabels([c.replace(" ", "\n") for c in CITTA], fontsize=7.5)
ax.set_ylabel("cities covered if chosen as hub")
ax.set_title(f"EX 4: the {len(ott)} chosen hubs (teal) and how many cities each site covers")
salva_figura(fig, "ex04_ottimo")
print("Done.")