Teil II: Die Kernverfahren der deterministischen Optimierung

Die nächsten vier Kapitel behandeln Probleme, bei denen alle Daten bekannt sind: Kosten, Kapazitäten, Zeiten stehen fest. Das klingt nach einer starken Vereinfachung, deckt aber den größten Teil der betrieblichen Planung ab — und ist die Grundlage für alles Weitere.

Vier Kapitel, vier Werkzeugkästen. Welcher zu Ihrem Problem passt, entscheiden Sie mit vier Fragen:

Entscheidungsdiagramm: Welches Werkzeug passt zu meinem Problem? (Teil II)

🎯 Merksatz zum Diagramm Die erste Frage ist die wichtigste — und sie lautet nicht „welcher Solver ist der schnellste?“, sondern „welche Struktur hat mein Problem?“. Ein Zuordnungsproblem braucht keinen MILP-Solver, sondern eine Zeile linear_sum_assignment. Ein Dienstplan gehört nicht in ein LP, sondern zu CP-SAT. Wer die Struktur erkennt, hat die halbe Laufzeit gespart, bevor die erste Zeile Code geschrieben ist.

Fällt Ihr Problem durch das Diagramm hindurch — nichtlineare Zielfunktion, unsichere Daten, Entscheidungen über mehrere Zeitstufen —, dann ist Teil III der richtige Ort. Dort steht ein zweites Diagramm für genau diese Fälle.


Kapitel 5: Lineare Programmierung — Simplex, Dualität und Schattenpreise

📌 Kapitel auf einen Blick

Worum geht es? Um das Kernverfahren des Operations Research. Wir bauen den Simplex-Algorithmus selbst — nicht um ihn zu verwenden, sondern um zu verstehen, woher Schattenpreise kommen. Sie sind das wertvollste Nebenprodukt jeder Optimierung.

Voraussetzungen: Kapitel 2 (Matrixform, Polyeder, Ecken).

Danach können Sie: Ein LP in Standardform bringen, den Simplex von Hand rechnen, Dualwerte interpretieren, daraus wirtschaftliche Handlungsempfehlungen ableiten — und erkennen, wann ein Schattenpreis eine Entscheidung nicht tragen darf.

Zeitbedarf: ca. 7 Stunden — das umfangreichste Kapitel von Teil II.

Programme:
Simplex_Tableau_LP.py
Sensitivitaetsanalyse.py
Dualitaet_Nachweis.py
Toleranzen_und_Entartung.py

Notebook: lp.ipynb
In Google Colab öffnen


5.1 In 5 Minuten gelöst

Bisher hat ein Solver Ihnen gesagt, was Sie tun sollen. Jetzt sagt er Ihnen zusätzlich, was eine zusätzliche Stunde wert wäre — und das ist die Zahl, für die Sie im Betrieb gehört werden.

🚀 In 5 Minuten gelöst: Wo lohnen sich Überstunden?

Ein Zulieferer fertigt Karosserieteile und Rahmen. Drei Bereiche begrenzen ihn: der Lackierofen (100 h), die Montage (160 h) und die Qualitätsprüfung (120 h).

Produkt Deckungsbeitrag Lackierofen Montage Prüfung
Karosserieteil 90 € 1 h 2 h 1 h
Rahmen 140 € 2 h 2 h 1 h

Der Betriebsrat hat 20 Überstunden genehmigt. In welchem Bereich sollen sie eingesetzt werden?

from scipy.optimize import linprog

# Deckungsbeitrag je Stueck (negiert, weil linprog minimiert)
c = [-90, -140]
A = [[1, 2],    # Lackierofen  (Stunden je Stueck)
     [2, 2],    # Montage
     [1, 1]]    # Qualitaetspruefung
b = [100, 160, 120]                       # verfuegbare Stunden
name = ["Lackierofen", "Montage", "Pruefung"]

res = linprog(c, A_ub=A, b_ub=b, bounds=(0, None))
print(f"Plan: {res.x[0]:.0f} Karosserieteile, {res.x[1]:.0f} Rahmen "
      f"-> {-res.fun:,.0f} EUR")
for i, y in enumerate(-res.ineqlin.marginals):
    print(f"  eine Stunde {name[i]:<12} mehr waere wert: {y:6.2f} EUR")

Ausgabe:

Plan: 60 Karosserieteile, 20 Rahmen -> 8,200 EUR
  eine Stunde Lackierofen  mehr waere wert:  50.00 EUR
  eine Stunde Montage      mehr waere wert:  20.00 EUR
  eine Stunde Pruefung     mehr waere wert:   0.00 EUR

Die Antwort steht in der letzten Spalte, und sie ist eindeutig: Die 20 Überstunden gehören an den Lackierofen. Jede Stunde dort bringt 50 € zusätzlichen Deckungsbeitrag, in der Montage nur 20 € — und in der Qualitätsprüfung null, denn dort sind 40 Stunden ohnehin unbenutzt. Wer die Überstunden gleichmäßig verteilt hätte, verschenkt gegenüber der richtigen Zuteilung rund 400 €.

Diese drei Zahlen heißen Schattenpreise (oder Dualwerte). Sie sind das eigentliche Produkt einer Optimierung — wertvoller als der Plan selbst, weil sie eine Frage beantworten, die der Plan gar nicht stellt: Woran hängt es, und was wäre eine Verbesserung wert?

🎯 Merksatz Der Plan sagt Ihnen, was Sie heute tun sollen. Der Schattenpreis sagt Ihnen, worin Sie morgen investieren sollen. Ein Schattenpreis von 0 ist dabei genauso wertvoll wie ein hoher: Er beweist, dass diese Ressource nicht der Engpass ist — und bewahrt Sie vor einer Investition, die nichts bringt.

Warum funktioniert das? Der Schattenpreis fällt beim Lösen nebenbei ab. Er ist die Lösung eines zweiten, spiegelbildlichen Problems, das der Solver implizit mitlöst: nicht „welche Mengen produziere ich?“, sondern „was sind meine Ressourcen wert?“. Dieses Zwillingsproblem heißt duales Problem, und der Rest des Kapitels erklärt, woher es kommt, warum seine Lösung exakt denselben Zielwert hat — und in welchen zwei Fällen man Schattenpreisen nicht trauen darf.


5.2 Lernziele

Nach diesem Kapitel können Sie …

  1. … Schlupfvariablen einführen und ein Ungleichungssystem in ein Gleichungssystem überführen.
  2. … eine Simplex-Iteration von Hand rechnen: Pivotspalte, Pivotzeile, Basistausch.
  3. … erklären, warum das Verfahren terminiert und warum es das Optimum findet.
  4. … das duale Problem zu einem gegebenen primalen aufstellen.
  5. Schattenpreise korrekt auslesen und interpretieren — inklusive der Vorzeichenfalle, die einer der häufigsten Fehler bei der Sensitivitätsanalyse ist.
  6. … aus einer Sensitivitätsanalyse eine begründete Kaufentscheidung ableiten.
  7. … ein Modell auf Entartung prüfen und statt eines nicht eindeutigen Schattenpreises dessen belastbare Spanne berechnen.
  8. … erklären, warum Zulässigkeit immer gegen eine Toleranz und nie mit == 0 geprüft wird.

5.3 Die Standardform und Schlupfvariablen

Lineare Programme zeichnen sich dadurch aus, dass Zielfunktion und alle Nebenbedingungen strikt linear sind — keine Produkte von Variablen, keine Quadrate, keine Logarithmen.

Ein Ungleichungssystem ist rechnerisch unhandlich. Gleichungssysteme dagegen kann man mit Gauß-Elimination lösen. Die Brücke bilden Schlupfvariablen (slack variables) s_i \ge 0:

\sum_{j=1}^n a_{ij}x_j \le b_i \qquad\Longleftrightarrow\qquad \sum_{j=1}^n a_{ij}x_j + s_i = b_i,\quad s_i \ge 0

📐 Formel-Lesehilfe Links steht „der Verbrauch ist höchstens die Kapazität“. Rechts steht „der Verbrauch plus ein Rest ist genau die Kapazität“.

Ohne Formel gesagt: Statt zu sagen „ich verbrauche höchstens 60 GB“, sagt man „ich verbrauche 60 GB minus dem, was übrig bleibt“. Der Rest bekommt einen Namen — und wird dadurch zu einer Variablen, mit der man rechnen kann.

Die ökonomische Bedeutung von s_i ist der eigentliche Gewinn dieser Umformung:

s_i Bedeutung Fachbegriff
s_i = 0 Ressource i ist vollständig ausgelastet bindende Nebenbedingung, Engpass (bottleneck)
s_i > 0 Es sind s_i Einheiten übrig nicht-bindende Nebenbedingung, Reserve

🎯 Merksatz Die Schlupfvariable ist kein Rechentrick, sondern eine Messgröße: Sie sagt Ihnen, welche Ressource Ihr Geschäft begrenzt. Und nur wo sie null ist, kann es sich lohnen, Kapazität nachzukaufen.


5.4 Der Simplex-Algorithmus Schritt für Schritt

Der von George Dantzig 1947 formulierte Simplex-Algorithmus nutzt den Fundamentalsatz aus Kapitel 2: Das Optimum liegt in einer Ecke. Statt alle Ecken aufzuzählen, wandert er von Ecke zu Ecke — und zwar immer in Richtung Verbesserung.

Abb. 5.1: Derselbe zulässige Bereich, aber der Weg der Handrechnung weiter unten: vom Ursprung über (0; 8) zum Optimum (7,5; 5). Drei der fünf Ecken werden besucht, keine zweimal — das ist der ganze Gewinn gegenüber dem Aufzählen aller Ecken. Erzeugt von bilder_04/erzeuge_polyeder.py.

Der Ablauf

  1. Initialisierung. Starte an einer bekannten Ecke — meist dem Ursprung \mathbf{x} = \mathbf{0}, bei dem alle Schlupfvariablen ihre Kapazität aufnehmen: \mathbf{s} = \mathbf{b}. Das ist zulässig, solange \mathbf{b} \ge \mathbf{0}.
  2. Optimalitätsprüfung. Sieh dir die Zielfunktionszeile an. Gibt es eine Variable, deren Aufnahme in die Basis den Zielwert noch verbessern würde? → Nein: fertig, das Optimum ist erreicht.
  3. Pivotspalte wählen (eintretende Variable). Diejenige Variable mit dem größten Verbesserungspotenzial pro Einheit (Dantzig-Regel).
  4. Pivotzeile wählen (austretende Variable). Der minimale Quotient \min_i \{ b_i / a_{ik} \mid a_{ik} > 0 \}. Das ist die Bedingung, die als erste erschöpft ist, wenn man die neue Variable hochfährt.
  5. Basistausch (Gauß-Jordan-Schritt). Eliminiere die eintretende Variable aus allen anderen Zeilen. Weiter bei Schritt 2.

✏️ Handrechnung 5.1: Eine vollständige Simplex-Rechnung

Wir lösen von Hand: \max\ Z = 150x_1 + 250x_2 \quad\text{u. d. N.}\quad 2x_1+5x_2 \le 40,\quad 4x_1+6x_2 \le 60,\quad x_1 \le 8,\quad x_1,x_2 \ge 0

Schritt 0 — Schlupfvariablen einführen: \begin{aligned} 2x_1+5x_2+s_1 &= 40\\ 4x_1+6x_2+s_2 &= 60\\ x_1+s_3 &= 8\\ Z - 150x_1 - 250x_2 &= 0 \end{aligned}

Starttableau (Basis: s_1, s_2, s_3; also x_1 = x_2 = 0, Z = 0):

Basis x_1 x_2 s_1 s_2 s_3 RHS
s_1 2 5 1 0 0 40
s_2 4 6 0 1 0 60
s_3 1 0 0 0 1 8
Z −150 −250 0 0 0 0

Iteration 1. Pivotspalte: In der Z-Zeile ist -250 am negativsten → x_2 tritt ein. Pivotzeile: Quotienten 40/5 = 8, 60/6 = 10, 8/0 = \inftyZeile 1 (kleinster Quotient 8). Pivotelement ist die 5.

Pivotzeile durch 5 teilen: (0{,}4;\ 1;\ 0{,}2;\ 0;\ 0 \mid 8). Dann x_2 aus den anderen Zeilen eliminieren: * Zeile 2 -\ 6 \cdot Pivotzeile: (4-2{,}4;\ 0;\ -1{,}2;\ 1;\ 0 \mid 60-48) = (1{,}6;\ 0;\ -1{,}2;\ 1;\ 0 \mid 12) * Zeile 3 bleibt (Koeffizient war 0). * Z-Zeile +\ 250 \cdot Pivotzeile: (-150+100;\ 0;\ 50;\ 0;\ 0 \mid 2000) = (-50;\ 0;\ 50;\ 0;\ 0 \mid 2000)

Basis x_1 x_2 s_1 s_2 s_3 RHS
x_2 0,4 1 0,2 0 0 8
s_2 1,6 0 −1,2 1 0 12
s_3 1 0 0 0 1 8
Z −50 0 50 0 0 2000

Zwischenstand: x_2 = 8, Z = 2000 — das ist genau die naive Lösung „nur Trendfolge“ aus Kapitel 1. Der Simplex ist noch nicht fertig, denn in der Z-Zeile steht noch -50.

Iteration 2. Pivotspalte: x_1 (einziger negativer Eintrag, -50). Pivotzeile: Quotienten 8/0{,}4 = 20, 12/1{,}6 = 7{,}5, 8/1 = 8Zeile 2 (kleinster Quotient 7,5). Pivotelement 1,6.

Pivotzeile durch 1,6: (1;\ 0;\ -0{,}75;\ 0{,}625;\ 0 \mid 7{,}5). Eliminieren: * Zeile 1 -\ 0{,}4\cdot: (0;\ 1;\ 0{,}5;\ -0{,}25;\ 0 \mid 5) * Zeile 3 -\ 1\cdot: (0;\ 0;\ 0{,}75;\ -0{,}625;\ 1 \mid 0{,}5) * Z-Zeile +\ 50\cdot: (0;\ 0;\ 12{,}5;\ 31{,}25;\ 0 \mid 2375)

Basis x_1 x_2 s_1 s_2 s_3 RHS
x_2 0 1 0,5 −0,25 0 5
x_1 1 0 −0,75 0,625 0 7,5
s_3 0 0 0,75 −0,625 1 0,5
Z 0 0 12,5 31,25 0 2375

Alle Einträge der Z-Zeile sind \ge 0 → Optimum erreicht.

Ablesen: x_1^* = 7{,}5, x_2^* = 5, Z^* = 2375. Das deckt sich exakt mit der Eckenrechnung aus Kapitel 2 — und wir haben statt fünf Ecken nur drei besucht.

Und jetzt der Clou: Die Zahlen in der Z-Zeile unter den Schlupfvariablen sind die Schattenpreise: * s_1 (vCPU): y_1^* = 12{,}5 €/vCPU * s_2 (RAM): y_2^* = 31{,}25 €/GB * s_3 (Marktlimit): y_3^* = 0

Der letzte Wert ist null, weil s_3 = 0{,}5 > 0 — es ist noch Luft beim Arbitrage-Limit, also ist eine Erhöhung dieser Grenze nichts wert. Genau das besagt der Satz vom komplementären Schlupf, den wir in Abschnitt 5.6 formulieren.


5.5 Das Simplex-Tableau in Python

#!/usr/bin/env python3

# Simplex_Tableau_LP.py
"""
Kapitel LP: Vollständige Implementierung des Simplex-Algorithmus (Tableau-Methode).

GRENZEN DIESER IMPLEMENTIERUNG (bewusst, aus didaktischen Gründen):
  * nur Maximierung
  * nur "<="-Nebenbedingungen
  * alle b_i >= 0   (sonst wäre der Ursprung keine zulässige Startecke und man
    bräuchte eine Phase-1-Rechnung mit künstlichen Variablen)
Für den produktiven Einsatz nimmt man HiGHS - dieser Code dient dem Verständnis.

Voraussetzungen werden geprüft statt stillschweigend angenommen;
Iterationsprotokoll und Schattenpreise werden ausgegeben.
"""

import numpy as np


class SimplexTableauSolver:
    """Maximierungs-Standardform:  max c^T x  u.d.N.  A x <= b,  x >= 0,  b >= 0."""

    def __init__(self, c, A, b, variablennamen=None, restriktionsnamen=None):
        self.c = np.asarray(c, dtype=float)
        self.A = np.asarray(A, dtype=float)
        self.b = np.asarray(b, dtype=float)
        self.n = len(self.c)                    # Anzahl Originalvariablen
        self.m = len(self.b)                    # Anzahl Nebenbedingungen

        # --- Voraussetzungen pruefen, statt sie stillschweigend anzunehmen ---
        if self.A.shape != (self.m, self.n):
            raise ValueError(f"A hat Form {self.A.shape}, erwartet ({self.m}, {self.n}).")
        if np.any(self.b < 0):
            raise ValueError(
                "Mindestens ein b_i ist negativ. Dann ist der Ursprung keine zulässige "
                "Startecke; dieser Solver benötigt eine Phase-1-Rechnung, die hier "
                "bewusst nicht implementiert ist. Nutzen Sie scipy.optimize.linprog."
            )

        self.var_namen = variablennamen or [f"x{j+1}" for j in range(self.n)]
        self.restr_namen = restriktionsnamen or [f"R{i+1}" for i in range(self.m)]

        self.tableau = None
        self.basis = None                       # welche Variable ist in welcher Zeile Basis?
        self._baue_starttableau()

    def _baue_starttableau(self):
        """Zeilen: m Nebenbedingungen + Zielfunktionszeile.
           Spalten: n Variablen + m Schlupfvariablen + rechte Seite."""
        m, n = self.m, self.n
        self.tableau = np.zeros((m + 1, n + m + 1))
        self.tableau[:m, :n] = self.A                    # Koeffizienten
        self.tableau[:m, n:n + m] = np.eye(m)            # Schlupfvariablen
        self.tableau[:m, -1] = self.b                    # rechte Seite
        self.tableau[-1, :n] = -self.c                   # Zielzeile: -c (Maximierung)
        self.basis = list(range(n, n + m))               # Start: alle Schlupf in der Basis

    def _spaltenname(self, index):
        return self.var_namen[index] if index < self.n else f"s{index - self.n + 1}"

    def solve(self, max_iterationen=100, protokoll=True):
        m, n = self.m, self.n
        if protokoll:
            print(f"{'Iter':>4} | {'eintritt':>9} | {'austritt':>9} | "
                  f"{'Pivot':>8} | {'Z':>12}")
            print("-" * 58)

        for iteration in range(1, max_iterationen + 1):
            zielzeile = self.tableau[-1, :-1]

            # 1. Optimalitätsprüfung: alle Koeffizienten >= 0 ?
            if np.all(zielzeile >= -1e-9):
                if protokoll:
                    print("-" * 58)
                    print(f"Optimum nach {iteration - 1} Pivotschritten erreicht.")
                return self._loesung_auslesen()

            # 2. Pivotspalte: negativster Eintrag (Dantzig-Regel)
            pivot_spalte = int(np.argmin(zielzeile))

            # 3. Pivotzeile: minimaler Quotient über POSITIVE Spalteneinträge
            spalte = self.tableau[:m, pivot_spalte]
            rechte_seite = self.tableau[:m, -1]
            quotienten = np.where(spalte > 1e-9, rechte_seite / np.where(spalte > 1e-9, spalte, 1),
                                  np.inf)
            pivot_zeile = int(np.argmin(quotienten))
            if not np.isfinite(quotienten[pivot_zeile]):
                raise ValueError(
                    f"Problem ist unbeschraenkt: Variable {self._spaltenname(pivot_spalte)} "
                    "kann beliebig wachsen, ohne eine Bedingung zu verletzen. "
                    "Meist fehlt eine Kapazitaetsbeschraenkung."
                )

            if protokoll:
                print(f"{iteration:>4} | {self._spaltenname(pivot_spalte):>9} | "
                      f"{self._spaltenname(self.basis[pivot_zeile]):>9} | "
                      f"{self.tableau[pivot_zeile, pivot_spalte]:>8.3f} | "
                      f"{self.tableau[-1, -1]:>12,.2f}")

            # 4. Pivotoperation (Gauß-Jordan)
            pivot_wert = self.tableau[pivot_zeile, pivot_spalte]
            self.tableau[pivot_zeile, :] /= pivot_wert
            for zeile in range(m + 1):
                if zeile != pivot_zeile:
                    faktor = self.tableau[zeile, pivot_spalte]
                    self.tableau[zeile, :] -= faktor * self.tableau[pivot_zeile, :]

            self.basis[pivot_zeile] = pivot_spalte

        raise RuntimeError("Maximale Iterationszahl ueberschritten (moeglicherweise Zyklus).")

    def _loesung_auslesen(self):
        """Basisvariablen tragen den RHS-Wert ihrer Zeile, Nichtbasisvariablen sind 0."""
        x = np.zeros(self.n + self.m)
        for zeile, spalte in enumerate(self.basis):
            x[spalte] = self.tableau[zeile, -1]
        return x[:self.n], x[self.n:], self.tableau[-1, -1]

    def schattenpreise(self):
        """Die Zielzeile unter den Schlupfspalten enthält direkt die Dualwerte."""
        return self.tableau[-1, self.n:self.n + self.m].copy()


if __name__ == "__main__":
    # Modell aus der Simplex-Handrechnung (Bot-Beispiel, Kapitel Einfuehrung)
    ertraege = [150.0, 250.0]
    matrix = [[2.0, 5.0],
              [4.0, 6.0],
              [1.0, 0.0]]
    kapazitaeten = [40.0, 60.0, 8.0]
    var_namen = ["x_A", "x_B"]
    restr_namen = ["vCPU", "RAM", "Marktlimit"]

    print("=" * 58)
    print("  SIMPLEX-TABLEAU: ITERATIONSPROTOKOLL")
    print("=" * 58)

    solver = SimplexTableauSolver(ertraege, matrix, kapazitaeten, var_namen, restr_namen)
    x_opt, schlupf, z_opt = solver.solve()

    print("\n" + "=" * 58)
    print("  ERGEBNIS")
    print("=" * 58)
    for name, wert in zip(var_namen, x_opt):
        print(f"  {name:<12} = {wert:8.4f}")
    print(f"  {'Zielwert Z':<12} = {z_opt:8.2f} EUR")

    print("\n  Ressourcenanalyse:")
    print(f"  {'Ressource':<12} {'Schlupf':>9} {'Status':>22} {'Schattenpreis':>15}")
    print("  " + "-" * 60)
    for name, s, y in zip(restr_namen, schlupf, solver.schattenpreise()):
        status = "ENGPASS (bindend)" if abs(s) < 1e-9 else "Reserve vorhanden"
        print(f"  {name:<12} {s:>9.3f} {status:>22} {y:>12.2f} EUR")

    # Selbstkontrolle: komplementaerer Schlupf muss gelten
    for s, y in zip(schlupf, solver.schattenpreise()):
        assert abs(s * y) < 1e-6, "Komplementaerer Schlupf verletzt - Rechenfehler!"
    print("\n  Pruefung: komplementaerer Schlupf (s_i * y_i = 0) fuer alle i erfuellt.")
    print("=" * 58)

Erwartete Ausgabe:

==========================================================
  SIMPLEX-TABLEAU: ITERATIONSPROTOKOLL
==========================================================
Iter |  eintritt |  austritt |    Pivot |            Z
----------------------------------------------------------
   1 |       x_B |        s1 |    5.000 |         0.00
   2 |       x_A |        s2 |    1.600 |     2,000.00
----------------------------------------------------------
Optimum nach 2 Pivotschritten erreicht.

==========================================================
  ERGEBNIS
==========================================================
  x_A          =   7.5000
  x_B          =   5.0000
  Zielwert Z   =  2375.00 EUR

  Ressourcenanalyse:
  Ressource      Schlupf                 Status   Schattenpreis
  ------------------------------------------------------------
  vCPU             0.000       ENGPASS (bindend)        12.50 EUR
  RAM              0.000       ENGPASS (bindend)        31.25 EUR
  Marktlimit       0.500       Reserve vorhanden         0.00 EUR

  Pruefung: komplementaerer Schlupf (s_i * y_i = 0) fuer alle i erfuellt.
==========================================================

Die Ausgabe reproduziert die Simplex-Handrechnung exakt — inklusive der Schattenpreise 12,50 € und 31,25 €.

💻 Code-Durchgang

Stelle Was passiert Warum
if np.any(self.b < 0): raise Voraussetzung geprüft Ohne diese Prüfung liefert der Solver hier still ein falsches Ergebnis. Ein Solver, der seine Annahmen nicht prüft, ist gefährlich
self.tableau[-1, :n] = -self.c Zielzeile negativ eintragen Trick, damit „alle Einträge \ge 0“ das Abbruchkriterium wird
np.argmin(zielzeile) Dantzig-Regel Größte Verbesserung pro Einheit; andere Regeln (Bland) vermeiden Zyklen, sind aber langsamer
quotienten = np.where(spalte > 1e-9, ...) Minimum-Quotienten-Test Nur positive Spalteneinträge zählen — sonst würde eine Variable negativ
raise ValueError("unbeschraenkt") alle Quotienten unendlich Diagnose statt Absturz: „Ihnen fehlt eine Nebenbedingung“
self.basis[pivot_zeile] = pivot_spalte Basiswechsel protokollieren Ohne diese Buchführung kann man die Lösung am Ende nicht auslesen
assert abs(s*y) < 1e-6 komplementärer Schlupf Eine Zeile, die jeden Vorzeichenfehler sofort aufdeckt

⚠️ Typische Fehler

  • Negative rechte Seite. -3x_1 - 2x_2 \le -12 (aus einer „\ge“-Bedingung entstanden) macht den Ursprung unzulässig. Dann braucht man Phase 1 mit künstlichen Variablen. Dieser Solver meldet das jetzt ehrlich.
  • Zyklen bei Entartung. Wenn mehrere Quotienten gleich sind, kann der Simplex theoretisch endlos kreisen. In der Praxis extrem selten; Bland’s Regel verhindert es beweisbar.
  • Den Simplex selbst produktiv einsetzen. Diese Implementierung ist ein Lehrmodell. HiGHS ist um Größenordnungen schneller und numerisch stabiler.

5.6 Dualität und Schattenpreise

Zu jedem linearen Optimierungsproblem (dem primalen Problem) existiert ein spiegelbildliches Zwillingsproblem: das duale Problem.

Abb. 5.2: Primales und duales Problem
Primal (Unternehmer) Dual (Ressourcenbewerter)
Fragestellung „Wie viel produziere ich?“ „Was sind meine Ressourcen wert?“
Zielfunktion \max\ \mathbf{c}^\top\mathbf{x} \min\ \mathbf{b}^\top\mathbf{y}
Nebenbedingungen \mathbf{A}\mathbf{x} \le \mathbf{b} \mathbf{A}^\top\mathbf{y} \ge \mathbf{c}
Variablen \mathbf{x} \ge \mathbf{0} (Mengen) \mathbf{y} \ge \mathbf{0} (Preise)

📐 Formel-Lesehilfe zum dualen Problem * \mathbf{b}^\top\mathbf{y} — der Gesamtwert aller vorhandenen Kapazitäten, bewertet mit den Preisen \mathbf{y}. * \mathbf{A}^\top\mathbf{y} \ge \mathbf{c} — für jedes Produkt muss gelten: Der Wert der Ressourcen, die eine Einheit verbraucht, ist mindestens so hoch wie ihr Verkaufserlös.

Ohne Formel gesagt: Ein gedachter Käufer will Ihnen die gesamten Ressourcen abkaufen. Er will möglichst wenig zahlen (Minimierung), muss Ihnen aber pro Ressourcenbündel mindestens so viel bieten, wie Sie durch eigene Produktion verdienen würden — sonst lehnen Sie ab.

Der starke Dualitätssatz

Satz. Besitzt das primale Problem eine endliche Optimallösung \mathbf{x}^*, so besitzt auch das duale eine Optimallösung \mathbf{y}^*, und die Zielwerte sind exakt gleich: \mathbf{c}^\top\mathbf{x}^* = \mathbf{b}^\top\mathbf{y}^*

Prüfen wir das an unserem Beispiel: \mathbf{b}^\top\mathbf{y}^* = 40 \cdot 12{,}5 + 60 \cdot 31{,}25 + 8 \cdot 0 = 500 + 1875 + 0 = 2375 ✓ — identisch mit Z^* = 2375.

Das lässt sich auch unabhängig von der Handrechnung nachweisen: Primal- und Dualproblem werden als zwei getrennte, voneinander unabhängige LPs aufgestellt und gelöst — wenn der starke Dualitätssatz stimmt, müssen beide denselben Zielwert liefern, ohne dass eines vom anderen „weiß“.

#!/usr/bin/env python3

# Dualitaet_Nachweis.py
"""
Kapitel LP: Primales und duales Problem unabhaengig loesen und den starken
Dualitaetssatz sowie den komplementaeren Schlupf numerisch nachweisen.

Modell aus der Simplex-Handrechnung (Bot-Allokation, LP-Relaxation):
    max 150*x1 + 250*x2  u.d.N.  2*x1+5*x2<=40, 4*x1+6*x2<=60, x1<=8, x>=0
"""

import numpy as np
from scipy.optimize import linprog

# --- Primales Problem --------------------------------------------------------
C_PRIMAL = np.array([150.0, 250.0])
A_PRIMAL = np.array([[2.0, 5.0], [4.0, 6.0], [1.0, 0.0]])
B_PRIMAL = np.array([40.0, 60.0, 8.0])

# --- Duales Problem: min b^T y  u.d.N.  A^T y >= c, y >= 0 ------------------
#     linprog kennt nur <=, also A^T y >= c  <=>  -A^T y <= -c
C_DUAL = B_PRIMAL
A_DUAL = -A_PRIMAL.T
B_DUAL = -C_PRIMAL


def loese():
    primal = linprog(c=-C_PRIMAL, A_ub=A_PRIMAL, b_ub=B_PRIMAL,
                      bounds=[(0, None)] * 2, method="highs")
    dual = linprog(c=C_DUAL, A_ub=A_DUAL, b_ub=B_DUAL,
                    bounds=[(0, None)] * 3, method="highs")
    if not primal.success or not dual.success:
        raise SystemExit("Primal oder Dual nicht loesbar.")
    return primal, dual


if __name__ == "__main__":
    primal, dual = loese()
    x = primal.x
    y = dual.x
    z_primal = -primal.fun
    z_dual = dual.fun

    print("=" * 78)
    print("  PRIMALES PROBLEM")
    print("=" * 78)
    print(f"x* = ({x[0]:.4f}, {x[1]:.4f})")
    print(f"Z* = {z_primal:.4f}")

    print("\n" + "=" * 78)
    print("  DUALES PROBLEM")
    print("=" * 78)
    print(f"y* = ({y[0]:.4f}, {y[1]:.4f}, {y[2]:.4f})")
    print(f"W* = {z_dual:.4f}")

    print("\n" + "=" * 78)
    print("  STARKER DUALITAETSSATZ:  c^T x* == b^T y* ?")
    print("=" * 78)
    print(f"  Primal Z* = {z_primal:.6f}")
    print(f"  Dual   W* = {z_dual:.6f}")
    differenz = abs(z_primal - z_dual)
    print(f"  Differenz = {differenz:.2e}  ->  "
          f"{'BESTAETIGT' if differenz < 1e-6 else 'VERLETZT!'}")

    print("\n" + "=" * 78)
    print("  KOMPLEMENTAERER SCHLUPF:  s_i * y_i == 0 fuer alle i ?")
    print("=" * 78)
    schlupf = B_PRIMAL - A_PRIMAL @ x
    ressourcen = ["vCPU (s1)", "RAM (s2)", "Marktlimit (s3)"]
    for name, s, yi in zip(ressourcen, schlupf, y):
        produkt = s * yi
        print(f"  {name:<16} Schlupf s={s:6.4f}  Schattenpreis y={yi:6.4f}  "
              f"s*y={produkt:.2e}  {'OK' if abs(produkt) < 1e-6 else 'VERLETZT!'}")

    print("\nFazit: Das dual geloeste y* stimmt exakt mit den Schattenpreisen")
    print("überein, die die Simplex-Rechnung von Hand in der Z-Zeile")
    print("ablas - unabhaengig voneinander berechnet, identisches Ergebnis.")
    print("=" * 78)

Erwartete Ausgabe:

==============================================================================
  PRIMALES PROBLEM
==============================================================================
x* = (7.5000, 5.0000)
Z* = 2375.0000

==============================================================================
  DUALES PROBLEM
==============================================================================
y* = (12.5000, 31.2500, 0.0000)
W* = 2375.0000

==============================================================================
  STARKER DUALITAETSSATZ:  c^T x* == b^T y* ?
==============================================================================
  Primal Z* = 2375.000000
  Dual   W* = 2375.000000
  Differenz = 4.55e-13  ->  BESTAETIGT

==============================================================================
  KOMPLEMENTAERER SCHLUPF:  s_i * y_i == 0 fuer alle i ?
==============================================================================
  vCPU (s1)        Schlupf s=0.0000  Schattenpreis y=12.5000  s*y=0.00e+00  OK
  RAM (s2)         Schlupf s=0.0000  Schattenpreis y=31.2500  s*y=0.00e+00  OK
  Marktlimit (s3)  Schlupf s=0.5000  Schattenpreis y=0.0000  s*y=0.00e+00  OK

Fazit: Das dual geloeste y* stimmt exakt mit den Schattenpreisen
überein, die die Simplex-Rechnung von Hand in der Z-Zeile
ablas - unabhaengig voneinander berechnet, identisches Ergebnis.
==============================================================================

Die winzige Differenz (4.55e-13) ist reines Gleitkommarauschen — numerisch ist das exakte Gleichheit. Wichtiger als die Zahl selbst: Primal und Dual wissen nichts voneinander, wurden als zwei separate linprog-Aufrufe gelöst, und landen trotzdem exakt beim selben Zielwert. Das ist der starke Dualitätssatz, nicht nur eine Illustration davon.

Der Schattenpreis als Ableitung

Der Dualwert y_i^* ist die Grenzproduktivität der Ressource i:

y_i^* = \frac{\partial Z^*}{\partial b_i}

📐 Formel-Lesehilfe Ohne Formel gesagt: „Um wie viele Euro steigt mein optimaler Gewinn, wenn ich von Ressource i eine Einheit mehr hätte?“

Im Beispiel: Ein zusätzliches GB RAM bringt 31,25 € zusätzlichen Tagesgewinn. Ein zusätzlicher vCPU bringt 12,50 €. Eine Lockerung des Arbitrage-Limits bringt nichts, weil es gar nicht bindet.

Satz vom komplementären Schlupf:

s_i^* \cdot y_i^* = 0 \qquad \text{für alle } i

Also: Entweder ist die Ressource knapp (s_i = 0) und kann einen positiven Preis haben, oder sie hat Reserven (s_i > 0) und ihr Preis ist zwingend null. Beides gleichzeitig geht nicht.

🔤 Formel-Übersetzer: das Dualitätspaket auf einen Blick

Mathematik Alltagssprache
\max\ \mathbf{c}^\top\mathbf{x} u. d. N. \mathbf{A}\mathbf{x} \le \mathbf{b} „Wie viel produziere ich von jedem Produkt, damit der Ertrag maximal wird und kein Vorrat überzogen wird?“
\min\ \mathbf{b}^\top\mathbf{y} u. d. N. \mathbf{A}^\top\mathbf{y} \ge \mathbf{c} „Was ist jede Ressourcenstunde wert? So wenig wie möglich — aber jedes Produkt muss seinen Verkaufserlös durch die verbrauchten Ressourcen gedeckt sehen.“
\mathbf{c}^\top\mathbf{x}^* = \mathbf{b}^\top\mathbf{y}^* „Der erwirtschaftete Gewinn und der Wert des Maschinenparks sind dieselbe Zahl — von zwei Seiten betrachtet.“
y_i^* = \partial Z^* / \partial b_i „Um so viel Euro steigt der Gewinn, wenn ich von Ressource i eine Einheit mehr hätte.“
s_i^* \cdot y_i^* = 0 „Was übrig ist, ist nichts wert. Was etwas wert ist, ist restlos verbraucht.“ Nie beides zugleich.
s_i^* > 0 \Rightarrow y_i^* = 0 „Von dieser Ressource haben Sie zu viel — kaufen Sie davon nichts nach.“
y_i^* > 0 \Rightarrow s_i^* = 0 „Diese Ressource ist Ihr Engpass — hier lohnt die Überstunde.“

Das Ganze in einem Satz: Jede Optimierung beantwortet gleichzeitig zwei Fragen — was zu tun ist, und was die Mittel wert sind, mit denen man es tut.

🎯 Merksatz Eine Optimierung liefert nicht nur eine Lösung, sondern auch ihre Begründung: Die Schattenpreise sagen, welcher Engpass Sie ausbremst und wie viel seine Beseitigung wert ist. Das ist in Managementgesprächen oft wertvoller als die Lösung selbst.


5.7 Die Vorzeichenfalle bei Schattenpreisen

Diese Falle sollten Sie sich merken, weil sie in jedem Projekt wieder auftaucht.

Das Problem: Wir wollen maximieren, aber minimiert. Also negieren wir die Zielfunktion. Damit wird auch die Ableitung negiert — und die Schattenpreise kommen mit umgekehrtem Vorzeichen heraus.

res = linprog(c=-gewinn, A_ub=A, b_ub=b, method="highs")
res.ineqlin.marginals      # <= 0  für "<="-Bedingungen bei Minimierung
Wert aus marginals Wirtschaftliche Bedeutung
Ressource mit Reserve 0 Preis 0 ✓
Engpassressource negativ, z. B. -33{,}33 Schattenpreis ist +33{,}33 €!

Eine unbedachte Abfrage if sp > 0: wird dadurch niemals wahr — ein Programm mit diesem Fehler empfiehlt selbst bei den knappsten Ressourcen „keine Zukäufe nötig“.

🎯 Die Regel Nach jeder Negation der Zielfunktion müssen auch die Dualwerte zurücknegiert werden. Und prüfen Sie es: Der Schattenpreis einer Engpassressource muss bei einer Maximierung positiv sein. Ist er negativ, haben Sie ein Vorzeichen vergessen.


5.8 Praxisfall: Sensitivitätsanalyse mit korrekten Schattenpreisen

Szenario. Ein Fertigungsbetrieb stellt drei Produkte her und will wissen:

  • Lohnt sich der Zukauf zusätzlicher Prüfstunden für 18 €/h?
  • Welche Ressource limitiert den Gewinn am stärksten?
#!/usr/bin/env python3

# Sensitivitaetsanalyse.py
"""
Kapitel LP: Schattenpreis- und Sensitivitätsanalyse mit SciPy und HiGHS.

Achtung: Ohne Vorzeichenumkehr der Dualwerte waere die Handlungsempfehlung
strukturell immer "kein Zukauf noetig" - selbst bei harten Engpaessen. Dieses
Programm zeigt die korrekte Vorzeichenbehandlung.
"""

import numpy as np
from scipy.optimize import linprog

# --- Modell: Maximiere Deckungsbeitrag aus 3 Produkten ---------------------
#   max 40*x1 + 30*x2 + 50*x3
DECKUNGSBEITRAG = np.array([40.0, 30.0, 50.0])

# Ressourcenverbrauch je Produkt (Zeile = Ressource, Spalte = Produkt)
VERBRAUCH = np.array([
    [2.0, 1.0, 3.0],      # Montagezeit
    [1.0, 2.0, 1.0],      # Lackierzeit
    [1.0, 0.5, 2.0],      # Qualitätsprüfung
])
KAPAZITAET = np.array([120.0, 80.0, 50.0])          # Stunden
RESSOURCEN = ["Montage", "Lackieren", "Qualitätsprüfung"]
PRODUKTE = ["Produkt 1", "Produkt 2", "Produkt 3"]

ANGEBOTSPREIS_PRUEFSTUNDE = 18.0                     # EUR/h - lohnt sich der Zukauf?


def analysiere():
    # linprog MINIMIERT -> Zielfunktion negieren
    ergebnis = linprog(c=-DECKUNGSBEITRAG, A_ub=VERBRAUCH, b_ub=KAPAZITAET,
                       bounds=[(0, None)] * len(DECKUNGSBEITRAG), method="highs")
    if not ergebnis.success:
        raise SystemExit(f"Kein Optimum gefunden: {ergebnis.message}")

    max_gewinn = -ergebnis.fun
    mengen = ergebnis.x
    schlupf = ergebnis.slack

    # ------------------------------------------------------------------
    # DER ENTSCHEIDENDE PUNKT:
    # Weil wir zur Maximierung negiert haben, sind die Dualwerte aus
    # linprog fuer "<="-Bedingungen <= 0. Zurueckdrehen!
    # ------------------------------------------------------------------
    schattenpreise = -ergebnis.ineqlin.marginals

    print("=" * 74)
    print("      PRIMALE UND DUALE ERGEBNISANALYSE (SENSITIVITAET)")
    print("=" * 74)
    print(f"Maximaler Deckungsbeitrag: {max_gewinn:,.2f} EUR\n")

    print("--- Primalloesung: optimale Produktionsmengen ---")
    for name, menge in zip(PRODUKTE, mengen):
        print(f"  * {name}: {menge:8.2f} Stueck")

    print("\n--- Duale Analyse: Schattenpreise und Auslastung ---")
    for i, name in enumerate(RESSOURCEN):
        kapazitaet = KAPAZITAET[i]
        genutzt = kapazitaet - schlupf[i]
        auslastung = genutzt / kapazitaet * 100
        preis = schattenpreise[i]
        bindend = abs(schlupf[i]) < 1e-9

        print(f"\nRessource '{name}':")
        print(f"  Auslastung:    {genutzt:6.1f} / {kapazitaet:6.1f} h ({auslastung:5.1f} %)"
              f"  -> {'ENGPASS' if bindend else 'Reserve: %.1f h' % schlupf[i]}")
        print(f"  Schattenpreis: {preis:6.2f} EUR je zusaetzlicher Stunde")

        if preis > 1e-9:
            print(f"  >> Zusaetzliche Stunden lohnen sich bis zu einem Preis von "
                  f"{preis:.2f} EUR/h.")
        else:
            print(f"  >> Kein Zukauf noetig - die Kapazitaet ist nicht erschoepft.")

    # --- Konkrete Kaufentscheidung ---------------------------------------
    preis_pruefung = schattenpreise[RESSOURCEN.index("Qualitätsprüfung")]
    marge = preis_pruefung - ANGEBOTSPREIS_PRUEFSTUNDE
    print("\n" + "-" * 74)
    print(f"ENTSCHEIDUNG: Pruefstunden werden fuer "
          f"{ANGEBOTSPREIS_PRUEFSTUNDE:.2f} EUR/h angeboten.")
    print(f"  Schattenpreis:  {preis_pruefung:6.2f} EUR/h")
    print(f"  Angebotspreis:  {ANGEBOTSPREIS_PRUEFSTUNDE:6.2f} EUR/h")
    print(f"  Marge:          {marge:+6.2f} EUR je zugekaufter Stunde")
    print(f"  >> {'ZUKAUFEN' if marge > 0 else 'NICHT ZUKAUFEN'}")

    # --- Numerische Gegenprobe: Kapazitaet wirklich um 1 erhoehen --------
    kapazitaet_plus = KAPAZITAET.copy()
    kapazitaet_plus[RESSOURCEN.index("Qualitätsprüfung")] += 1.0
    gegenprobe = linprog(c=-DECKUNGSBEITRAG, A_ub=VERBRAUCH, b_ub=kapazitaet_plus,
                         bounds=[(0, None)] * 3, method="highs")
    tatsaechlicher_zuwachs = -gegenprobe.fun - max_gewinn
    print("\n--- Gegenprobe: Modell mit +1 Pruefstunde neu geloest ---")
    print(f"  Vorhergesagt (Schattenpreis): {preis_pruefung:8.4f} EUR")
    print(f"  Tatsaechlich gemessen:        {tatsaechlicher_zuwachs:8.4f} EUR")
    assert abs(tatsaechlicher_zuwachs - preis_pruefung) < 1e-6, \
        "Schattenpreis stimmt nicht mit der Messung ueberein!"
    print("  -> Der Schattenpreis ist bestaetigt.")

    # --- Komplementaerer Schlupf pruefen --------------------------------
    for s, y in zip(schlupf, schattenpreise):
        assert abs(s * y) < 1e-6, "Komplementaerer Schlupf verletzt!"
    print("\nPruefung: komplementaerer Schlupf fuer alle Ressourcen erfuellt.")
    print("=" * 74)


if __name__ == "__main__":
    analysiere()

Erwartete Ausgabe:

==========================================================================
      PRIMALE UND DUALE ERGEBNISANALYSE (SENSITIVITAET)
==========================================================================
Maximaler Deckungsbeitrag: 2,200.00 EUR

--- Primalloesung: optimale Produktionsmengen ---
  * Produkt 1:    40.00 Stueck
  * Produkt 2:    20.00 Stueck
  * Produkt 3:     0.00 Stueck

--- Duale Analyse: Schattenpreise und Auslastung ---

Ressource 'Montage':
  Auslastung:     100.0 /  120.0 h ( 83.3 %)  -> Reserve: 20.0 h
  Schattenpreis:    0.00 EUR je zusaetzlicher Stunde
  >> Kein Zukauf noetig - die Kapazitaet ist nicht erschoepft.

Ressource 'Lackieren':
  Auslastung:      80.0 /   80.0 h (100.0 %)  -> ENGPASS
  Schattenpreis:    6.67 EUR je zusaetzlicher Stunde
  >> Zusaetzliche Stunden lohnen sich bis zu einem Preis von 6.67 EUR/h.

Ressource 'Qualitätsprüfung':
  Auslastung:      50.0 /   50.0 h (100.0 %)  -> ENGPASS
  Schattenpreis:   33.33 EUR je zusaetzlicher Stunde
  >> Zusaetzliche Stunden lohnen sich bis zu einem Preis von 33.33 EUR/h.

--------------------------------------------------------------------------
ENTSCHEIDUNG: Pruefstunden werden fuer 18.00 EUR/h angeboten.
  Schattenpreis:   33.33 EUR/h
  Angebotspreis:   18.00 EUR/h
  Marge:          +15.33 EUR je zugekaufter Stunde
  >> ZUKAUFEN

--- Gegenprobe: Modell mit +1 Pruefstunde neu geloest ---
  Vorhergesagt (Schattenpreis):  33.3333 EUR
  Tatsaechlich gemessen:         33.3333 EUR
  -> Der Schattenpreis ist bestaetigt.

Pruefung: komplementaerer Schlupf fuer alle Ressourcen erfuellt.
==========================================================================

Der Zukauf für 18 €/h ist hochprofitabel: Jede zusätzliche Prüfstunde bringt 33,33 € Deckungsbeitrag, also 15,33 € Reingewinn.

💻 Code-Durchgang: die drei Sicherungen

Dieses Programm enthält drei Prüfungen, die genau diese Vorzeichenfalle zuverlässig aufdecken würden:

  1. schattenpreise = -ergebnis.ineqlin.marginals — die eigentliche Korrektur.
  2. Numerische Gegenprobe: Das Modell wird mit b_3 + 1 neu gelöst; der gemessene Zuwachs muss dem Schattenpreis entsprechen. Diese Prüfung ist unabhängig von jeder Vorzeichenkonvention und deshalb der zuverlässigste Test überhaupt.
  3. Komplementärer Schlupf: s_i \cdot y_i = 0 muss für alle i gelten.

Übernehmen Sie dieses Muster in eigene Projekte. Der Aufwand ist eine Handvoll Zeilen, der Nutzen ist die Gewissheit, dass Ihre Handlungsempfehlung nicht das Gegenteil des Richtigen sagt.

⚠️ Grenzen der Schattenpreis-Aussage

Der Schattenpreis gilt nur lokal, in einem begrenzten Intervall um die aktuelle Kapazität. Kauft man 200 Prüfstunden zu, wird irgendwann eine andere Ressource zum Engpass, und der Schattenpreis springt auf einen neuen Wert (oder auf null). Wer große Kapazitätsänderungen bewerten will, muss das Modell neu rechnen — nicht linear hochrechnen. Die Aufgabe Gültigkeitsbereich des Schattenpreises (Abschnitt 5.10) macht diesen Effekt sichtbar.


5.9 Wann Schattenpreise lügen: Entartung und Toleranzen

Der vorige Abschnitt hat gezeigt, wie wertvoll Schattenpreise sind. Dieser zeigt die zwei Fälle, in denen sie eine Entscheidung nicht tragen — und wie man beide erkennt, bevor jemand auf ihrer Grundlage eine Maschine kauft.

Fall 1: Entartung

Erinnern Sie sich an Abschnitt 2.4: In zwei Dimensionen legen zwei sich schneidende Geraden eine Ecke fest, in drei Dimensionen drei Ebenen, allgemein n Restriktionen bei n Variablen. Was passiert, wenn in einer Ecke mehr Nebenbedingungen aktiv sind als das Problem Variablen hat?

Geometrisch: Drei Geraden laufen zufällig durch denselben Punkt. Die Ecke ist immer noch eine Ecke — aber sie ist überbestimmt. Man nennt das Entartung (Degeneriertheit), und sie ist in der Praxis nicht die Ausnahme, sondern die Regel: Sie entsteht überall dort, wo Kapazitäten aus derselben Planung stammen und deshalb glatt aufeinander passen — Schichtlängen, Chargengrößen, runde Vertragsmengen.

🎯 Merksatz Bei einer entarteten Ecke ist der Plan eindeutig, der Schattenpreis aber nicht. Es gibt dann viele gleichermaßen korrekte Dualvektoren, und welchen Sie sehen, hängt davon ab, welchen Algorithmus der Solver zufällig benutzt hat.

Das ist keine theoretische Sorge. Dasselbe Modell, zwei Verfahren desselben Solvers:

Verfahren Schattenpreise Was das Management daraus liest
Dual Simplex (0{,}33;\ 0{,}33;\ 0) „Fräse und Schleiferei sind die Engpässe, die Prüfung ist wertlos.“
Innere-Punkte-Verfahren (0;\ 0;\ 1{,}00) „Die Prüfung ist der Engpass, Fräse und Schleiferei sind wertlos.“

Beide Zeilen sind mathematisch korrekt. Sie widersprechen sich trotzdem vollständig.

Fall 2: Toleranzen

Der zweite Fall ist unscheinbarer und deshalb häufiger. Wie stellt man fest, ob eine Ressource ausgelastet ist? Naheliegend wäre if schlupf == 0. Genau das ist falsch.

Solver rechnen mit endlicher Genauigkeit und brechen ab, sobald ihre eigene Toleranz erreicht ist (Abschnitt 2.7). Ein voll ausgelasteter Engpass meldet dann einen Schlupf von -4{,}44 \cdot 10^{-16} statt exakt 0 — rechnerisch null, aber eben nicht == 0.0. Wer exakt vergleicht, übersieht ausgerechnet die Engpässe, die er sucht.

Toleranz Was sie steuert Typischer Standardwert
Primale Zulässigkeit Wie weit darf \mathbf{A}\mathbf{x} die Schranke \mathbf{b} überschreiten? 10^{-7} (HiGHS), 10^{-6} (CP-SAT)
Duale Zulässigkeit (Optimalität) Wie weit dürfen die reduzierten Kosten das falsche Vorzeichen haben? 10^{-7}
Ganzzahligkeit Wie weit darf eine Ganzzahlvariable von der nächsten ganzen Zahl abweichen? 10^{-6} (Kapitel 6)

⚠️ Toleranzen kleiner zu drehen ist selten die Lösung. Wer 1e-12 verlangt, bekommt meist keinen genaueren Solver, sondern einen, der INFEASIBLE meldet oder nicht konvergiert. Die Ursache steckt fast immer in der Skalierung des Modells, nicht in der Einstellung — deshalb steht der Konditionsabschnitt in Kapitel 2 vor diesem hier.

Die ehrliche Auskunft: Schattenpreis-Spannen

Was tut man also, wenn das Modell entartet ist? Man meldet keinen Einzelwert, sondern die Spanne. Die Menge aller optimalen Dualvektoren ist selbst ein Polyeder:

\mathbf{A}^\top\mathbf{y} \ge \mathbf{c},\qquad \mathbf{y} \ge \mathbf{0}, \qquad \mathbf{b}^\top\mathbf{y} = Z^*

🔤 Formel-Übersetzer

Mathematik Alltagssprache
\mathbf{A}^\top\mathbf{y} \ge \mathbf{c} „Die Preise müssen jedes Produkt decken“ — Dualzulässigkeit.
\mathbf{b}^\top\mathbf{y} = Z^* „Und der Gesamtwert muss genau dem erreichten Gewinn entsprechen“ — starker Dualitätssatz als Gleichung.
\min / \max\ y_i darauf „Wie klein und wie groß darf der Preis von Ressource i sein, ohne diese Regeln zu verletzen?“

Zwei zusätzliche LPs je Ressource — und Sie haben statt einer Zufallszahl ein belastbares Intervall.

#!/usr/bin/env python3

# Toleranzen_und_Entartung.py
"""
Kapitel LP: Zwei Faelle, in denen man Schattenpreisen NICHT trauen darf.

  1. Entartung (Degeneriertheit): Mehr Nebenbedingungen sind aktiv, als das
     Problem Variablen hat. Dann ist der Schattenpreis nicht eindeutig - zwei
     korrekte Solver liefern voellig verschiedene Werte fuer dasselbe Optimum.
  2. Toleranzen: "ausgelastet" heisst nie 'schlupf == 0', sondern immer
     'schlupf < toleranz'. Wer auf exakte Gleichheit prueft, baut Berichte,
     die zufaellig mal stimmen und mal nicht.

Teil 3 zeigt die professionelle Antwort auf Fall 1: Statt EINEN Schattenpreis
zu melden, berechnet man seine SPANNE ueber alle optimalen Dualloesungen.

Benoetigt: numpy, scipy
"""

from __future__ import annotations

import numpy as np
from scipy.optimize import linprog

# Ein bewusst entartetes Beispiel: drei Geraden schneiden sich in EINEM Punkt.
#   max x1 + x2
#   u.d.N.  x1 + 2*x2 <= 4        (A)
#          2*x1 +  x2 <= 4        (B)
#           x1 +  x2 <= 8/3       (C)  - laeuft genau durch die Ecke (4/3, 4/3)
# In zwei Dimensionen legen schon zwei Geraden eine Ecke fest. Hier sind drei
# aktiv - eine zu viel. Genau das ist Entartung.
C_ZIEL = np.array([-1.0, -1.0])          # linprog minimiert -> negiert
A_UB = np.array([[1.0, 2.0],
                 [2.0, 1.0],
                 [1.0, 1.0]])
B_UB = np.array([4.0, 4.0, 8.0 / 3.0])
NAMEN = ["A: Fraeszeit", "B: Schleifzeit", "C: Pruefzeit"]


def zeige_entartung() -> float:
    """Loest dasselbe LP mit zwei Verfahren und vergleicht die Dualwerte."""
    print("=" * 78)
    print("  1. ENTARTUNG: DERSELBE PLAN, GEGENSAETZLICHE SCHATTENPREISE")
    print("=" * 78)

    print(f"{'Verfahren':<26} {'x1':>7} {'x2':>7} {'Z*':>9}   Schattenpreise")
    print("-" * 78)

    dualwerte = {}
    for verfahren, beschreibung in [("highs-ds", "Dual Simplex"),
                                    ("highs-ipm", "Innere-Punkte-Verfahren")]:
        ergebnis = linprog(C_ZIEL, A_ub=A_UB, b_ub=B_UB, bounds=(0, None),
                           method=verfahren)
        if not ergebnis.success:
            raise RuntimeError(f"{verfahren}: {ergebnis.message}")
        y = -ergebnis.ineqlin.marginals
        dualwerte[verfahren] = y
        print(f"{beschreibung:<26} {ergebnis.x[0]:>7.3f} {ergebnis.x[1]:>7.3f} "
              f"{-ergebnis.fun:>9.4f}   {np.round(y, 4)}")

    print("-" * 78)
    print("Beide Zeilen sind RICHTIG: gleicher Plan, gleicher Zielwert, und beide")
    print("Dualvektoren erfuellen die Optimalitaetsbedingungen. Trotzdem sagen sie")
    print("das Gegenteil:")
    print(f"  Dual Simplex : {NAMEN[2]} ist wertlos, A und B sind je 0,33 EUR wert.")
    print(f"  Innere Punkte: {NAMEN[0]} und {NAMEN[1]} sind wertlos, C ist 1,00 EUR wert.")
    print()
    print("Wer auf dieser Grundlage eine Maschine kauft, hat eine 50:50-Chance -")
    print("abhaengig davon, welches Verfahren der Solver zufaellig gewaehlt hat.")

    return float(-linprog(C_ZIEL, A_ub=A_UB, b_ub=B_UB, bounds=(0, None)).fun)


def entartung_erkennen() -> None:
    """Der Test, der in jedes Auswertungsskript gehoert."""
    print("\n" + "=" * 78)
    print("  2. ENTARTUNG ERKENNEN - UND WARUM '== 0' DABEI VERSAGT")
    print("=" * 78)

    ergebnis = linprog(C_ZIEL, A_ub=A_UB, b_ub=B_UB, bounds=(0, None))
    schlupf = B_UB - A_UB @ ergebnis.x

    print(f"{'Nebenbedingung':<18} {'Schlupf':>16} {'== 0 ?':>9} "
          f"{'< 1e-7 ?':>10}")
    print("-" * 78)
    for name, s in zip(NAMEN, schlupf):
        print(f"{name:<18} {s:>16.3e} {str(s == 0.0):>9} {str(abs(s) < 1e-7):>10}")

    aktiv = int((np.abs(schlupf) < 1e-7).sum())
    variablen = A_UB.shape[1]
    print("-" * 78)
    print(f"Aktive Nebenbedingungen: {aktiv}, Variablen: {variablen}")
    if aktiv > variablen:
        print(f"=> ENTARTET. {aktiv} aktive Restriktionen bei nur {variablen} "
              "Variablen bedeuten:")
        print("   Der Schattenpreis ist nicht eindeutig. Melden Sie eine Spanne,")
        print("   keinen Einzelwert (siehe Teil 3).")
    else:
        print("=> nicht entartet, die Dualwerte sind eindeutig.")

    print("\nBeachten Sie die Spalte '== 0': Ein Schlupf von 4.44e-16 ist")
    print("rechnerisch null, aber nicht gleich 0.0. Wer mit '==' prueft,")
    print("uebersieht genau die Engpaesse, die er sucht.")


def schattenpreis_spanne(zielwert: float, toleranz: float = 1e-9
                         ) -> list[tuple[float, float]]:
    """Berechnet fuer jede Nebenbedingung die Spanne ihres Schattenpreises
    ueber ALLE optimalen Dualloesungen.

    Die Menge der optimalen Dualloesungen ist selbst ein Polyeder:

        A^T y >= c,   y >= 0,   b^T y = Z*

    (Dualzulaessigkeit plus starker Dualitaetssatz.) Minimiert und maximiert
    man darauf y_i, erhaelt man die exakten Grenzen. Das ist die ehrliche
    Auskunft an das Management: nicht 'die Stunde ist 0,33 EUR wert', sondern
    'zwischen 0,00 und 0,33 EUR - der Wert ist aus dem Modell nicht bestimmbar'.
    """
    m = A_UB.shape[0]
    # A^T y >= c  <=>  -A^T y <= -c ; Ziel war max c^T x, in linprog-Notation
    # steckt c mit negativem Vorzeichen in C_ZIEL.
    c_original = -C_ZIEL
    A_dual_ub = -A_UB.T
    b_dual_ub = -c_original

    spannen = []
    for i in range(m):
        richtung = np.zeros(m)
        richtung[i] = 1.0
        grenzen = []
        for vorzeichen in (1.0, -1.0):          # 1 = minimieren, -1 = maximieren
            ergebnis = linprog(
                vorzeichen * richtung,
                A_ub=A_dual_ub, b_ub=b_dual_ub,
                A_eq=B_UB.reshape(1, -1), b_eq=[zielwert],
                bounds=(0, None))
            if not ergebnis.success:
                raise RuntimeError(f"Spannenberechnung fehlgeschlagen: "
                                   f"{ergebnis.message}")
            grenzen.append(float(ergebnis.x[i]))
        spannen.append((min(grenzen), max(grenzen)))
    return spannen


def zeige_spanne(zielwert: float) -> None:
    print("\n" + "=" * 78)
    print("  3. DIE EHRLICHE AUSKUNFT: SCHATTENPREIS-SPANNEN")
    print("=" * 78)

    spannen = schattenpreis_spanne(zielwert)
    print(f"{'Nebenbedingung':<18} {'von':>10} {'bis':>10}   Aussage")
    print("-" * 78)
    for name, (unten, oben) in zip(NAMEN, spannen):
        if oben - unten < 1e-7:
            aussage = f"eindeutig {oben:.2f} EUR"
        elif oben < 1e-7:
            aussage = "sicher wertlos (kein Engpass)"
        else:
            aussage = "NICHT bestimmbar - Spanne melden!"
        print(f"{name:<18} {unten:>10.4f} {oben:>10.4f}   {aussage}")

    print("-" * 78)
    print("So berichtet man an Entscheider: 'Eine zusaetzliche Fraesstunde ist")
    print("zwischen 0,00 und 0,33 EUR wert - das Modell kann es nicht genauer")
    print("sagen, weil drei Engpaesse exakt gleichzeitig binden.' Das ist eine")
    print("brauchbare Aussage. Ein erfundener Einzelwert ist es nicht.")


if __name__ == "__main__":
    zielwert = zeige_entartung()
    entartung_erkennen()
    zeige_spanne(zielwert)

    print("\n" + "=" * 78)
    print("Merksatz: Pruefen Sie VOR jeder Sensitivitaetsaussage auf Entartung -")
    print("und vergleichen Sie Schlupfwerte nie mit '== 0', sondern mit einer")
    print("Toleranz.")
    print("=" * 78)

Erwartete Ausgabe:

==============================================================================
  1. ENTARTUNG: DERSELBE PLAN, GEGENSAETZLICHE SCHATTENPREISE
==============================================================================
Verfahren                       x1      x2        Z*   Schattenpreise
------------------------------------------------------------------------------
Dual Simplex                 1.333   1.333    2.6667   [0.3333 0.3333 0.    ]
Innere-Punkte-Verfahren      1.333   1.333    2.6667   [-0. -0.  1.]
------------------------------------------------------------------------------
Beide Zeilen sind RICHTIG: gleicher Plan, gleicher Zielwert, und beide
Dualvektoren erfuellen die Optimalitaetsbedingungen. Trotzdem sagen sie
das Gegenteil:
  Dual Simplex : C: Pruefzeit ist wertlos, A und B sind je 0,33 EUR wert.
  Innere Punkte: A: Fraeszeit und B: Schleifzeit sind wertlos, C ist 1,00 EUR wert.

Wer auf dieser Grundlage eine Maschine kauft, hat eine 50:50-Chance -
abhaengig davon, welches Verfahren der Solver zufaellig gewaehlt hat.

==============================================================================
  2. ENTARTUNG ERKENNEN - UND WARUM '== 0' DABEI VERSAGT
==============================================================================
Nebenbedingung              Schlupf    == 0 ?   < 1e-7 ?
------------------------------------------------------------------------------
A: Fraeszeit              0.000e+00      True       True
B: Schleifzeit            0.000e+00      True       True
C: Pruefzeit             -4.441e-16     False       True
------------------------------------------------------------------------------
Aktive Nebenbedingungen: 3, Variablen: 2
=> ENTARTET. 3 aktive Restriktionen bei nur 2 Variablen bedeuten:
   Der Schattenpreis ist nicht eindeutig. Melden Sie eine Spanne,
   keinen Einzelwert (siehe Teil 3).

Beachten Sie die Spalte '== 0': Ein Schlupf von 4.44e-16 ist
rechnerisch null, aber nicht gleich 0.0. Wer mit '==' prueft,
uebersieht genau die Engpaesse, die er sucht.

==============================================================================
  3. DIE EHRLICHE AUSKUNFT: SCHATTENPREIS-SPANNEN
==============================================================================
Nebenbedingung            von        bis   Aussage
------------------------------------------------------------------------------
A: Fraeszeit           0.0000     0.3333   NICHT bestimmbar - Spanne melden!
B: Schleifzeit         0.0000     0.3333   NICHT bestimmbar - Spanne melden!
C: Pruefzeit           0.0000     1.0000   NICHT bestimmbar - Spanne melden!
------------------------------------------------------------------------------
So berichtet man an Entscheider: 'Eine zusaetzliche Fraesstunde ist
zwischen 0,00 und 0,33 EUR wert - das Modell kann es nicht genauer
sagen, weil drei Engpaesse exakt gleichzeitig binden.' Das ist eine
brauchbare Aussage. Ein erfundener Einzelwert ist es nicht.

💻 Code-Durchgang

Stelle Was passiert Warum es zählt
method="highs-ds" vs. "highs-ipm" zwei Verfahren desselben Solvers Der Widerspruch entsteht nicht durch einen Fehler oder unterschiedliche Bibliotheken, sondern durch verschiedene, gleich gültige Wege zur selben Ecke.
aktiv > variablen der Entartungstest Drei Zeilen Code. Sie gehören in jedes Skript, das Schattenpreise ausgibt — und fehlen in fast allen.
Spalte == 0 ? zeigt False bei -4{,}44 \cdot 10^{-16} Der Beleg, warum Zulässigkeitsprüfungen immer eine Toleranz brauchen. Die Zahl ist echtes Solver-Rauschen, kein konstruiertes Beispiel.
A_eq=B_UB.reshape(1, -1), b_eq=[zielwert] der starke Dualitätssatz als Nebenbedingung Das ist der ganze Trick der Spannenrechnung: Wir suchen unter allen Dualvektoren, die denselben Zielwert erzeugen.
zwei linprog-Läufe je Ressource Minimum und Maximum von y_i Bei 50 Ressourcen sind das 100 kleine LPs — Sekunden. Verglichen mit einer Fehlinvestition ist das billig.

⚠️ Typische Fehler

  • Schattenpreis als „der Wert“ berichten, ohne auf Entartung zu prüfen. Der häufigste Weg, wie eine formal korrekte Optimierung zu einer falschen Investitionsentscheidung führt.
  • Schattenpreis weit extrapolieren. y_i ist eine Ableitung: Sie gilt lokal. „Eine Stunde mehr bringt 50 €“ heißt nicht „200 Stunden mehr bringen 10 000 €“ — ab einem gewissen Punkt bindet eine andere Ressource, und der Wert bricht ein.
  • if schlupf == 0. Siehe oben. Richtig ist abs(schlupf) < 1e-7.
  • Toleranzen herunterdrehen statt das Modell zu skalieren. Behandelt das Symptom und erzeugt neue Probleme (Abschnitt 2.7).

5.10 Übungsaufgaben

Lösungen: Abschnitt A.5.

Aufgabe 5.1 ⭐ — Schlupf deuten. Ein LP liefert s = (0;\ 12{,}5;\ 0;\ 3) und y = (4{,}2;\ 0;\ 9{,}8;\ 0). (a) Welche Ressourcen sind Engpässe? (b) Ist die Lösung mit dem Satz vom komplementären Schlupf verträglich? (c) In welche Ressource würden Sie zuerst investieren?

Aufgabe 5.2 ⭐ — Vorzeichen prüfen. Ein Kollege maximiert Gewinn mit linprog und meldet: „Der Schattenpreis der Engpassmaschine ist -45 €.“ Was ist passiert, und wie lautet der korrekte Wert?

Aufgabe 5.3 ⭐⭐ — Simplex von Hand. Lösen Sie mit dem Tableau-Verfahren vollständig von Hand: \max\ 5x_1 + 4x_2 \quad \text{u. d. N.}\quad 6x_1+4x_2 \le 24,\quad x_1+2x_2 \le 6,\quad x_1,x_2\ge0 Geben Sie jedes Zwischentableau an und lesen Sie am Ende Lösung und Schattenpreise ab. Prüfen Sie mit scipy.optimize.linprog.

Aufgabe 5.4 ⭐⭐ — Duales Problem aufstellen. Stellen Sie zum Modell aus der Aufgabe Simplex von Hand das duale Problem auf, lösen Sie es mit linprog und weisen Sie den starken Dualitätssatz numerisch nach.

Aufgabe 5.5 ⭐⭐ — Unbeschränktheit erkennen. Was liefert SimplexTableauSolver für \max x_1 + x_2 u. d. N. x_1 - x_2 \le 5, x_1, x_2 \ge 0? Erklären Sie die Fehlermeldung geometrisch.

Aufgabe 5.6 ⭐⭐⭐ — Gültigkeitsbereich des Schattenpreises. Erweitern Sie Sensitivitaetsanalyse.py: Lösen Sie das Modell für Prüfkapazitäten von 30 bis 120 Stunden (Schrittweite 5) und tragen Sie Gewinn und Schattenpreis gegen die Kapazität auf. (a) Ab welcher Kapazität fällt der Schattenpreis auf einen niedrigeren Wert? Warum? (b) Wie viele Stunden sollte der Betrieb bei einem Angebotspreis von 18 €/h maximal zukaufen? (c) Zeichnen Sie den Gewinnverlauf. Was für eine Kurvenform ergibt sich, und warum?

Aufgabe 5.7 ⭐⭐⭐ — Phase 1 ergänzen. Erweitern Sie den SimplexTableauSolver um eine Phase-1-Rechnung mit künstlichen Variablen, sodass auch b_i < 0 verarbeitet werden kann. Testen Sie an: \max\ 3x_1+2x_2 \quad\text{u. d. N.}\quad x_1+x_2 \ge 4,\quad x_1+3x_2 \le 12,\quad x_1,x_2\ge0


5.11 Finde den Denkfehler

🐛 Finde den Denkfehler: Die 380 000-Euro-Maschine

Ein Werksleiter lässt die Engpässe seiner Fertigung analysieren. Das Skript liefert für den Lackierofen einen Schattenpreis von 50 €/Stunde. Er rechnet:

„Ein zweiter Lackierofen bringt 2 000 zusätzliche Stunden im Jahr. Bei 50 € > je Stunde sind das 100 000 € Deckungsbeitrag pro Jahr. Die Maschine kostet 380 000 € > und amortisiert sich in 3,8 Jahren. Wir kaufen.“

Das Skript, das die Zahl geliefert hat:

res = linprog(c, A_ub=A, b_ub=b, bounds=(0, None))
for i, y in enumerate(-res.ineqlin.marginals):
    if y > 0:
        print(f"Engpass {name[i]}: {y:.2f} EUR je zusaetzlicher Stunde")

Ihre Aufgabe:

  1. Der Schattenpreis ist y_i = \partial Z^* / \partial b_i. Welche Eigenschaft einer Ableitung übersieht die Hochrechnung auf 2 000 Stunden?

  2. Skizzieren Sie, wie der zusätzliche Deckungsbeitrag als Funktion der zusätzlichen Ofenstunden tatsächlich verläuft. Warum ist er nicht linear — und in welche Richtung liegt der Fehler?

  3. Zwei Prüfungen fehlen im Skript, bevor die Zahl 50 € überhaupt berichtet werden darf. Welche?

  4. Wie ermitteln Sie den tatsächlichen Nutzen von 2 000 Zusatzstunden — ohne jede Extrapolation? Schreiben Sie die drei Zeilen hin.

Auflösung: Abschnitt A.5.


5.12 Micro-Quiz

❓ Micro-Quiz 5: Drei Fragen zum Selbstcheck

Genau eine Antwort ist jeweils richtig. Auflösung in Anhang A.

1. Eine Nebenbedingung hat den Schlupf s_i = 12 und den Schattenpreis y_i = 0. Was folgt daraus? (a) Der Solver hat einen Fehler gemacht — bei positivem Schlupf muss der Dualwert ebenfalls positiv sein. (b) Von dieser Ressource sind 12 Einheiten übrig; sie ist kein Engpass, und Zukauf bringt nichts. Das ist genau der komplementäre Schlupf. (c) Die Ressource ist voll ausgelastet, wird aber nicht benötigt.

2. Ihr Modell hat 5 Variablen. In der optimalen Ecke sind 7 Nebenbedingungen aktiv. Was bedeutet das für Ihren Bericht ans Management? (a) Nichts Besonderes — mehr aktive Bedingungen heißt nur, dass das Modell gut ausgelastet ist. (b) Das Modell ist entartet: Der Plan ist eindeutig, die Schattenpreise sind es nicht. Statt Einzelwerten gehören Spannen in den Bericht. (c) Das Modell ist unlösbar, weil mehr Bedingungen als Variablen aktiv sind.

3. Ein Kollege prüft mit if schlupf == 0.0, welche Ressourcen Engpässe sind. Der Bericht weist einen offensichtlichen Engpass nicht aus. Warum? (a) Der Solver hat den Engpass übersehen. (b) Der Schlupf beträgt -4{,}4 \cdot 10^{-16} statt exakt 0 — rechnerisch null, aber nicht gleich 0.0. Richtig ist ein Vergleich gegen eine Toleranz. (c) Schlupfwerte sind bei Maximierungsproblemen immer negativ und müssen erst umgerechnet werden.


5.13 Selbsttest

Antworten: Anhang A.

  1. Was misst eine Schlupfvariable, und was bedeutet der Wert 0?
  2. Nach welchem Kriterium wählt der Simplex die Pivotzeile — und was würde passieren, wenn man stattdessen einfach die erste Zeile nähme?
  3. Formulieren Sie den Satz vom komplementären Schlupf und erklären Sie ihn wirtschaftlich.
  4. Warum sind die Dualwerte aus scipy.optimize.linprog bei einem Maximierungsproblem negativ?
  5. Ihr Modell meldet „unbeschränkt“. Was ist die wahrscheinlichste Ursache?

5.14 Zusammenfassung

  • Schlupfvariablen verwandeln Ungleichungen in Gleichungen — und messen nebenbei ungenutzte Kapazität.
  • Der Simplex wandert von Ecke zu Ecke, immer bergauf, und stoppt, wenn keine Verbesserung mehr möglich ist. Im Beispiel genügten 2 Schritte statt 5 Eckenprüfungen.
  • Dualität verwandelt „Wie viel produziere ich?“ in „Was sind meine Ressourcen wert?“ — beide Probleme haben denselben Optimalwert.
  • Schattenpreise beantworten die Managementfrage schlechthin: Wo ist der Engpass, und was ist seine Beseitigung wert?
  • Vorzeichen prüfen! Nach einer Negation der Zielfunktion müssen die Dualwerte zurücknegiert werden. Die numerische Gegenprobe („Kapazität um 1 erhöhen und neu lösen“) ist die sicherste Kontrolle.

Ausblick. Kapitel 6 bricht mit der Annahme beliebiger Teilbarkeit: Was, wenn man nur ganze Maschinen kaufen kann? Wir werden sehen, dass Runden der LP-Lösung nicht nur ungenau, sondern grundsätzlich falsch ist — und lernen Branch-and-Bound kennen.