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.py

Notebook: milp.ipynb — herunterladen und in Jupyter öffnen, in Colab hochladen oder mit dem Kurs-Image starten


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 …

  1. … an einem Zahlenbeispiel belegen, warum Runden einer LP-Lösung scheitert.
  2. … den Ablauf von Branch-and-Bound erklären und einen kleinen Suchbaum von Hand zeichnen.
  3. … die vier klassischen Big-M-Muster anwenden (Fixkosten, Entweder-Oder, Wenn-Dann, Kardinalität).
  4. … begründen, warum M so klein wie möglich gewählt werden muss.
  5. … ein Portfolioproblem mit Ordergebühren und Höchstzahl an Positionen lösen.
  6. Incumbent, Schranke und MIP-Gap unterscheiden und aus einem abgebrochenen Solverlauf eine belastbare Aussage ableiten.
  7. … alle Solver-Statusfälle explizit behandeln, statt OPTIMAL vorauszusetzen.
  8. … 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.

Abb. 6.1: Links die Handrechnung von unten als Bild: Die Zielfunktion läuft parallel zur Restriktion, also ist die ganze Kante optimal. Aufrunden auf (1;1) führt aus dem zulässigen Bereich heraus, Abrunden auf (0;0) auf den Wert null. Rechts die Messung aus Runden_Gegenbeispiel.py über je 200 Zufallsinstanzen. Erzeugt von bilder_04/erzeuge_runden.py.

✏️ 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 > 3unzulä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:

  1. Verletzung von Nebenbedingungen. Aufrunden überschreitet Kapazitätsgrenzen — die Lösung wird unzulässig.
  2. Suboptimalität. Abrunden verschenkt Kapazität. Der Verlust ist umso größer, je kleiner die Zahlen sind.
  3. 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.

Abb. 6.2: Der Suchbaum der Handrechnung weiter unten — nicht gezeichnet, sondern gerechnet: Das Skript löst die LP-Relaxationen und kappt nach denselben drei Regeln, die oben stehen. Ast B wird verworfen, weil sein Wert 18 den Incumbent 20 aus Ast A nicht schlägt. Erzeugt von bilder_04/erzeuge_branch_and_bound.py.

Die drei Phasen

  1. 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.
  2. 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.
  3. 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 = 19ganzzahlig! Incumbent = 19. * A2 (x_2\le1, x_1\ge4): x = (4;\ 0), Z = 20ganzzahlig! 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.py rechnet 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:

  1. Die LP-Relaxation packt zwei Drittel Kocher ein — physikalisch unsinnig, als Schranke aber wertvoll: „Mehr als 111,33 ist unmöglich.“
  2. 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.
  3. 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.py

Das Programm wertet den Solver nicht über ergebnis.success aus, sondern über status_von_scipy() und das Loesung-Objekt aus dem gemeinsamen Unterbau (Abschnitt 22.6). Der Unterschied ist kein Schönheitsfehler:

  • success ist 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 Ausgabe Gap: 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 linprog minimiert), Gebühren positiv (sie sollen ja gedrückt werden)
A_eq[0, :N] = 1.0 Budgetgleichung betrifft nur die x-Variablen
zeile[i]=1; zeile[N+i]=-MAX_POSITION Muster 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_POSITION Muster 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“ getrennt
schranke=-ergebnis.mip_dual_bound die zweite Zahl aus Abschnitt 6.8. Erst mit ihr kann das Loesung-Objekt den Gap ausrechnen — und der Bericht sagt Gap: 0.00%, also beweisbar optimal
pruefe_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 -
    # der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming).
    # 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 meldet INFEASIBLE, 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.

  1. Warum liefert die LP-Relaxation bei Maximierung eine obere Schranke?
  2. Nennen Sie die drei Gründe, aus denen ein Branch-and-Bound-Ast gekappt werden darf.
  3. Wie modelliert man „x ist entweder 0 oder mindestens 500“?
  4. Warum verschlechtert ein zu großes M die Laufzeit, obwohl das Modell korrekt bleibt?
  5. 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).
  • OPTIMAL ist 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.