Kapitel 6: Gemischt-ganzzahlige Optimierung — Diskrete Entscheidungen und Branch-and-Bound
📌 Kapitel auf einen Blick
Worum geht es? Um Entscheidungen, die nicht teilbar sind: ganze Maschinen, Ja/Nein, „höchstens fünf davon“. Wir zeigen mit einem konkreten Gegenbeispiel, warum Runden falsch ist, und lernen die Modellierungstricks, mit denen sich logische Regeln in lineare Ungleichungen übersetzen lassen.
Voraussetzungen: Kapitel 5 (LP, Relaxation, Schranken).
Danach können Sie: Fixkosten, Entweder-Oder, Wenn-Dann und Kardinalitätsgrenzen modellieren, ein MILP mit CP-SAT oder HiGHS lösen, den MIP-Gap als Zusage an den Auftraggeber lesen — und ein Ergebnis erkennen, das nur deshalb gut aussieht, weil das Big-M zu groß gewählt war.
Zeitbedarf: ca. 6,5 Stunden.
Programme:
Runden_Gegenbeispiel.py
Rucksack.py
MILP_Portfolio_Fixgebuehren.py
Solverstatus_und_Gap.py
Warmstart_Effekt.py
Big_M_Falle.pyNotebook: milp.ipynb
In Google Colab öffnen
6.1 In 5 Minuten gelöst
🚀 In 5 Minuten gelöst: Welches Lager eröffnen wir?
Ein Händler muss 100 Paletten pro Woche ausliefern und kann dafür bis zu drei Lager eröffnen. Jedes verursacht Fixkosten, egal wie viel darüber läuft.
Lager Fixkosten/Woche Kapazität Kosten je Palette Nord 180 € 40 6,00 € Mitte 260 € 70 4,50 € Süd 300 € 90 3,80 € import numpy as np from scipy.optimize import milp, LinearConstraint, Bounds fix = np.array([180.0, 260.0, 300.0]) # Fixkosten je Lager kap = np.array([40.0, 70.0, 90.0]) # Kapazitaet je Lager var = np.array([6.0, 4.5, 3.8]) # variable Kosten je Palette A = np.zeros((4, 6)) # Variablen: 3x Menge, 3x Schalter A[0, :3] = 1.0 # Summe der Mengen = 100 for i in range(3): A[i + 1, i], A[i + 1, 3 + i] = 1.0, -kap[i] # Menge <= Kapazitaet * Schalter nb = LinearConstraint(A, [100, -np.inf, -np.inf, -np.inf], [100, 0, 0, 0]) grenzen = Bounds([0] * 6, list(kap) + [1, 1, 1]) for name, ganzzahlig in [("LP-Relaxation", [0] * 6), ("MILP", [0, 0, 0, 1, 1, 1])]: r = milp(c=np.concatenate([var, fix]), constraints=[nb], integrality=ganzzahlig, bounds=grenzen) print(f"{name:14} {r.fun:7.2f} EUR Mengen {np.round(r.x[:3], 1)} " f"Schalter {np.round(r.x[3:], 3)}")Ausgabe:
LP-Relaxation 724.14 EUR Mengen [ 0. 10. 90.] Schalter [-0. 0.143 1. ] MILP 882.00 EUR Mengen [10. 0. 90.] Schalter [ 1. -0. 1. ]
Sehen Sie sich die Schalter der ersten Zeile an: 0,143. Die LP-Relaxation eröffnet Lager Mitte zu 14,3 % — und zahlt dafür nur 14,3 % der Fixkosten. So etwas gibt es in der Wirklichkeit nicht. Ein Lager ist offen oder zu.
Und jetzt der Punkt, um den es in diesem ganzen Kapitel geht — was passiert, wenn man diese 0,143 rundet?
| Vorgehen | Ergebnis |
|---|---|
| Abrunden auf y = (0, 0, 1) | Nur Süd offen: 90 Paletten Kapazität für 100 Paletten Bedarf → unzulässig |
| Aufrunden auf y = (0, 1, 1) | Zulässig, aber 947 € — 65 € teurer als nötig |
| MILP-Optimum y = (1, 0, 1) | 882 € — und es öffnet Nord, ein Lager, das die Relaxation gar nicht vorgeschlagen hatte |
Runden führt hier also in beide Richtungen in die Irre: einmal in die Unzulässigkeit, einmal in unnötige Kosten. Und der entscheidende Punkt ist der dritte: Die ganzzahlige Lösung ist strukturell anders. Sie öffnet ein anderes Lager. Keine noch so geschickte Rundung der LP-Lösung hätte darauf kommen können.
🎯 Merksatz Die LP-Relaxation ist keine ungefähre Antwort, die man nur noch glattziehen muss. Sie ist eine Antwort auf eine andere Frage — nämlich die, bei der man Lager auch zu 14,3 % eröffnen darf. Ihr Wert liegt nicht in ihrer Lösung, sondern in ihrer Schranke: Weniger als 724,14 € kann die richtige Antwort nicht kosten.
Genau diese Schranke ist der Hebel, mit dem Branch-and-Bound arbeitet. Wie, zeigt der Abschnitt Abschnitt 6.4.
6.2 Lernziele
Nach diesem Kapitel können Sie …
- … an einem Zahlenbeispiel belegen, warum Runden einer LP-Lösung scheitert.
- … den Ablauf von Branch-and-Bound erklären und einen kleinen Suchbaum von Hand zeichnen.
- … die vier klassischen Big-M-Muster anwenden (Fixkosten, Entweder-Oder, Wenn-Dann, Kardinalität).
- … begründen, warum M so klein wie möglich gewählt werden muss.
- … ein Portfolioproblem mit Ordergebühren und Höchstzahl an Positionen lösen.
- … Incumbent, Schranke und MIP-Gap unterscheiden und aus einem abgebrochenen Solverlauf eine belastbare Aussage ableiten.
- … alle Solver-Statusfälle explizit behandeln, statt
OPTIMALvorauszusetzen. - … einen Warm-Start setzen — und messen, ob er auf Ihrer Problemklasse überhaupt etwas bringt.
6.3 Warum Runden fundamental scheitert
In der realen Welt sind viele Entscheidungen nicht teilbar. Man kann nicht 0,47 Flugzeuge kaufen, keine halbe Lagerhalle bauen, keinen Mitarbeiter zu 38 % einstellen. Noch wichtiger: Logische Schalter — „Wenn Fabrik A gebaut wird, muss auch Lager B gebaut werden“ — brauchen diskrete Zustände.
Mixed-Integer Linear Programming (MILP), deutsch gemischt-ganzzahlige lineare Optimierung, erweitert das LP um ganzzahlige (\mathbb{Z}) und binäre (\{0,1\}) Variablen.
Der verlockende Gedanke lautet: „Wir lösen das Problem kontinuierlich und runden.“ Eine verbreitete, aber unbelegte Behauptung dazu lautet, man verliere dadurch „oft 20–50 % des Gewinns“. Rechnen wir es nach, statt es zu behaupten.
✏️ Handrechnung 6.1: Ein Gegenbeispiel, das Sie im Kopf prüfen können
\max\ Z = x_1 + x_2 \quad\text{u. d. N.}\quad 2x_1 + 2x_2 \le 3,\quad x_1,x_2 \in \{0,1,2,\dots\}
LP-Relaxation (Ganzzahligkeit weggelassen): Jede Kombination mit x_1 + x_2 = 1{,}5 ist optimal, z. B. x_1 = x_2 = 0{,}75 mit Z_{LP} = 1{,}5.
Aufrunden auf (1, 1): 2\cdot1 + 2\cdot1 = 4 > 3 → unzulässig. Abrunden auf (0, 0): zulässig, aber Z = 0. Wahres ganzzahliges Optimum: (1, 0) oder (0, 1) mit Z_{IP} = 1.
Das Abrunden verliert hier 100 % des erreichbaren Werts. Das ist kein exotischer Sonderfall, sondern typisch, sobald die Zahlen klein sind — und bei Ja/Nein-Entscheidungen sind sie immer klein.
Die drei Gründe systematisch:
- Verletzung von Nebenbedingungen. Aufrunden überschreitet Kapazitätsgrenzen — die Lösung wird unzulässig.
- Suboptimalität. Abrunden verschenkt Kapazität. Der Verlust ist umso größer, je kleiner die Zahlen sind.
- Distanz im Raum. In hohen Dimensionen liegt der beste ganzzahlige Punkt oft weit entfernt von der LP-Lösung — er ist durch Runden gar nicht erreichbar.
Das folgende Programm quantifiziert den Effekt über viele Zufallsinstanzen.
#!/usr/bin/env python3
# Runden_Gegenbeispiel.py
"""
Kapitel MILP: Wie schlecht ist Runden wirklich?
Ersetzt die unbelegte Behauptung "20-50 % Verlust" durch eine Messung ueber
viele Zufallsinstanzen.
"""
import numpy as np
from scipy.optimize import linprog
def erzeuge_instanz(n, m, rng):
"""Zufaelliges Rucksack-aehnliches MILP mit kleinen Zahlen."""
c = rng.integers(3, 20, size=n).astype(float) # Ertraege
A = rng.integers(1, 9, size=(m, n)).astype(float) # Verbraeuche
b = (A.sum(axis=1) * rng.uniform(0.25, 0.45)).round() # knappe Kapazitaeten
return c, A, b
def loese_lp(c, A, b, ganzzahlig=False):
"""LP-Relaxation oder exaktes MILP ueber HiGHS."""
n = len(c)
ergebnis = linprog(
c=-c, A_ub=A, b_ub=b, bounds=[(0, None)] * n,
integrality=np.ones(n) if ganzzahlig else None,
method="highs")
return (-ergebnis.fun, ergebnis.x) if ergebnis.success else (None, None)
def abrunden_und_reparieren(x_lp, c, A, b):
"""Naive Strategie: abrunden, dann gierig auffuellen, solange zulaessig."""
x = np.floor(x_lp + 1e-9)
verbessert = True
while verbessert: # gierig auffuellen
verbessert = False
for j in np.argsort(-c): # bester Ertrag zuerst
kandidat = x.copy()
kandidat[j] += 1
if np.all(A @ kandidat <= b + 1e-9):
x = kandidat
verbessert = True
break
return c @ x, x
if __name__ == "__main__":
rng = np.random.default_rng(2026)
print("=" * 82)
print(" WIE TEUER IST RUNDEN? (200 Zufallsinstanzen je Groesse)")
print("=" * 82)
print(f"{'n x m':>8} | {'Aufrunden unzul.':>17} | {'Abrunden: mittl.':>17} | "
f"{'schlimmster':>12} | {'gierig':>8}")
print(f"{'':>8} | {'':>17} | {'Verlust':>17} | {'Fall':>12} | {'Verlust':>8}")
print("-" * 82)
for n, m in [(5, 2), (10, 3), (20, 5), (40, 8)]:
unzulaessig = 0
verluste_ab, verluste_gierig = [], []
for _ in range(200):
c, A, b = erzeuge_instanz(n, m, rng)
z_lp, x_lp = loese_lp(c, A, b, ganzzahlig=False)
z_ip, _ = loese_lp(c, A, b, ganzzahlig=True)
if z_lp is None or z_ip is None or z_ip <= 0:
continue
# Variante 1: aufrunden
x_auf = np.ceil(x_lp - 1e-9)
if np.any(A @ x_auf > b + 1e-9):
unzulaessig += 1
# Variante 2: abrunden
x_ab = np.floor(x_lp + 1e-9)
verluste_ab.append(1.0 - (c @ x_ab) / z_ip)
# Variante 3: abrunden + gierig auffuellen
z_gierig, _ = abrunden_und_reparieren(x_lp, c, A, b)
verluste_gierig.append(1.0 - z_gierig / z_ip)
print(f"{n:>3} x {m:<3} | {unzulaessig/2:>15.1f} % | "
f"{np.mean(verluste_ab)*100:>15.1f} % | "
f"{np.max(verluste_ab)*100:>10.1f} % | "
f"{np.mean(verluste_gierig)*100:>6.1f} %")
print("-" * 82)
print("Lesart: 'Aufrunden unzul.' = Anteil der Faelle, in denen die aufgerundete")
print("Loesung eine Nebenbedingung verletzt. 'Verlust' = Abstand zum exakten Optimum.")
print("=" * 82)Erwartete Ausgabe:
==================================================================================
WIE TEUER IST RUNDEN? (200 Zufallsinstanzen je Groesse)
==================================================================================
n x m | Aufrunden unzul. | Abrunden: mittl. | schlimmster | gierig
| | Verlust | Fall | Verlust
----------------------------------------------------------------------------------
5 x 2 | 82.0 % | 17.9 % | 100.0 % | 3.1 %
10 x 3 | 90.0 % | 17.2 % | 100.0 % | 3.0 %
20 x 5 | 97.5 % | 17.8 % | 50.0 % | 2.9 %
40 x 8 | 100.0 % | 15.6 % | 35.7 % | 3.6 %
----------------------------------------------------------------------------------
Lesart: 'Aufrunden unzul.' = Anteil der Faelle, in denen die aufgerundete
Loesung eine Nebenbedingung verletzt. 'Verlust' = Abstand zum exakten Optimum.
==================================================================================
Was die Zahlen sagen:
- Aufrunden ist fast immer unzulässig (82 % bei kleinen, 100 % bei größeren Instanzen). Es ist keine Strategie, sondern ein Fehler — und zwar einer, der mit der Problemgröße zunimmt, weil mehr Variablen mehr Gelegenheiten bieten, eine Kapazität zu sprengen.
- Abrunden verliert im Mittel 16–18 %, in den schlimmsten gemessenen Fällen 100 % (dann bleibt das Ergebnis bei null). Die eingangs zitierte Behauptung „20–50 %“ lag zu hoch; der gemessene Mittelwert liegt darunter, die Extremfälle darüber. Beides ist erst durch die Messung sichtbar.
- Abrunden plus gieriges Auffüllen ist deutlich besser (rund 3 % Verlust) — aber weiterhin ohne jede Garantie und mit zusätzlichem Implementierungsaufwand. Ein exakter MILP-Solver liefert die Optimalität gratis und dazu einen Beweis dafür.
⚠️ Zur Ehrlichkeit von Messungen Diese Zahlen gelten für diese Instanzfamilie (kleine ganzzahlige Erträge, knappe Kapazitäten). Bei großen Stückzahlen — etwa 4 700 statt 4,7 produzierten Einheiten — ist der relative Rundungsfehler viel kleiner, und Runden wird zu einer brauchbaren Heuristik. Die Faustregel lautet deshalb nicht „Runden ist immer schlecht“, sondern: Je kleiner die Zahlen und je knapper die Kapazitäten, desto teurer das Runden. Bei Ja/Nein-Variablen — dem häufigsten Fall — sind die Zahlen maximal klein, und Runden ist sinnlos.
🎯 Merksatz Runden ist kein Näherungsverfahren mit kontrollierbarem Fehler, sondern ein Verfahren ohne jede Garantie. Wenn Ganzzahligkeit zum Problem gehört, gehört sie ins Modell.
6.4 Branch-and-Bound
MILP-Probleme sind NP-schwer. Der Standardansatz moderner Solver (HiGHS, SCIP, Gurobi) heißt Branch-and-Cut — Branch-and-Bound plus Schnittebenen.
Die drei Phasen
- LP-Relaxation. Ganzzahligkeit vorübergehend weglassen und das LP lösen. Der Wert ist eine obere Schranke (bei Maximierung): Besser als das kann kein ganzzahliger Punkt sein, denn jeder ganzzahlige Punkt ist auch ein zulässiger LP-Punkt.
- Branching. Hat eine ganzzahlige Variable einen gebrochenen Wert, etwa x_1 = 2{,}7, wird das Problem in zwei disjunkte Teilprobleme zerlegt: \text{Ast 1: } x_1 \le 2 \qquad\text{und}\qquad \text{Ast 2: } x_1 \ge 3 Der Wert 2{,}7 selbst wird dadurch ausgeschlossen — kein ganzzahliger Punkt geht verloren.
- Bounding und Pruning. Ein Ast wird abgeschnitten (pruned), wenn:
- das Teilproblem unzulässig ist,
- seine LP-Schranke schlechter ist als die beste bereits gefundene ganzzahlige Lösung (der Incumbent),
- oder die LP-Lösung bereits ganzzahlig ist (dann ist dieser Ast fertig).
Zusätzlich fügen Solver Schnittebenen (cutting planes, z. B. Gomory-Cuts) hinzu: gültige Ungleichungen, die gebrochene Bereiche wegschneiden, ohne einen einzigen zulässigen ganzzahligen Punkt zu entfernen.
✏️ Handrechnung 6.2: Ein Suchbaum von Hand
\max\ 5x_1 + 4x_2 \quad\text{u. d. N.}\quad 6x_1+4x_2\le24,\quad x_1+2x_2\le6,\quad x_1,x_2\in\mathbb{Z}_{\ge0}
Wurzel P_0: LP-Lösung x = (3;\ 1{,}5), Z_{LP} = 21. → obere Schranke 21. x_2 ist gebrochen → verzweigen über x_2.
Ast A (x_2 \le 1): LP liefert x = (3{,}33;\ 1), Z = 20{,}67. Noch gebrochen (x_1) → weiter verzweigen. * A1 (x_2\le1, x_1\le3): x = (3;\ 1), Z = 19 → ganzzahlig! Incumbent = 19. * A2 (x_2\le1, x_1\ge4): x = (4;\ 0), Z = 20 → ganzzahlig! Incumbent verbessert auf 20.
Ast B (x_2 \ge 2): LP liefert x = (2{,}67;\ 2), Z = 21{,}33… halt: Prüfen wir 6x_1+4x_2\le24 mit x_2=2: 6x_1 \le 16, also x_1 \le 2{,}67; und x_1+2x_2\le6 gibt x_1 \le 2. Also x_1 = 2, Z = 5\cdot2+4\cdot2 = 18 → ganzzahlig, aber schlechter als der Incumbent 20 → verworfen.
Ergebnis: x^* = (4;\ 0), Z^* = 20. Wir haben fünf LPs gelöst — P_0, A, A1, A2 und B — statt alle ganzzahligen Punkte aufzuzählen. Das Skript
bilder_04/erzeuge_branch_and_bound.pyrechnet genau diesen Baum nach und zählt mit.Beachten Sie: Der Wert Z_{LP} = 21 der Wurzel ist die Optimalitätslücke-Referenz. Nach dem Finden von 20 weiß der Solver: Das Optimum liegt zwischen 20 und 21 — eine Lücke von 5 %. In der Praxis bricht man oft genau hier ab (siehe Kapitel 22).
6.5 Modellierungstricks: Big-M und logische Bedingungen
Hier kommt das Handwerkszeug, das MILP so mächtig macht: Man übersetzt Geschäftsregeln in lineare Ungleichungen — mithilfe binärer Hilfsvariablen y \in \{0,1\} und einer hinreichend großen Konstante M.
Muster 1 — Fixkosten / Aktivierungsschalter
Regel: Wird Maschine j genutzt (x_j > 0), fallen Rüstkosten F_j an.
x_j \le M \cdot y_j, \qquad y_j \in \{0,1\},\ x_j \ge 0
- Ist y_j = 0: erzwingt x_j \le 0, zusammen mit x_j \ge 0 also x_j = 0.
- Ist y_j = 1: gilt nur x_j \le M — die Kapazität ist freigegeben.
In der Zielfunktion erscheint dann +F_j y_j als Kostenterm.
Muster 2 — Entweder-Oder (disjunctive constraints)
Regel: Es muss entweder f(\mathbf{x}) \le b_1 oder g(\mathbf{x}) \le b_2 gelten.
f(\mathbf{x}) \le b_1 + M(1-y), \qquad g(\mathbf{x}) \le b_2 + M y, \qquad y\in\{0,1\}
- y=1: erste Bedingung aktiv, zweite durch +M praktisch außer Kraft.
- y=0: umgekehrt.
Muster 3 — Wenn-Dann (Implikation)
Regel: Wenn y_1 = 1, dann muss auch y_2 = 1 sein.
y_1 \le y_2
Kein Big-M nötig — das ist die eleganteste Formulierung überhaupt. Prüfen Sie die vier Fälle: (0,0) ✓, (0,1) ✓, (1,1) ✓, (1,0) ✗ — genau die verbotene Kombination wird ausgeschlossen.
Muster 4 — Kardinalität („höchstens K aus N“)
\sum_{j=1}^N y_j \le K
Muster 5 — Semikontinuierlich („entweder 0 oder mindestens L“)
Regel: Eine Position ist entweder gar nicht besetzt oder mit mindestens L Euro.
L\,y_j \le x_j \le U\,y_j
Dieses Muster brauchen wir gleich für die Mindestordergröße.
⚠️ Die Big-M-Falle: M so klein wie möglich!
M muss groß genug sein, um die Bedingung wirklich außer Kraft zu setzen — aber jedes Übermaß kostet Laufzeit. Der Grund liegt in der LP-Relaxation: Mit M = 10^9 und x_j \le 10^9 y_j genügt schon y_j = 10^{-9} \cdot x_j, um die Bedingung zu erfüllen. Die Relaxation ist dann extrem schwach, die obere Schranke nutzlos, und Branch-and-Bound muss praktisch alles durchsuchen.
Regel: Wählen Sie M als kleinste Zahl, die nachweislich nie bindet — meist eine ohnehin vorhandene Kapazitätsgrenze. Im Portfoliobeispiel unten ist das die Obergrenze pro Position (40 000 €), nicht eine willkürliche Million.
Gemessen an der Standortplanung aus
Big_M_Falle.py(12 Lager, 40 Kunden, HiGHS):
M Knoten im Suchbaum Zielwert = Lagerkapazität (knappstmöglich) 13 richtig 10\times zu groß 51 richtig 10^4\times zu groß 51 richtig 10^7\times zu groß 51 richtig Zwei Dinge sind daran bemerkenswert. Erstens: Das knappe M braucht viermal weniger Knoten — der Effekt ist real. Zweitens: Ab dem Zehnfachen wird es nicht mehr schlimmer, weil HiGHS’ Presolve das übergroße M selbst auf die implizit vorhandene Schranke zurechtstutzt. Verlassen Sie sich darauf nicht: Presolve kann das nur, wenn eine solche Schranke im Modell überhaupt herleitbar ist. Und wenn nicht, wird es richtig unangenehm — siehe Abschnitt 6.10.
6.6 Beispiel: Das Rucksackproblem
Bevor wir zum Portfolio kommen, das klassische Einstiegsproblem — kurz, verständlich und überall wiederzuerkennen.
#!/usr/bin/env python3
# Rucksack.py
"""
Kapitel MILP: Das Rucksackproblem (Knapsack).
Zeigt LP-Relaxation, Branch-and-Bound-Ergebnis und den Preis der Ganzzahligkeit
an einem Beispiel, das man vollstaendig im Kopf nachvollziehen kann.
"""
import numpy as np
from scipy.optimize import linprog
GEGENSTAENDE = ["Zelt", "Schlafsack", "Kocher", "Kamera", "Buch", "Wasserfilter", "Seil"]
NUTZEN = np.array([40.0, 35.0, 20.0, 30.0, 8.0, 25.0, 12.0])
GEWICHT = np.array([ 6.0, 4.0, 3.0, 2.0, 1.0, 2.0, 3.0])
KAPAZITAET = 11.0 # kg
def loese(ganzzahlig: bool):
n = len(NUTZEN)
ergebnis = linprog(
c=-NUTZEN, A_ub=[GEWICHT], b_ub=[KAPAZITAET],
bounds=[(0, 1)] * n, # jedes Teil hoechstens einmal
integrality=np.ones(n) if ganzzahlig else None,
method="highs")
return -ergebnis.fun, ergebnis.x
if __name__ == "__main__":
z_lp, x_lp = loese(ganzzahlig=False)
z_ip, x_ip = loese(ganzzahlig=True)
print("=" * 72)
print(f" RUCKSACKPROBLEM (Kapazitaet {KAPAZITAET:.0f} kg)")
print("=" * 72)
print(f"{'Gegenstand':<14} {'Nutzen':>7} {'kg':>5} {'Nutzen/kg':>10} "
f"{'LP':>7} {'MILP':>6}")
print("-" * 72)
for i, name in enumerate(GEGENSTAENDE):
# int(round(...)) statt Format "%.0f": vermeidet die Ausgabe "-0"
print(f"{name:<14} {NUTZEN[i]:>7.0f} {GEWICHT[i]:>5.0f} "
f"{NUTZEN[i]/GEWICHT[i]:>10.2f} {x_lp[i]:>7.2f} {int(round(x_ip[i])):>6d}")
print("-" * 72)
print(f"{'Gesamtnutzen':<14} {'':<7} {'':<5} {'':<10} {z_lp:>7.2f} {z_ip:>6.0f}")
print(f"{'Gesamtgewicht':<14} {'':<7} {'':<5} {'':<10} "
f"{GEWICHT @ x_lp:>7.2f} {GEWICHT @ x_ip:>6.0f}")
print("-" * 72)
print(f"Obere Schranke aus der LP-Relaxation: {z_lp:.2f}")
print(f"Bestes ganzzahliges Ergebnis: {z_ip:.0f}")
print(f"Preis der Ganzzahligkeit: {z_lp - z_ip:.2f} "
f"({(1 - z_ip/z_lp)*100:.1f} %)")
gebrochen = [GEGENSTAENDE[i] for i in range(len(NUTZEN)) if 1e-6 < x_lp[i] < 1 - 1e-6]
print(f"\nIn der LP-Loesung nur teilweise eingepackt: {gebrochen}")
print("Genau hier wuerde Branch-and-Bound verzweigen:")
print(f" Ast 1: {gebrochen[0]} bleibt ganz zuhause (x=0)")
print(f" Ast 2: {gebrochen[0]} kommt ganz mit (x=1)")
print("=" * 72)Erwartete Ausgabe:
========================================================================
RUCKSACKPROBLEM (Kapazitaet 11 kg)
========================================================================
Gegenstand Nutzen kg Nutzen/kg LP MILP
------------------------------------------------------------------------
Zelt 40 6 6.67 0.00 0
Schlafsack 35 4 8.75 1.00 1
Kocher 20 3 6.67 0.67 1
Kamera 30 2 15.00 1.00 1
Buch 8 1 8.00 1.00 0
Wasserfilter 25 2 12.50 1.00 1
Seil 12 3 4.00 0.00 0
------------------------------------------------------------------------
Gesamtnutzen 111.33 110
Gesamtgewicht 11.00 11
------------------------------------------------------------------------
Obere Schranke aus der LP-Relaxation: 111.33
Bestes ganzzahliges Ergebnis: 110
Preis der Ganzzahligkeit: 1.33 (1.2 %)
In der LP-Loesung nur teilweise eingepackt: ['Kocher']
Genau hier wuerde Branch-and-Bound verzweigen:
Ast 1: Kocher bleibt ganz zuhause (x=0)
Ast 2: Kocher kommt ganz mit (x=1)
========================================================================
Drei Beobachtungen, die den Kern des Kapitels illustrieren:
- Die LP-Relaxation packt zwei Drittel Kocher ein — physikalisch unsinnig, als Schranke aber wertvoll: „Mehr als 111,33 ist unmöglich.“
- Runden hätte hier versagt. Abrunden des Kochers auf 0 ergäbe 35+30+8+25 = 98 bei nur 9 kg — zwölf Punkte schlechter als das Optimum, und drei Kilo Kapazität bleiben ungenutzt. Aufrunden auf 1 ergäbe 12 kg und wäre unzulässig. Das exakte Optimum tauscht stattdessen das Buch gegen den Kocher — eine Umschichtung, auf die kein Rundungsverfahren kommt, weil sie eine bereits „fertige“ Variable wieder verändert.
- Die Lücke ist klein (1,2 %). Ein Solver, der frühzeitig bei 110 abbricht, kann beweisen, höchstens 1,2 % vom Optimum entfernt zu sein — ohne alle Äste zu durchsuchen. Genau das ist der MIP-Gap aus Kapitel 22.
6.7 Praxisfall: Portfolio mit Ordergebühren und Kardinalitätsgrenze
Szenario. Ein Investor verteilt 100\,000\,\text{€} auf sechs Anlageklassen.
- Jede Transaktion kostet 50 € Fixgebühr, unabhängig vom Betrag.
- Höchstens 3 Positionen gleichzeitig (Kardinalitätsbeschränkung).
- Wird eine Position eröffnet, dann mit mindestens 10 000 € (Mindestordergröße).
- Höchstens 40 000 € pro Position.
Modell. Für jede Anlage i: x_i \ge 0 (Euro-Betrag) und y_i \in \{0,1\} (Position aktiv).
\max \sum_{i=1}^n \mu_i x_i - F\sum_{i=1}^n y_i
\begin{aligned} \sum_i x_i &= B && \text{(Budget vollständig investiert)}\\ \sum_i y_i &\le K && \text{(höchstens $K$ Positionen)}\\ L\,y_i \le x_i &\le U\,y_i && \forall i \quad \text{(semikontinuierlich, Muster 5)} \end{aligned}
📐 Formel-Lesehilfe * \mu_i — erwartete Jahresrendite der Anlage i (z. B. 0,11 = 11 %). * \mu_i x_i — erwarteter Ertrag in Euro. * F\sum y_i — Summe der Ordergebühren: 50 € je aktivierter Position. * L y_i \le x_i \le U y_i — die Doppelungleichung erledigt beides auf einmal: Ist y_i=0, folgt 0 \le x_i \le 0, also x_i=0. Ist y_i=1, folgt 10\,000 \le x_i \le 40\,000.
Ohne Formel gesagt: „Investiere das ganze Budget in höchstens drei Töpfe, jeweils zwischen 10 000 und 40 000 Euro, und ziehe für jeden benutzten Topf 50 Euro Gebühr ab.“
⚠️ Eine ökonomische Unsauberkeit, die Sie kennen sollten
Das Modell verrechnet einen einmaligen Gebührenbetrag (50 €) mit einem jährlichen Ertrag (\mu_i x_i). Streng genommen mischt das Einheiten — bei einer Haltedauer von einem Jahr geht es auf, bei zehn Jahren wären die Gebühren zehnfach zu schwer gewichtet. Für das Beispiel ist der Effekt klein (150 € gegen ~11 000 € Ertrag), aber in einem echten Modell würde man entweder die Gebühr annualisieren oder mit Barwerten rechnen.
📎 Zwei Bausteine aus
or_kern.pyDas Programm wertet den Solver nicht über
ergebnis.successaus, sondern überstatus_von_scipy()und dasLoesung-Objekt aus dem gemeinsamen Unterbau (Abschnitt 22.6). Der Unterschied ist kein Schönheitsfehler:
successist ein Bit. Es unterscheidet nicht zwischen „es gibt keine Lösung“ (Modellfehler — das Modell muss geändert werden) und „die Zeit war um“ (Rechenproblem — mehr Zeit oder ein besserer Startwert hilft). Das sind zwei völlig verschiedene Nachrichten an völlig verschiedene Adressaten.- Das
Loesung-Objekt führt neben dem Zielwert die Schranke mit und rechnet daraus den Gap aus. Erst dadurch steht in der AusgabeGap: 0.00%— die Zusage, dass hier wirklich das Optimum gefunden und bewiesen wurde und nicht bloß irgendetwas.Genau die Unterscheidung, um die es im nächsten Abschnitt geht — hier schon einmal angewandt.
#!/usr/bin/env python3
# MILP_Portfolio_Fixgebuehren.py
"""
Kapitel MILP: MILP-Portfolio-Selektion mit Fixkosten und Kardinalitaet.
CP-SAT-freie, gut lesbare Formulierung ueber scipy/HiGHS (kein manueller
CSR-Matrixaufbau, deutlich leichter nachvollziehbar), mit Vergleich gegen die
Loesung OHNE Restriktionen.
Die Auswertung laeuft ueber SolverStatus und das Loesung-Objekt aus
or_kern.py: Alle Statusfaelle werden behandelt, und der MIP-Gap steht im
Bericht - statt eines blossen 'success', das nicht verraet, ob der Solver
fertig geworden ist oder nur aufgegeben hat.
Benoetigt: numpy, pandas, scipy, pydantic (ueber or_kern)
"""
from __future__ import annotations
import time
import numpy as np
import pandas as pd
from scipy.optimize import linprog
from or_kern import Loesung, SolverStatus, status_von_scipy
ANLAGEN = ["US-Aktien", "EU-Aktien", "Emerging-Markets",
"Staatsanleihen", "Unternehmensanleihen", "Rohstoffe"]
RENDITE = np.array([0.11, 0.08, 0.13, 0.03, 0.05, 0.07]) # erwartet, p.a.
BUDGET = 100_000.0
MIN_POSITION = 10_000.0 # L
MAX_POSITION = 40_000.0 # U (dient zugleich als Big-M!)
GEBUEHR = 50.0 # F, je aktivierter Position
MAX_POSITIONEN = 3 # K
N = len(ANLAGEN)
def baue_und_loese(zeitlimit: float | None = None) -> Loesung:
"""
Variablenreihenfolge: [x_0..x_{N-1}, y_0..y_{N-1}]
Zielfunktion (Maximierung -> fuer linprog negiert):
max sum(rendite_i * x_i) - GEBUEHR * sum(y_i)
"""
c = np.concatenate([-RENDITE, np.full(N, GEBUEHR)]) # negiert = Minimierung
# --- Gleichungsnebenbedingung: Budget vollstaendig investiert -----------
A_eq = np.zeros((1, 2 * N))
A_eq[0, :N] = 1.0
b_eq = np.array([BUDGET])
zeilen, grenzen = [], []
# --- Kardinalitaet: sum(y_i) <= K --------------------------------------
zeile = np.zeros(2 * N)
zeile[N:] = 1.0
zeilen.append(zeile)
grenzen.append(MAX_POSITIONEN)
# --- Obergrenze (Big-M): x_i - U*y_i <= 0 -----------------------------
for i in range(N):
zeile = np.zeros(2 * N)
zeile[i] = 1.0
zeile[N + i] = -MAX_POSITION
zeilen.append(zeile)
grenzen.append(0.0)
# --- Untergrenze: L*y_i - x_i <= 0 (entspricht x_i >= L*y_i) --------
for i in range(N):
zeile = np.zeros(2 * N)
zeile[i] = -1.0
zeile[N + i] = MIN_POSITION
zeilen.append(zeile)
grenzen.append(0.0)
A_ub = np.array(zeilen)
b_ub = np.array(grenzen)
schranken = [(0.0, MAX_POSITION)] * N + [(0.0, 1.0)] * N
ganzzahligkeit = np.concatenate([np.zeros(N), np.ones(N)]) # y binaer
t0 = time.perf_counter()
ergebnis = linprog(c=c, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq=b_eq,
bounds=schranken, integrality=ganzzahligkeit,
method="highs",
options={"time_limit": zeitlimit} if zeitlimit else None)
laufzeit = time.perf_counter() - t0
status = status_von_scipy(ergebnis)
if not status.brauchbar:
return Loesung(status=status, laufzeit=laufzeit)
# Zurueck in die Maximierungswelt: Zielwert UND Schranke negieren.
# Aus beiden zusammen rechnet das Loesung-Objekt den MIP-Gap aus.
x, y = ergebnis.x[:N], np.round(ergebnis.x[N:])
return Loesung(
status=status,
werte={**{name: float(w) for name, w in zip(ANLAGEN, x)},
**{f"aktiv:{name}": float(w) for name, w in zip(ANLAGEN, y)}},
zielwert=float(-ergebnis.fun),
schranke=float(-ergebnis.mip_dual_bound),
laufzeit=laufzeit)
def ohne_restriktionen() -> tuple[float, np.ndarray]:
"""Vergleichsfall: nur Budget, keine Gebuehren/Kardinalitaet/Mindestgroesse."""
ergebnis = linprog(c=-RENDITE, A_eq=[np.ones(N)], b_eq=[BUDGET],
bounds=[(0, None)] * N, method="highs")
status = status_von_scipy(ergebnis)
if not status.brauchbar:
raise SystemExit(f"Vergleichsfall nicht loesbar: {status.value}")
return -ergebnis.fun, ergebnis.x
def pruefe_portfolio(loesung: Loesung, toleranz: float = 1e-6) -> list[str]:
"""Prueft die Loesung gegen die Anforderungen - ohne den Solver zu fragen.
Bewusst kein assert: Eine Beanstandungsliste laesst sich protokollieren,
weiterreichen und testen. Ein assert verschwindet ausserdem, sobald
jemand Python mit -O startet.
"""
x = np.array([loesung.werte[name] for name in ANLAGEN])
y = np.array([loesung.werte[f"aktiv:{name}"] for name in ANLAGEN])
beanstandungen: list[str] = []
if abs(x.sum() - BUDGET) > 1e-4:
beanstandungen.append(f"Budget nicht exakt investiert: {x.sum():,.2f}")
if y.sum() > MAX_POSITIONEN + toleranz:
beanstandungen.append(f"{y.sum():.0f} Positionen statt hoechstens "
f"{MAX_POSITIONEN}")
for i, name in enumerate(ANLAGEN):
if abs(y[i] - round(y[i])) > toleranz:
beanstandungen.append(f"{name}: y = {y[i]!r} ist nicht ganzzahlig")
elif y[i] and not (MIN_POSITION - toleranz <= x[i]
<= MAX_POSITION + toleranz):
beanstandungen.append(f"{name}: {x[i]:,.2f} EUR verletzt die "
f"Groessengrenzen")
elif not y[i] and x[i] > toleranz:
beanstandungen.append(f"{name}: inaktiv, aber {x[i]:,.2f} EUR "
f"investiert")
return beanstandungen
if __name__ == "__main__":
loesung = baue_und_loese()
print("=" * 88)
print(" OPTIMALE MILP-PORTFOLIO-ALLOKATION MIT FIXGEBUEHREN")
print("=" * 88)
print(f"Budget: {BUDGET:,.0f} EUR | max. {MAX_POSITIONEN} Positionen | "
f"je {MIN_POSITION:,.0f}-{MAX_POSITION:,.0f} EUR | Gebuehr {GEBUEHR:.0f} EUR\n")
# Zuerst der Status - erst danach interessieren die Zahlen.
if loesung.status.modellfehler:
raise SystemExit(f"Das Modell ist nicht loesbar ({loesung.status.value}). "
f"Naechster Schritt: Anhang Fehlerdiagnose.")
if not loesung.status.brauchbar:
raise SystemExit(f"Keine Loesung erhalten ({loesung.status.value}). "
f"Zeitlimit erhoehen oder Modell vereinfachen.")
if loesung.status is SolverStatus.ZULAESSIG:
print(f"ACHTUNG: nicht beweisbar optimal - Gap {loesung.gap:.2%}\n")
x = np.array([loesung.werte[name] for name in ANLAGEN])
y = np.array([loesung.werte[f"aktiv:{name}"] for name in ANLAGEN], dtype=int)
tabelle = pd.DataFrame({
"Anlage": ANLAGEN,
"Aktiv": ["JA" if y[i] else "-" for i in range(N)],
"Investition (EUR)": [f"{x[i]:,.0f}" for i in range(N)],
"Anteil": [f"{x[i]/BUDGET*100:5.1f} %" for i in range(N)],
"Erw. Rendite": [f"{RENDITE[i]*100:4.1f} %" for i in range(N)],
"Erw. Ertrag (EUR)": [f"{x[i]*RENDITE[i]:,.0f}" for i in range(N)],
})
print(tabelle.to_string(index=False))
brutto = float(RENDITE @ x)
gebuehren = float(GEBUEHR * y.sum())
print("-" * 88)
print(f"Erwarteter Bruttoertrag: {brutto:>12,.2f} EUR")
print(f"Ordergebuehren: {-gebuehren:>12,.2f} EUR ({y.sum()} Positionen)")
print(f"Netto-Erwartungswert: {loesung.zielwert:>12,.2f} EUR")
print(f"\n{loesung.als_bericht()}")
# --- Vergleich mit dem unbeschraenkten Fall ---------------------------
z_frei, x_frei = ohne_restriktionen()
print("-" * 88)
print(f"Zum Vergleich ohne jede Restriktion (alles in den Bestwert): "
f"{z_frei:,.2f} EUR")
print(f"Kosten der Realitaet (Gebuehren, Streuung, Mindestgroessen): "
f"{z_frei - loesung.zielwert:,.2f} EUR "
f"({(1 - loesung.zielwert/z_frei)*100:.2f} %)")
# --- Alle Nebenbedingungen nachpruefen --------------------------------
beanstandungen = pruefe_portfolio(loesung)
print("-" * 88)
if beanstandungen:
raise SystemExit("Abnahmepruefung fehlgeschlagen:\n - "
+ "\n - ".join(beanstandungen))
print("Abnahmepruefung: alle Nebenbedingungen geprueft und eingehalten.")
print("=" * 88)Erwartete Ausgabe:
========================================================================================
OPTIMALE MILP-PORTFOLIO-ALLOKATION MIT FIXGEBUEHREN
========================================================================================
Budget: 100,000 EUR | max. 3 Positionen | je 10,000-40,000 EUR | Gebuehr 50 EUR
Anlage Aktiv Investition (EUR) Anteil Erw. Rendite Erw. Ertrag (EUR)
US-Aktien JA 40,000 40.0 % 11.0 % 4,400
EU-Aktien JA 20,000 20.0 % 8.0 % 1,600
Emerging-Markets JA 40,000 40.0 % 13.0 % 5,200
Staatsanleihen - 0 0.0 % 3.0 % 0
Unternehmensanleihen - 0 0.0 % 5.0 % 0
Rohstoffe - 0 0.0 % 7.0 % 0
----------------------------------------------------------------------------------------
Erwarteter Bruttoertrag: 11,200.00 EUR
Ordergebuehren: -150.00 EUR (3 Positionen)
Netto-Erwartungswert: 11,050.00 EUR
Status: optimal | Zielwert: 11,050.00 | Gap: 0.00% | Zeit: 0.01s
----------------------------------------------------------------------------------------
Zum Vergleich ohne jede Restriktion (alles in den Bestwert): 13,000.00 EUR
Kosten der Realitaet (Gebuehren, Streuung, Mindestgroessen): 1,950.00 EUR (15.00 %)
----------------------------------------------------------------------------------------
Abnahmepruefung: alle Nebenbedingungen geprueft und eingehalten.
========================================================================================
💻 Code-Durchgang
Stelle Was passiert c = concat([-RENDITE, full(N, GEBUEHR)])Zielfunktion über beide Variablenblöcke: Erträge negativ (weil linprogminimiert), Gebühren positiv (sie sollen ja gedrückt werden)A_eq[0, :N] = 1.0Budgetgleichung betrifft nur die x-Variablen zeile[i]=1; zeile[N+i]=-MAX_POSITIONMuster 1: x_i - U y_i \le 0. M = U = 40\,000 — die kleinstmögliche gültige Wahl zeile[i]=-1; zeile[N+i]=MIN_POSITIONMuster 5 unten: L y_i - x_i \le 0 integrality=concat([zeros(N), ones(N)])nur die y sind ganzzahlig — das ist das „mixed“ in MILP status = status_von_scipy(ergebnis)übersetzt den SciPy-Rückgabewert in die gemeinsame Sprache aus Abschnitt 22.6 — die drei if-Zweige darunter behandeln Modellfehler, Abbruch ohne Lösung und „zulässig, aber unbewiesen“ getrenntschranke=-ergebnis.mip_dual_bounddie zweite Zahl aus Abschnitt 6.8. Erst mit ihr kann das Loesung-Objekt den Gap ausrechnen — und der Bericht sagtGap: 0.00%, also beweisbar optimalpruefe_portfolio(...)prüft jede modellierte Regel einzeln nach und liefert eine Liste von Beanstandungen Warum das Ergebnis wirtschaftlich Sinn ergibt: Der Solver wählt die drei renditestärksten Anlagen (13 %, 11 %, 8 %) und füllt sie in dieser Reihenfolge bis zur Obergrenze von 40 000 €. Die drittbeste Position (EU-Aktien) erhält nur den Rest von 20 000 € — sie liegt über der Mindestordergröße von 10 000 €, ist also zulässig. Ohne Kardinalitätsgrenze läge alles im Bestwert (13 %); die Restriktionen kosten 15 % des theoretischen Ertrags. Genau diese Zahl braucht man, wenn man mit dem Risikomanagement über die Sinnhaftigkeit einer Regel diskutiert: „Die Obergrenze von 40 % je Position kostet uns 1 950 € pro Jahr — ist uns die Diversifikation das wert?“ Das ist eine beantwortbare Frage; „wir sollten breiter streuen“ ist es nicht.
6.8 Wenn der Solver nicht fertig wird: Gap, Zeitlimit und Warm-Start
Bei einem LP gibt es zwei Ausgänge: eine optimale Lösung oder eine klare Absage. Bei einem MILP gibt es einen dritten, und im Betrieb ist er der häufigste:
„Ich habe eine Lösung. Ob sie die beste ist, weiß ich nicht. Die Zeit ist um.“
Dieser Abschnitt handelt davon, wie man damit professionell umgeht — statt so zu tun, als käme immer OPTIMAL zurück.
Die zwei Zahlen, die zählen
Branch-and-Bound führt zu jedem Zeitpunkt zwei Werte mit:
| Begriff | Was er bedeutet | Wie er sich entwickelt |
|---|---|---|
| Incumbent (beste gefundene Lösung) | ein Plan, den man tatsächlich ausführen könnte | wird im Lauf der Suche immer besser |
| Schranke (dual bound) | der beste Wert, den es überhaupt geben könnte | wird im Lauf der Suche immer schlechter |
Beide laufen aufeinander zu. Ihr Abstand ist der MIP-Gap:
\text{Gap} = \frac{\lvert\, z_{\text{incumbent}} - z_{\text{schranke}}\,\rvert} {\lvert\, z_{\text{incumbent}}\,\rvert}
🔤 Formel-Übersetzer
Mathematik Alltagssprache z_{\text{incumbent}} „Das ist der beste Plan, den ich bisher tatsächlich in der Hand habe.“ z_{\text{schranke}} „Besser als das kann es rechnerisch nicht werden — bewiesen.“ Gap = 0 „Beide sind gleich: Der Plan ist beweisbar optimal.“ Gap = 0{,}09 „Mein Plan ist höchstens 9 % schlechter als das theoretische Beste. Garantiert.“ Der Gap ist damit keine Schätzung und keine Fehlerbalken-Angabe, sondern eine Zusage. Das macht ihn zu der Zahl, die man ins Managementgespräch mitnimmt: „Wir liegen höchstens 2 % vom Optimum entfernt“ ist eine belastbare Aussage. „Der Solver hat lange gerechnet“ ist keine.
Alle Statusfälle behandeln
Der zweite Teil der Professionalität ist unspektakulär, aber entscheidend: jeden Rückgabewert auswerten, nicht nur OPTIMAL.
| Status | Bedeutung | Was Sie tun |
|---|---|---|
Optimal |
beweisbar bestmöglich | ausführen |
Time limit reached (mit Lösung) |
zulässig, nicht bewiesen optimal | Gap prüfen, dann entscheiden |
Time limit reached (ohne Lösung) |
nichts gefunden | Modell vereinfachen, Heuristik als Startwert, Limit erhöhen |
Infeasible |
kein zulässiger Plan existiert | harte Bedingungen prüfen (Kapitel 22, Anhang C) |
Unbounded |
Ziel wächst unbegrenzt | fehlende Schranke — fast immer ein Modellierungsfehler |
#!/usr/bin/env python3
# Solverstatus_und_Gap.py
"""
Kapitel MILP: Was tun, wenn der Solver nicht fertig wird?
Bei einem LP kommt entweder eine optimale Loesung oder eine klare Absage.
Bei einem MILP ist der haeufigste Ausgang im Betrieb ein dritter: "Ich habe
eine Loesung, ich weiss aber nicht, ob sie die beste ist - und die Zeit ist
um." Dieses Programm zeigt, wie man mit diesem Fall umgeht.
1. Alle Statusfaelle explizit behandeln, statt OPTIMAL vorauszusetzen.
2. Den MIP-Gap lesen: Wie weit kann ich hoechstens danebenliegen?
3. Messen, was zusaetzliche Rechenzeit ueberhaupt noch bringt.
4. Warm-Start ausprobieren - und ehrlich messen, ob er etwas bringt.
Beispiel: Standortplanung, 45 moegliche Lager, 120 Kunden (5445 Variablen,
davon 45 binaer).
Benoetigt: numpy, highspy
"""
from __future__ import annotations
import time
from dataclasses import dataclass
import numpy as np
import highspy
RNG = np.random.default_rng(7)
N_LAGER, N_KUNDE = 45, 120
FIXKOSTEN = RNG.uniform(3000, 9000, N_LAGER)
TRANSPORT = RNG.uniform(5, 60, (N_LAGER, N_KUNDE))
BEDARF = RNG.uniform(10, 60, N_KUNDE)
KAPAZITAET = np.full(N_LAGER, BEDARF.sum() * 0.22)
@dataclass
class Ergebnis:
"""Alles, was nach einem Solverlauf ausgewertet werden muss - nicht nur
der Zielwert."""
status: str
brauchbar: bool # Gibt es ueberhaupt eine zulaessige Loesung?
beweisbar_optimal: bool
zielwert: float # bester gefundener Wert (Incumbent)
schranke: float # beste bewiesene Schranke (Dual Bound)
gap: float # relativer Abstand zwischen beiden
knoten: int
dauer: float
loesung: np.ndarray
def loese(zeitlimit: float, startloesung: np.ndarray | None = None) -> Ergebnis:
"""Loest das Standortmodell mit Zeitlimit und wertet ALLE Statusfaelle aus."""
hochschule = highspy.Highs()
hochschule.setOptionValue("output_flag", False)
hochschule.setOptionValue("time_limit", zeitlimit)
anzahl_x = N_LAGER * N_KUNDE
unendlich = highspy.kHighsInf
hochschule.addVars(anzahl_x, np.zeros(anzahl_x), np.full(anzahl_x, unendlich))
hochschule.addVars(N_LAGER, np.zeros(N_LAGER), np.ones(N_LAGER))
for i in range(N_LAGER):
hochschule.changeColIntegrality(anzahl_x + i, highspy.HighsVarType.kInteger)
hochschule.changeColCost(anzahl_x + i, FIXKOSTEN[i])
for j in range(N_KUNDE):
hochschule.changeColCost(i * N_KUNDE + j, TRANSPORT[i, j])
for j in range(N_KUNDE):
index = np.array([i * N_KUNDE + j for i in range(N_LAGER)], dtype=np.int32)
hochschule.addRow(BEDARF[j], BEDARF[j], len(index), index, np.ones(len(index)))
for i in range(N_LAGER):
index = np.array([i * N_KUNDE + j for j in range(N_KUNDE)] + [anzahl_x + i],
dtype=np.int32)
werte = np.concatenate([np.ones(N_KUNDE), [-KAPAZITAET[i]]])
hochschule.addRow(-unendlich, 0.0, len(index), index, werte)
if startloesung is not None:
hochschule.setSolution(len(startloesung),
np.arange(len(startloesung), dtype=np.int32),
startloesung)
t0 = time.perf_counter()
hochschule.run()
dauer = time.perf_counter() - t0
status = hochschule.modelStatusToString(hochschule.getModelStatus())
info = hochschule.getInfo()
# Der Kern der Sache: Aus dem Status folgt, WAS man mit dem Ergebnis
# ueberhaupt anfangen darf.
brauchbar = status in ("Optimal", "Time limit reached", "Solution limit reached")
beweisbar_optimal = status == "Optimal"
if status in ("Infeasible", "Unbounded", "Primal infeasible or unbounded"):
brauchbar = False
return Ergebnis(
status=status,
brauchbar=brauchbar and info.objective_function_value < unendlich,
beweisbar_optimal=beweisbar_optimal,
zielwert=info.objective_function_value,
schranke=info.mip_dual_bound,
gap=info.mip_gap,
knoten=info.mip_node_count,
dauer=dauer,
loesung=np.array(hochschule.getSolution().col_value),
)
def gieriger_startplan() -> tuple[np.ndarray, float]:
"""Eine Faustregel-Loesung, wie sie ein Disponent von Hand erstellen wuerde:
die guenstigsten Lager oeffnen (Fixkosten je Kapazitaetseinheit), dann
jeden Kunden dem naechstgelegenen offenen Lager mit Restkapazitaet
zuordnen. Kein Solver noetig - und in Sekunden fertig.
"""
reihenfolge = np.argsort(FIXKOSTEN / KAPAZITAET)
offen: list[int] = []
for i in reihenfolge:
offen.append(int(i))
if KAPAZITAET[offen].sum() >= BEDARF.sum() * 1.05:
break
rest = KAPAZITAET.copy()
x = np.zeros((N_LAGER, N_KUNDE))
for j in np.argsort(-BEDARF): # groesste Kunden zuerst
for i in sorted(offen, key=lambda i: TRANSPORT[i, j]):
menge = min(rest[i], BEDARF[j] - x[:, j].sum())
if menge > 1e-9:
x[i, j] += menge
rest[i] -= menge
if abs(x[:, j].sum() - BEDARF[j]) < 1e-9:
break
y = np.zeros(N_LAGER)
y[offen] = 1.0
kosten = float((FIXKOSTEN * y).sum() + (TRANSPORT * x).sum())
return np.concatenate([x.ravel(), y]), kosten
def zeige(titel: str, e: Ergebnis) -> None:
print(f"\n{titel}")
print(f" Status {e.status}")
if not e.brauchbar:
print(" -> KEINE verwertbare Loesung. Nicht weiterrechnen!")
return
print(f" bester Plan (Incumbent) {e.zielwert:>12,.2f} EUR")
print(f" bewiesene Schranke {e.schranke:>12,.2f} EUR")
print(f" MIP-Gap {e.gap * 100:>12.3f} %")
print(f" Knoten / Zeit {e.knoten:>12,} / {e.dauer:.2f} s")
if e.beweisbar_optimal:
print(" -> beweisbar optimal")
else:
print(f" -> zulaessig, aber nicht bewiesen optimal. Der wahre Bestwert")
print(f" liegt zwischen {e.schranke:,.2f} und {e.zielwert:,.2f} EUR.")
if __name__ == "__main__":
print("=" * 78)
print(" MIP-GAP UND ZEITLIMIT: STANDORTPLANUNG, 45 LAGER, 120 KUNDEN")
print("=" * 78)
print(f"{N_LAGER * N_KUNDE + N_LAGER:,} Variablen, davon {N_LAGER} binaer.")
kurz = loese(2.0)
zeige("[1] Zeitlimit 2 Sekunden", kurz)
lang = loese(60.0)
zeige("[2] Zeitlimit 60 Sekunden", lang)
print("\n" + "=" * 78)
print(" WAS BRINGT MEHR RECHENZEIT?")
print("=" * 78)
verbesserung = kurz.zielwert - lang.zielwert
print(f"Nach 2 Sekunden: {kurz.zielwert:,.2f} EUR, Gap {kurz.gap * 100:.2f} %")
print(f"Nach {lang.dauer:.1f} Sekunden: {lang.zielwert:,.2f} EUR, bewiesen optimal")
print(f"Gewinn durch {lang.dauer - kurz.dauer:.1f} Sekunden mehr Rechenzeit: "
f"{verbesserung:,.2f} EUR "
f"({verbesserung / kurz.zielwert * 100:.2f} %)")
print()
print("Das ist die Frage, die im Betrieb wirklich zaehlt: Der Gap von")
print(f"{kurz.gap * 100:.1f} % nach 2 Sekunden ist eine GARANTIE - schlechter als")
print("dieser Wert kann die Loesung nicht sein. Ob sich die restliche")
print("Rechenzeit lohnt, entscheidet nicht der Solver, sondern die Anwendung:")
print("Ein naechtlicher Tourenplan darf eine Stunde rechnen, eine Umplanung")
print("bei Maschinenausfall hat 30 Sekunden.")
print("\n" + "=" * 78)
print(" BRINGT EIN WARM-START ETWAS?")
print("=" * 78)
start, start_kosten = gieriger_startplan()
print(f"Faustregel-Startplan (ohne Solver): {start_kosten:,.2f} EUR")
print(f"Das sind {(start_kosten / lang.zielwert - 1) * 100:.1f} % ueber dem Optimum.\n")
warm = loese(60.0, startloesung=start)
print(f"{'':26} {'Zeit':>9} {'Knoten':>9} {'Ziel':>13}")
print("-" * 78)
print(f"{'ohne Warm-Start':<26} {lang.dauer:>8.2f}s {lang.knoten:>9,} "
f"{lang.zielwert:>13,.2f}")
print(f"{'mit Warm-Start':<26} {warm.dauer:>8.2f}s {warm.knoten:>9,} "
f"{warm.zielwert:>13,.2f}")
print("-" * 78)
print("Ergebnis: praktisch kein Unterschied. Der Grund ist nicht, dass")
print("Warm-Starts nichts taugen - sondern dass HiGHS' eigene Heuristiken")
print("innerhalb der ersten Sekunde bereits eine BESSERE Loesung finden als")
print("unsere Faustregel. Ein Startwert hilft nur, wenn er besser ist als")
print("das, was der Solver von allein in derselben Zeit findet.")
print()
print("Warm-Starts lohnen sich damit vor allem in zwei Faellen:")
print(" * Sie haben Domaenenwissen, das der Solver nicht hat (siehe")
print(" Warmstart_Effekt.py - dort halbiert ein Heuristik-Hinweis die Zeit).")
print(" * Sie planen laufend neu und der gestrige Plan ist fast noch gueltig.")
print("In beiden Faellen gilt: MESSEN, nicht glauben.")
print("=" * 78)Erwartete Ausgabe (Zeiten hardwareabhängig):
==============================================================================
MIP-GAP UND ZEITLIMIT: STANDORTPLANUNG, 45 LAGER, 120 KUNDEN
==============================================================================
5,445 Variablen, davon 45 binaer.
[1] Zeitlimit 2 Sekunden
Status Time limit reached
bester Plan (Incumbent) 69,255.12 EUR
bewiesene Schranke 62,982.37 EUR
MIP-Gap 9.057 %
Knoten / Zeit 2 / 2.01 s
-> zulaessig, aber nicht bewiesen optimal. Der wahre Bestwert
liegt zwischen 62,982.37 und 69,255.12 EUR.
[2] Zeitlimit 60 Sekunden
Status Optimal
bester Plan (Incumbent) 69,134.14 EUR
bewiesene Schranke 69,128.19 EUR
MIP-Gap 0.009 %
Knoten / Zeit 150 / 8.98 s
-> beweisbar optimal
==============================================================================
WAS BRINGT MEHR RECHENZEIT?
==============================================================================
Nach 2 Sekunden: 69,255.12 EUR, Gap 9.06 %
Nach 9.0 Sekunden: 69,134.14 EUR, bewiesen optimal
Gewinn durch 7.0 Sekunden mehr Rechenzeit: 120.98 EUR (0.17 %)
Diese letzte Zeile ist die betriebswirtschaftlich interessante. Nach 2 Sekunden liegt ein Plan vor, der garantiert höchstens 9 % vom Optimum entfernt ist. Die restlichen 7 Sekunden Rechenzeit verbessern ihn um 0,17 % — sie werden fast vollständig dafür verbraucht, die Optimalität zu beweisen, nicht den Plan zu verbessern.
🎯 Merksatz Bei einem MILP kostet der Beweis der Optimalität meist ein Vielfaches dessen, was das Finden der optimalen Lösung kostet. Fragen Sie deshalb nicht „wie lange bis optimal?“, sondern „welchen Gap kann ich mir leisten?“. Ein nächtlicher Tourenplan darf eine Stunde rechnen; die Umplanung bei einem Maschinenausfall hat dreißig Sekunden.
Warm-Starts: was sie können und was nicht
Ein Warm-Start gibt dem Solver eine bekannte Lösung als Startpunkt mit. Die Idee ist verlockend: Wer schon eine brauchbare Lösung hat, muss nicht bei null anfangen.
In der Literatur wird das oft als sicherer Beschleuniger dargestellt. Das ist es nicht — und der ehrlichste Weg, das zu zeigen, ist eine Messung, die beide Seiten enthält. Der letzte Teil von Solverstatus_und_Gap.py füttert HiGHS mit einem Faustregel-Startplan:
Faustregel-Startplan (ohne Solver): 75,091.59 EUR
Das sind 8.6 % ueber dem Optimum.
Zeit Knoten Ziel
------------------------------------------------------------------------------
ohne Warm-Start 8.98s 150 69,134.14
mit Warm-Start 8.44s 150 69,134.14
Kein nennenswerter Unterschied. Der Grund ist nicht, dass Warm-Starts nichts taugen, sondern dass HiGHS’ eigene Heuristiken innerhalb der ersten Sekunde bereits eine bessere Lösung finden als unsere Faustregel. Ein Startwert hilft nur, wenn er besser ist als das, was der Solver in derselben Zeit von allein findet.
Der Gegenfall — eine Heuristik mit echtem Domänenwissen:
#!/usr/bin/env python3
# Warmstart_Effekt.py
"""
Kapitel MILP: Wann ein Warm-Start wirklich etwas bringt.
Solverstatus_und_Gap.py zeigt einen Fall, in dem ein Startwert NICHTS
bringt - HiGHS findet von allein schneller etwas Besseres. Hier der
Gegenfall: eine Heuristik, die dem Solver echtes Domaenenwissen liefert.
Problem: Lastverteilung. n Auftraege mit bekannter Dauer sind auf m
gleichartige Maschinen zu verteilen, sodass die zuletzt fertige Maschine
so frueh wie moeglich fertig wird (Makespan-Minimierung).
Die Heuristik: LPT (Longest Processing Time first) - laengste Auftraege
zuerst, jeder auf die momentan am wenigsten belastete Maschine. Sie ist
Jahrzehnte alt, in zwei Zeilen geschrieben und beweisbar nie schlechter
als 4/3 des Optimums.
Ueber model.AddHint() bekommt CP-SAT diese Loesung als Startpunkt.
WICHTIG: highspy wird hier bewusst NICHT importiert - es vertraegt sich
nicht mit ortools im selben Prozess (siehe Kapitel Oekosystem).
Benoetigt: numpy, ortools
"""
from __future__ import annotations
import time
import numpy as np
from ortools.sat.python import cp_model
RNG = np.random.default_rng(4)
def lpt_heuristik(dauer: np.ndarray, n_maschinen: int) -> tuple[np.ndarray, int]:
"""Longest Processing Time first.
Laengste Auftraege zuerst auf die jeweils freieste Maschine legen. Zwei
Zeilen, keine Bibliothek, Ergebnis in Mikrosekunden - und erstaunlich
nah am Optimum.
"""
zuordnung = np.zeros(len(dauer), dtype=int)
belegung = np.zeros(n_maschinen)
for auftrag in np.argsort(-dauer): # laengster zuerst
maschine = int(np.argmin(belegung)) # freieste Maschine
zuordnung[auftrag] = maschine
belegung[maschine] += dauer[auftrag]
return zuordnung, int(belegung.max())
def loese(dauer: np.ndarray, n_maschinen: int, zeitlimit: float,
hinweis: np.ndarray | None = None) -> tuple[str, int, float]:
"""Exaktes Modell mit CP-SAT, optional mit Startloesung als Hinweis."""
n_auftraege = len(dauer)
obergrenze = int(dauer.sum())
modell = cp_model.CpModel()
# x[i][k] = 1 <=> Auftrag i laeuft auf Maschine k
x = [[modell.NewBoolVar(f"x_{i}_{k}") for k in range(n_maschinen)]
for i in range(n_auftraege)]
for i in range(n_auftraege):
modell.AddExactlyOne(x[i]) # jeder Auftrag genau einmal
belegung = [modell.NewIntVar(0, obergrenze, f"last_{k}")
for k in range(n_maschinen)]
for k in range(n_maschinen):
modell.Add(belegung[k] == sum(int(dauer[i]) * x[i][k]
for i in range(n_auftraege)))
makespan = modell.NewIntVar(0, obergrenze, "makespan")
modell.AddMaxEquality(makespan, belegung) # das Maximum ueber alle Maschinen
modell.Minimize(makespan)
# Der Warm-Start: ein Hinweis pro Variable. CP-SAT muss ihn nicht
# befolgen - er nutzt ihn als erste Loesung, wenn er zulaessig ist.
if hinweis is not None:
for i in range(n_auftraege):
for k in range(n_maschinen):
modell.AddHint(x[i][k], 1 if hinweis[i] == k else 0)
loeser = cp_model.CpSolver()
loeser.parameters.max_time_in_seconds = zeitlimit
# Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist.
# Im Produktivbetrieb laesst man beides auf den Standardwerten.
loeser.parameters.num_workers = 1
loeser.parameters.random_seed = 1
t0 = time.perf_counter()
status = loeser.Solve(modell)
dauer_s = time.perf_counter() - t0
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
raise RuntimeError(f"Kein Plan gefunden: {loeser.StatusName(status)}")
return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer_s
if __name__ == "__main__":
print("=" * 78)
print(" WARM-START: WENN DIE HEURISTIK MEHR WEISS ALS DER SOLVER")
print("=" * 78)
print("Lastverteilung: Auftraege auf gleichartige Maschinen verteilen,")
print("sodass die letzte Maschine so frueh wie moeglich fertig wird.\n")
print(f"{'Instanz':<22} {'Variante':<22} {'Makespan':>9} {'Zeit':>9} "
f"{'Faktor':>8}")
print("-" * 78)
for n_auftraege, n_maschinen in [(60, 7), (80, 9)]:
dauer = RNG.integers(10, 90, n_auftraege)
start, lpt_wert = lpt_heuristik(dauer, n_maschinen)
untere_schranke = dauer.sum() / n_maschinen
instanz = f"{n_auftraege} Auftr., {n_maschinen} Masch."
print(f"{instanz:<22} {'LPT-Heuristik':<22} {lpt_wert:>9} "
f"{'< 0.001s':>9} {'':>8}")
_, ziel_kalt, zeit_kalt = loese(dauer, n_maschinen, 60.0)
print(f"{'':<22} {'CP-SAT kalt':<22} {ziel_kalt:>9} "
f"{zeit_kalt:>8.3f}s {'1,0x':>8}")
_, ziel_warm, zeit_warm = loese(dauer, n_maschinen, 60.0, hinweis=start)
print(f"{'':<22} {'CP-SAT + LPT-Hinweis':<22} {ziel_warm:>9} "
f"{zeit_warm:>8.3f}s {zeit_kalt / zeit_warm:>7.1f}x")
assert ziel_kalt == ziel_warm, \
"Der Hinweis darf das Optimum nicht veraendern - nur den Weg dorthin!"
print(f"{'':<22} {'untere Schranke':<22} {untere_schranke:>9.1f}")
print("-" * 78)
print("\nDrei Beobachtungen:")
print("1. Der Hinweis aendert das ERGEBNIS nicht - beide Laeufe finden")
print(" dasselbe Optimum. Er aendert nur, wie lange der Beweis dauert.")
print(" Genau deshalb ist ein Warm-Start ungefaehrlich: Ein schlechter")
print(" Hinweis kostet Zeit, er verfaelscht aber nie die Loesung.")
print("2. Die LPT-Heuristik liegt schon sehr nah am Optimum. Ihr Wert fuer")
print(" den Solver liegt weniger in der Qualitaet als darin, dass sie")
print(" SOFORT da ist - der Solver kann von Beginn an alles verwerfen,")
print(" was schlechter ist.")
print("3. Der Faktor schwankt von Instanz zu Instanz - oben 2,2x und 1,4x -")
print(" und laesst sich NICHT aus der Problemgroesse ableiten. Er haengt")
print(" davon ab, wie schnell der Solver von allein eine vergleichbar gute")
print(" Loesung findet. Das ist die eigentliche Lehre: Ein Warm-Start ist")
print(" eine Messung wert, keine Glaubensfrage.")
print("=" * 78)Erwartete Ausgabe (Zeiten hardwareabhängig):
==============================================================================
WARM-START: WENN DIE HEURISTIK MEHR WEISS ALS DER SOLVER
==============================================================================
Lastverteilung: Auftraege auf gleichartige Maschinen verteilen,
sodass die letzte Maschine so frueh wie moeglich fertig wird.
Instanz Variante Makespan Zeit Faktor
------------------------------------------------------------------------------
60 Auftr., 7 Masch. LPT-Heuristik 483 < 0.001s
CP-SAT kalt 475 0.627s 1,0x
CP-SAT + LPT-Hinweis 475 0.292s 2.2x
untere Schranke 474.7
------------------------------------------------------------------------------
80 Auftr., 9 Masch. LPT-Heuristik 445 < 0.001s
CP-SAT kalt 442 3.275s 1,0x
CP-SAT + LPT-Hinweis 442 2.420s 1.4x
untere Schranke 441.8
------------------------------------------------------------------------------
Hier wirkt der Hinweis: Faktor 2,2 bzw. 1,4. Beachten Sie aber, dass der Faktor schwankt und sich nicht aus der Problemgröße ableiten lässt — bei der größeren Instanz ist er sogar kleiner. Es gibt keine Regel, es gibt nur die Messung.
⚠️ Typische Fehler
if status == OPTIMAL: ... else: return None. Wirft eine Lösung weg, die 9 % vom Optimum entfernt und damit vollkommen brauchbar ist. Werten Sie den Gap aus, nicht nur den Status.- Zeitlimit ohne Gap-Auswertung. Ein Limit erzwingt ein Ende, sagt aber nichts über die Qualität. Ohne den Gap wissen Sie nicht, ob Sie 0,1 % oder 60 % danebenliegen.
- Warm-Start als Selbstverständlichkeit. Messen Sie ihn — auf Ihrer Instanzklasse, gegen den kalten Lauf. In der Hälfte der Fälle bringt er nichts.
- Einen Hinweis für eine unzulässige Lösung geben. CP-SAT und HiGHS verwerfen ihn dann stillschweigend. Prüfen Sie Ihre Startlösung vorher auf Zulässigkeit — sonst messen Sie einen Effekt, den es gar nicht gibt.
🎯 Merksatz Ein Warm-Start kann das Ergebnis nie verfälschen — im schlimmsten Fall kostet er Zeit. Genau deshalb darf man ihn ausprobieren. Aber man muss ihn auch messen, statt an ihn zu glauben.
6.9 Übungsaufgaben
Lösungen: Abschnitt A.6.
Aufgabe 6.1 ⭐ — Runden widerlegen. Konstruieren Sie selbst ein Beispiel mit zwei Variablen, bei dem Abrunden der LP-Lösung mindestens 50 % des optimalen Zielwerts verliert. Begründen Sie.
Aufgabe 6.2 ⭐ — Big-M wählen. Ein Modell enthält x_j \le M y_j mit x_j \le 250 als bekannter Kapazität. Welchen Wert sollte M haben? Was passiert bei M = 10^6?
Aufgabe 6.3 ⭐⭐ — Regeln in Ungleichungen übersetzen. Formulieren Sie mit Binärvariablen: (a) „Wenn Produkt A produziert wird, darf Produkt B nicht produziert werden.“ (b) „Mindestens zwei der vier Standorte müssen eröffnet werden.“ (c) „Wenn Standort 1 und Standort 2 eröffnet werden, muss auch das Zentrallager gebaut werden.“ (d) „Die Produktionsmenge ist entweder 0 oder liegt zwischen 500 und 2000.“ (e) „Genau eine der drei Maschinen wird eingesetzt.“
Aufgabe 6.4 ⭐⭐ — Branch-and-Bound von Hand. Lösen Sie mit Branch-and-Bound und zeichnen Sie den Suchbaum: \max\ 8x_1+11x_2+6x_3+4x_4 \quad\text{u. d. N.}\quad 5x_1+7x_2+4x_3+3x_4\le14,\quad x_i\in\{0,1\} Geben Sie für jeden Knoten die LP-Schranke an und markieren Sie, wo gekappt wird.
Aufgabe 6.5 ⭐⭐⭐ — Kardinalität variieren. Erweitern Sie MILP_Portfolio_Fixgebuehren.py so, dass es K = 1, 2, \dots, 6 durchläuft und Netto-Ertrag sowie Rechenzeit tabelliert. (a) Ab welchem K steigt der Ertrag nicht mehr? Warum? (b) Wie verhält sich die Rechenzeit? (c) Was wäre der „faire Preis“ für die Erlaubnis, eine vierte Position zu eröffnen?
Aufgabe 6.6 ⭐⭐⭐ — Big-M-Effekt messen. Ersetzen Sie im Portfoliomodell MAX_POSITION in der Big-M-Zeile durch 10^4, 10^6 und 10^9 (die echte Obergrenze bleibt in den bounds). Messen Sie jeweils Laufzeit und Zahl der Branch-and-Bound-Knoten (ergebnis.mip_node_count). Stellen Sie die Ergebnisse dar und erklären Sie sie.
Aufgabe 6.7 ⭐⭐⭐ — Standortplanung. Modellieren und lösen Sie: Fünf mögliche Lagerstandorte mit Fixkosten (80, 60, 90, 70, 50) Tsd. € versorgen vier Regionen mit Bedarf (30, 45, 25, 40) Einheiten. Transportkosten je Einheit stehen in einer 5\times4-Matrix Ihrer Wahl. Jedes eröffnete Lager hat Kapazität 80. Minimieren Sie Fix- plus Transportkosten.
6.10 Finde den Denkfehler
Die Warnung „M so klein wie möglich“ wird meist mit der Laufzeit begründet: schwache Relaxation, mehr Knoten. Das stimmt — und ist die harmlosere Hälfte der Wahrheit.
🐛 Finde den Denkfehler: Elf Lager, die keine Fixkosten kosten
Ein Team plant Standorte: 12 mögliche Lager, 40 Kunden, Fixkosten je eröffnetem Lager. Für das Big-M in der Kopplung
\sum_j x_{ij} \le M \cdot y_i
setzt jemand „sicherheitshalber“ eine sehr große Zahl ein. Das Modell läuft durch und meldet Gesamtkosten von 12 441,96 €. Mit einem knapp gewählten M hatte dasselbe Modell zuvor 26 525,28 € gemeldet. Das Team freut sich über die Einsparung.
Ihre Aufgabe: (a) Warum ist die zweite Zahl kleiner, obwohl sich am Problem nichts geändert hat? Was müsste für ein größeres M mathematisch gelten? (b) Sehen Sie sich unten die Werte der Binärvariablen an — was fällt auf, und was hat das mit der Ganzzahltoleranz des Solvers zu tun? (c) Wie viele Lager liefern in dieser „Lösung“ tatsächlich Ware, und wie viele Fixkosten werden dafür verbucht? (d) Formulieren Sie die Prüfung, die diesen Fehler in jedem MILP-Skript auffliegen ließe.
Auflösung: Abschnitt A.6.
Das folgende Programm führt beide Läufe nebeneinander aus und wendet auf beide dieselbe Prüfung an.
#!/usr/bin/env python3
# Big_M_Falle.py
"""
Kapitel MILP: Was ein zu grosses Big-M wirklich anrichtet.
Lehrbuecher warnen vor grossem M mit dem Hinweis "die Relaxation wird
schwach, der Solver braucht mehr Knoten". Das stimmt - ist aber die
harmlosere Haelfte der Wahrheit. Die gefaehrlichere: Bei sehr grossem M
kann der Solver eine Loesung als ganzzahlig ANNEHMEN, in der die
Binaervariablen bei 1e-8 stehen. Dann liefern zugeschaltete Anlagen Ware
aus, waehrend das Modell ihre Fixkosten mit 0 verbucht.
Beispiel: Standortplanung, 12 moegliche Lager, 40 Kunden.
min sum_i fix_i * y_i + sum_ij kosten_ij * x_ij
u.d.N. sum_i x_ij = bedarf_j (jeder Kunde beliefert)
sum_j x_ij <= M_i * y_i (Lager offen, wenn es liefert)
y_i binaer, x_ij >= 0
Zwei Laeufe:
1. M knapp gewaehlt (= tatsaechliche Lagerkapazitaet) -> richtig
2. M = 1e7-fach zu gross, Presolve abgeschaltet -> stilles Desaster
Beide werden mit derselben Pruefung kontrolliert, die den Fall auffliegen laesst.
Benoetigt: numpy, highspy
"""
from __future__ import annotations
import time
import numpy as np
import highspy
RNG = np.random.default_rng(11)
N_LAGER, N_KUNDE = 12, 40
FIXKOSTEN = RNG.uniform(2000, 5000, N_LAGER)
TRANSPORT = RNG.uniform(5, 40, (N_LAGER, N_KUNDE))
BEDARF = RNG.uniform(10, 60, N_KUNDE)
KAPAZITAET = BEDARF.sum() * 0.45 # jedes Lager schafft 45 % des Gesamtbedarfs
# Toleranz, ab der ein Solver eine Variable als ganzzahlig durchgehen laesst.
# HiGHS und die meisten anderen verwenden 1e-6 als Standard.
GANZZAHL_TOLERANZ = 1e-6
def loese(big_m: float, presolve: str = "on") -> dict:
"""Baut und loest das Standortmodell. Liefert Loesung und Solverkennzahlen."""
hochschule = highspy.Highs()
hochschule.setOptionValue("output_flag", False)
hochschule.setOptionValue("presolve", presolve)
hochschule.setOptionValue("time_limit", 300.0)
anzahl_x = N_LAGER * N_KUNDE
unendlich = highspy.kHighsInf
# Spalten: erst alle x_ij, dann die y_i
hochschule.addVars(anzahl_x, np.zeros(anzahl_x), np.full(anzahl_x, unendlich))
hochschule.addVars(N_LAGER, np.zeros(N_LAGER), np.ones(N_LAGER))
for i in range(N_LAGER):
hochschule.changeColIntegrality(anzahl_x + i,
highspy.HighsVarType.kInteger)
hochschule.changeColCost(anzahl_x + i, FIXKOSTEN[i])
for j in range(N_KUNDE):
hochschule.changeColCost(i * N_KUNDE + j, TRANSPORT[i, j])
# Jeder Kunde wird genau beliefert
for j in range(N_KUNDE):
index = np.array([i * N_KUNDE + j for i in range(N_LAGER)], dtype=np.int32)
hochschule.addRow(BEDARF[j], BEDARF[j], len(index), index,
np.ones(len(index)))
# Die Kopplung: sum_j x_ij - M * y_i <= 0
for i in range(N_LAGER):
index = np.array([i * N_KUNDE + j for j in range(N_KUNDE)] + [anzahl_x + i],
dtype=np.int32)
werte = np.concatenate([np.ones(N_KUNDE), [-big_m]])
hochschule.addRow(-unendlich, 0.0, len(index), index, werte)
t0 = time.perf_counter()
hochschule.run()
dauer = time.perf_counter() - t0
loesung = np.array(hochschule.getSolution().col_value)
info = hochschule.getInfo()
return {
"x": loesung[:anzahl_x].reshape(N_LAGER, N_KUNDE),
"y": loesung[anzahl_x:],
"zielwert": info.objective_function_value,
"knoten": info.mip_node_count,
"dauer": dauer,
}
def pruefe(ergebnis: dict) -> tuple[bool, list[str]]:
"""Die Pruefung, die in jedes MILP-Auswertungsskript gehoert.
Sie rechnet die Kosten AUS DER LOESUNG neu aus, statt dem Zielwert des
Solvers zu glauben - und vergleicht beide. Genau diese Gegenrechnung
entlarvt eine Loesung, in der Binaervariablen bei 1e-8 haengengeblieben
sind.
"""
beanstandungen = []
x, y = ergebnis["x"], ergebnis["y"]
# 1. Sind die Binaervariablen wirklich binaer?
abstand = np.abs(y - np.round(y))
if abstand.max() > GANZZAHL_TOLERANZ:
beanstandungen.append(
f"y ist nicht ganzzahlig: groesster Abstand {abstand.max():.2e}")
# 2. Liefert ein Lager, dessen Schalter aus ist?
liefert = x.sum(axis=1) > 1e-6
geschlossen_aber_aktiv = np.where(liefert & (y < 0.5))[0]
if geschlossen_aber_aktiv.size:
beanstandungen.append(
f"Lager {geschlossen_aber_aktiv.tolist()} liefern Ware, "
f"gelten im Modell aber als geschlossen")
# 3. Stimmt der Zielwert mit den echten Kosten ueberein?
echte_fixkosten = FIXKOSTEN[liefert].sum()
echte_transportkosten = float((TRANSPORT * x).sum())
echte_kosten = echte_fixkosten + echte_transportkosten
if abs(echte_kosten - ergebnis["zielwert"]) > 1e-4 * max(1.0, echte_kosten):
beanstandungen.append(
f"Zielwert {ergebnis['zielwert']:,.2f} weicht von den echten Kosten "
f"{echte_kosten:,.2f} ab (Differenz {echte_kosten - ergebnis['zielwert']:,.2f})")
return not beanstandungen, beanstandungen
def zeige(titel: str, ergebnis: dict) -> None:
x, y = ergebnis["x"], ergebnis["y"]
liefert = x.sum(axis=1) > 1e-6
print(f"\n{titel}")
print(f" Zielwert laut Solver {ergebnis['zielwert']:>14,.2f} EUR")
print(f" Knoten / Zeit {ergebnis['knoten']:>14,} "
f"/ {ergebnis['dauer']:.3f} s")
print(f" Lager mit y = 1 {int((y > 0.5).sum()):>14}")
print(f" Lager, die tatsaechlich liefern {int(liefert.sum()):>10}")
unter = y[y < 0.5]
print(f" groesster y-Wert unter 0.5 "
f"{(unter.max() if unter.size else 0.0):>14.3e}")
print(f" Fixkosten real / verbucht {FIXKOSTEN[liefert].sum():>14,.2f} "
f"/ {float((FIXKOSTEN * y).sum()):,.2f} EUR")
in_ordnung, beanstandungen = pruefe(ergebnis)
if in_ordnung:
print(" PRUEFUNG: bestanden")
else:
print(" PRUEFUNG: DURCHGEFALLEN")
for text in beanstandungen:
print(f" - {text}")
if __name__ == "__main__":
print("=" * 78)
print(" DIE BIG-M-FALLE: STANDORTPLANUNG, 12 LAGER, 40 KUNDEN")
print("=" * 78)
print(f"Tatsaechliche Lagerkapazitaet: {KAPAZITAET:,.1f} Einheiten.")
print("Genau das ist das kleinstmoegliche gueltige M - mehr kann ein Lager")
print("ohnehin nicht ausliefern.")
knapp = loese(KAPAZITAET)
zeige("[1] M = Kapazitaet (richtig gewaehlt)", knapp)
gross = loese(1e7 * KAPAZITAET, presolve="off")
zeige("[2] M = 10 Millionen mal Kapazitaet, Presolve abgeschaltet", gross)
print("\n" + "=" * 78)
print(" WAS DA PASSIERT IST")
print("=" * 78)
fehlbetrag = knapp["zielwert"] - gross["zielwert"]
print(f"Lauf [2] meldet {gross['zielwert']:,.2f} EUR und sieht damit um")
print(f"{fehlbetrag:,.2f} EUR BESSER aus als die richtige Loesung - ein Ergebnis,")
print("ueber das sich jeder Auftraggeber freuen wuerde.")
print()
print("Der Grund steht in der Zeile 'groesster y-Wert unter 0.5': Die")
print(f"Schaltervariablen stehen bei rund "
f"{gross['y'][gross['y'] < 0.5].max():.0e}.")
print(f"Das ist kleiner als die Ganzzahltoleranz {GANZZAHL_TOLERANZ:.0e}, also gilt")
print("y = 0 - 'Lager geschlossen'. Zugleich ist M so gross, dass")
print(" sum_j x_ij <= M * 1e-8")
print("immer noch reichlich Liefermenge erlaubt. Die Lager liefern also,")
print("ohne dass ihre Fixkosten je bezahlt werden. Der Fachbegriff dafuer")
print("ist 'trickle flow'.")
print()
print("WICHTIG: Mit eingeschaltetem Presolve (Standard) faellt HiGHS hier")
print("nicht darauf herein - es zieht M selbst zurecht. Verlassen Sie sich")
print("nicht darauf: Presolve kann das nur, wenn eine implizite Schranke")
print("herleitbar ist. Die Pruefung aus pruefe() kostet Millisekunden und")
print("funktioniert immer.")
print("=" * 78)Erwartete Ausgabe:
==============================================================================
DIE BIG-M-FALLE: STANDORTPLANUNG, 12 LAGER, 40 KUNDEN
==============================================================================
Tatsaechliche Lagerkapazitaet: 722.4 Einheiten.
Genau das ist das kleinstmoegliche gueltige M - mehr kann ein Lager
ohnehin nicht ausliefern.
[1] M = Kapazitaet (richtig gewaehlt)
Zielwert laut Solver 26,525.28 EUR
Knoten / Zeit 13 / 0.075 s
Lager mit y = 1 3
Lager, die tatsaechlich liefern 3
groesster y-Wert unter 0.5 0.000e+00
Fixkosten real / verbucht 10,671.79 / 10,671.79 EUR
PRUEFUNG: bestanden
[2] M = 10 Millionen mal Kapazitaet, Presolve abgeschaltet
Zielwert laut Solver 12,441.96 EUR
Knoten / Zeit 1 / 0.006 s
Lager mit y = 1 0
Lager, die tatsaechlich liefern 11
groesster y-Wert unter 0.5 4.074e-08
Fixkosten real / verbucht 35,847.91 / 0.00 EUR
PRUEFUNG: DURCHGEFALLEN
- Lager [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 11] liefern Ware, gelten im Modell aber als geschlossen
- Zielwert 12,441.96 weicht von den echten Kosten 48,289.86 ab (Differenz 35,847.91)
==============================================================================
WAS DA PASSIERT IST
==============================================================================
Lauf [2] meldet 12,441.96 EUR und sieht damit um
14,083.32 EUR BESSER aus als die richtige Loesung - ein Ergebnis,
ueber das sich jeder Auftraggeber freuen wuerde.
Der Grund steht in der Zeile 'groesster y-Wert unter 0.5': Die
Schaltervariablen stehen bei rund 4e-08.
Das ist kleiner als die Ganzzahltoleranz 1e-06, also gilt
y = 0 - 'Lager geschlossen'. Zugleich ist M so gross, dass
sum_j x_ij <= M * 1e-8
immer noch reichlich Liefermenge erlaubt. Die Lager liefern also,
ohne dass ihre Fixkosten je bezahlt werden. Der Fachbegriff dafuer
ist 'trickle flow'.
WICHTIG: Mit eingeschaltetem Presolve (Standard) faellt HiGHS hier
nicht darauf herein - es zieht M selbst zurecht. Verlassen Sie sich
nicht darauf: Presolve kann das nur, wenn eine implizite Schranke
herleitbar ist. Die Pruefung aus pruefe() kostet Millisekunden und
funktioniert immer.
==============================================================================
💻 Code-Durchgang
Stelle Was passiert Warum es zählt presolve="off"schaltet HiGHS’ Vorverarbeitung ab Mit Standardeinstellung fällt HiGHS nicht herein: Presolve zieht das übergroße M selbst zurecht. Der Fehler ist trotzdem real — Presolve kann das nur, wenn eine implizite Schranke im Modell steckt. Verlassen Sie sich nicht auf eine Rettung, die Sie nicht kontrollieren. np.abs(y - np.round(y))prüft, ob Binärvariablen wirklich binär sind Ein Wert von 4 \cdot 10^{-8} liegt unter der Toleranz 10^{-6} und gilt dem Solver als 0. Für Ihr Geschäft ist er es nicht. liefert & (y < 0.5)sucht Lager, die liefern, obwohl der Schalter aus ist Die inhaltliche Prüfung: Widerspricht die Lösung sich selbst? Kosten aus der Lösung neu berechnen statt dem Zielwert zu glauben Der wichtigste Test überhaupt. Er vergleicht nicht Modell mit Modell, sondern Modell mit Wirklichkeit — und deckt jeden Fehler dieser Art auf, egal wodurch er entstanden ist.
6.11 Micro-Quiz
❓ Micro-Quiz 6: Drei Fragen zum Selbstcheck
Genau eine Antwort ist jeweils richtig. Auflösung in Anhang A.
1. Ihr Solver meldet nach dem Zeitlimit: Incumbent 48 200 €, Schranke 47 100 €. Was berichten Sie dem Auftraggeber? (a) „Der Solver ist nicht fertig geworden, das Ergebnis ist unbrauchbar.“ (b) „Wir haben einen Plan für 48 200 €. Er ist garantiert höchstens 2,3 % teurer als das theoretisch bestmögliche.“ (c) „Der optimale Wert liegt bei 47 100 €, wir müssen den Plan nur noch dorthin verbessern.“
2. In Ihrem Fixkostenmodell ist M = 10^9, die tatsächliche Anlagenkapazität beträgt 500. Welche Gefahr ist die größere? (a) Der Solver braucht mehr Knoten und wird langsamer. (b) Binärvariablen können bei Werten um 10^{-8} hängenbleiben, gelten dem Solver als 0 — und Anlagen produzieren, ohne dass ihre Fixkosten je verbucht werden. (c) Die Zielfunktion wird numerisch instabil und liefert negative Kosten.
3. Sie geben CP-SAT über
AddHint()eine Startlösung mit, die eine Nebenbedingung verletzt. Was passiert? (a) Der Solver meldetINFEASIBLE, weil der Hinweis unzulässig ist. (b) Der Solver übernimmt den Hinweis und liefert eine unzulässige Lösung. (c) Der Hinweis wird verworfen; das Ergebnis bleibt korrekt, der erhoffte Zeitgewinn bleibt aber aus.
6.12 Selbsttest
Antworten: Anhang A.
- Warum liefert die LP-Relaxation bei Maximierung eine obere Schranke?
- Nennen Sie die drei Gründe, aus denen ein Branch-and-Bound-Ast gekappt werden darf.
- Wie modelliert man „x ist entweder 0 oder mindestens 500“?
- Warum verschlechtert ein zu großes M die Laufzeit, obwohl das Modell korrekt bleibt?
- Was bedeutet ein MIP-Gap von 3 %?
6.13 Zusammenfassung
- Runden ist keine Lösung: Aufrunden ist fast immer unzulässig (98–100 % der Fälle), Abrunden verliert im Mittel 10–26 %, im Extremfall alles.
- Branch-and-Bound löst LP-Relaxationen, verzweigt an gebrochenen Variablen und kappt Äste anhand von Schranken. Der Preis: NP-Schwere.
- Fünf Modellierungsmuster decken die meisten Praxisfälle ab: Fixkosten, Entweder-Oder, Wenn-Dann, Kardinalität, semikontinuierlich.
- M so klein wie möglich — nicht nur wegen der Laufzeit. Ein übergroßes M kann Binärvariablen bei 10^{-8} hängen lassen, wo sie dem Solver als 0 gelten: Anlagen produzieren, ohne dass ihre Fixkosten je verbucht werden (Trickle Flow).
OPTIMAList nicht der Normalfall. Werten Sie jeden Status aus und lesen Sie den MIP-Gap — er ist eine Garantie („höchstens 2,3 % vom Optimum“), keine Schätzung. Der Beweis der Optimalität kostet meist ein Vielfaches dessen, was das Finden der optimalen Lösung kostet.- Warm-Starts können nichts verfälschen, aber auch nichts versprechen. Ob ein Startwert hilft, hängt davon ab, ob er besser ist als das, was der Solver in derselben Zeit von allein findet — im Buch gemessen: einmal Faktor 2,2, einmal gar nichts.
- Prüfen Sie jede modellierte Regel nach dem Lösen mit einer
assert-Zeile — und rechnen Sie den Zielwert aus der Lösung nach, statt ihm zu glauben.
Ausblick. Kapitel 7 wechselt das Paradigma: Bei Dienstplänen, Zuordnungen und Reihenfolgen ist die algebraische MILP-Sicht oft unhandlich. Constraint Programming denkt stattdessen in Wertebereichen und logischen Regeln — und löst solche Probleme um Größenordnungen schneller.