Anhang C: Fehlerdiagnose-Handbuch

Wofür dieser Anhang gedacht ist: Etwas funktioniert nicht. Suchen Sie unten das Symptom, das Sie beobachten, und arbeiten Sie die Diagnoseschritte der Reihe nach ab.

Programme:
Konfliktsuche.py


Symptom-Schnellübersicht

Symptom Abschnitt
Solver meldet INFEASIBLE C1
INFEASIBLE, aber keine einzelne Bedingung ist schuld C1, Deletion Filter
Solver meldet UNBOUNDED C2
Solver läuft ewig / Timeout C3
Ergebnis ist offensichtlich unsinnig C4
Ergebnis ändert sich bei kleinsten Datenänderungen stark C5
Schattenpreise sehen falsch aus C6
Zwei Solver liefern verschiedene Ergebnisse C7
CVXPY wirft DCPError C8
ImportError bei ortools/highspy C9
Backtest sieht zu gut aus C10
Ergebnisse sind falsch beschriftet C11

C1 — INFEASIBLE

Bedeutung. Es gibt keinen einzigen Punkt, der alle Bedingungen gleichzeitig erfüllt. Das ist eine Aussage über Ihr Modell, nicht über den Solver.

Diagnose in fünf Schritten

Schritt 1 — Trivialprüfungen von Hand. Rechnen Sie die offensichtlichen Bilanzen nach, bevor Sie den Solver befragen:

assert summe_angebot >= summe_bedarf, "Angebot deckt Bedarf nicht"
assert anzahl_personal * max_schichten >= anzahl_schichten, "zu wenig Personal"
for slot, fach in enumerate(faecher):
    qualifiziert = [p for p in personal if fach in qualifikation[p]]
    assert qualifiziert, f"Fuer Slot {slot} ({fach}) gibt es niemanden"

Erfahrungsgemäß findet dieser Schritt die Mehrzahl aller Fälle.

Schritt 2 — Bedingungen einzeln abschalten. Kommentieren Sie Bedingungsgruppen nacheinander aus. Wird das Modell lösbar, ist die zuletzt entfernte Gruppe (mit-)verantwortlich.

Schritt 3 — Schlupfvariablen einbauen (das wirksamste Mittel).

# statt:  modell.Add(bedingung)
schlupf = modell.NewIntVar(0, obergrenze, f"schlupf_{name}")
modell.Add(linke_seite <= rechte_seite + schlupf)
strafen.append(10_000 * schlupf)

Nach dem Lösen zeigen die Schlupfvariablen mit Wert > 0 präzise, welche Bedingung wo und um wie viel verletzt werden musste. Das ist zugleich die produktionstaugliche Lösung (Muster B18).

Schritt 4 — Zeitfenster und Erreichbarkeit prüfen. Bei Scheduling und Routing: Ist jeder Termin überhaupt physisch erreichbar?

for kunde, (frueh, spaet) in enumerate(zeitfenster):
    assert distanz[depot][kunde] <= spaet, f"Kunde {kunde} nicht rechtzeitig erreichbar"

Schritt 5 — Rundungs- und Einheitenfehler. Sind Kapazitäten in Stunden, Verbräuche aber in Minuten? Rechnen CP-SAT-Modelle mit ganzen Zahlen, wo Sie Nachkommastellen brauchen? Ein klassischer Fall: sum(w) == 1 mit Gleitkommazahlen — nutzen Sie eine Toleranz oder rechnen Sie in Ganzzahlen (Promille).

Wenn die fünf Schritte nicht reichen: den Konflikt einkreisen

Warum Schritt 2 so oft ins Leere läuft. Bedingungen einzeln abzuschalten funktioniert nur, solange es einen Schuldigen gibt. Überlagern sich zwei unabhängige Widersprüche, bleibt das Modell nach jeder einzelnen Abschaltung unlösbar — die Suche endet mit null Treffern, obwohl beide Widersprüche unverändert im Modell stehen. Genau dann steht man vor einem Modell mit tausenden Restriktionen und einem Statuscode.

Der Deletion Filter dreht die Frage um. Er fragt nicht „ist diese Bedingung schuld?“, sondern „wird diese Bedingung für den Widerspruch überhaupt gebraucht?“ — und das ist eine Frage, die sich beantworten lässt:

Nimm eine Bedingung versuchsweise heraus. Bleibt der Rest unlösbar, wurde sie nicht gebraucht: weg damit, endgültig. Wird der Rest lösbar, war sie beteiligt: sie bleibt.

Nach genau einem Durchlauf über alle n Bedingungen ist die übrig gebliebene Menge unreduzierbar — entfernt man aus ihr irgendeine Bedingung, ist der Rest lösbar. Das ist ein Irreducible Infeasible Subset, kurz IIS (Irreducible Infeasible Subset). Kommerzielle Solver bieten das als fertige Funktion an (computeIIS bei Gurobi, der Conflict Refiner bei CPLEX); im Open-Source-Werkzeugkasten dieses Buchs gibt es sie nicht — sie ist aber in zwanzig Zeilen selbst geschrieben, und sie kostet n Aufrufe des Solvers statt 2^n durchprobierter Teilmengen.

#!/usr/bin/env python3

# Konfliktsuche.py
"""
Anhang Fehlerdiagnose: Den Konflikt finden, der INFEASIBLE verursacht.

Ein Deletion Filter (auch: IIS, Irreducible Infeasible Subset) isoliert aus
einem unloesbaren Modell eine kleinste widerspruechliche Teilmenge von
Bedingungen: Nimmt man aus ihr auch nur eine einzige Bedingung heraus, ist
der Rest wieder loesbar.

Das Programm zeigt sechs Dinge:
  1. Der Solver meldet INFEASIBLE - und sonst nichts.
  2. Die naive Suche ("jede Bedingung einmal weglassen") findet hier gar nichts.
  3. Der Deletion Filter findet einen Konflikt in n Solveraufrufen.
  4. Ein Konflikt ist selten einer: nach der Reparatur folgt der naechste.
  5. "Den" kleinsten Konflikt gibt es nicht - dieses Modell enthaelt neun.
  6. Welchen davon man zu sehen bekommt, steuert die Pruefreihenfolge.

Abgrenzung zu Infeasibility_Diagnose.py (Kapitel Praxisfallen): Dort geht es
um die Relaxation - das Modell soll trotz Widerspruch eine brauchbare Antwort
liefern. Hier geht es um die Diagnose - welche Bedingungen widersprechen sich
ueberhaupt. Beides zusammen ergibt den Umgang mit INFEASIBLE in Produktion.

Benoetigt: numpy, scipy
"""

from __future__ import annotations

import numpy as np
from scipy.optimize import linprog

PRODUKTE = ["Rahmen", "Gehaeuse", "Deckel", "Traeger", "Halter"]

# Jede Bedingung traegt einen sprechenden Namen - das ist keine Kosmetik,
# sondern die Voraussetzung dafuer, dass der Befund lesbar wird.
# (Name, Koeffizienten je Produkt, Richtung, rechte Seite)
BEDINGUNGEN: list[tuple[str, list[float], str, float]] = [
    ("Kapazitaet Montage",       [3, 2, 1, 4, 2],      "<=",   400),
    ("Kapazitaet Lackieren",     [2, 3, 0, 1, 0],      "<=",   150),
    ("Kapazitaet Pruefung",      [1, 1, 1, 1, 1],      "<=",   300),
    ("Liefervertrag Rahmen",     [1, 0, 0, 0, 0],      ">=",    40),
    ("Liefervertrag Gehaeuse",   [0, 1, 0, 0, 0],      ">=",    30),
    ("Liefervertrag Deckel",     [0, 0, 1, 0, 0],      ">=",    20),
    ("Liefervertrag Traeger",    [0, 0, 0, 1, 0],      ">=",    25),
    ("Marktgrenze Rahmen",       [1, 0, 0, 0, 0],      "<=",   120),
    ("Marktgrenze Gehaeuse",     [0, 1, 0, 0, 0],      "<=",    90),
    ("Marktgrenze Deckel",       [0, 0, 1, 0, 0],      "<=",   150),
    ("Marktgrenze Halter",       [0, 0, 0, 0, 1],      "<=",   200),
    ("Mindestumsatz",            [90, 70, 30, 60, 20], ">=", 12000),
    ("Sortimentsbreite",         [0, 0, 1, 1, 1],      ">=",   100),
    ("Lackierbudget Schicht 2",  [0, 1, 0, 1, 0],      "<=",    55),
]

# Welche Bedingungen koennte ein Planer im Ernstfall wirklich veraendern?
# Kapazitaeten lassen sich durch Sonderschichten dehnen, Liefervertraege und
# Marktgrenzen nicht.
VERHANDELBAR = {"Kapazitaet Montage", "Kapazitaet Lackieren",
                "Kapazitaet Pruefung", "Lackierbudget Schicht 2"}

aufrufe = 0          # zaehlt jeden Solveraufruf mit - der Preis des Verfahrens


def ist_loesbar(auswahl: list[int]) -> bool:
    """Gibt es einen Punkt, der ALLE Bedingungen aus 'auswahl' erfuellt?

    Es wird nur Zulaessigkeit geprueft, keine Zielfunktion optimiert: die
    Zielfunktion ist konstant null. INFEASIBLE haengt nie an der Zielfunktion.
    """
    global aufrufe
    aufrufe += 1
    matrix, rechte_seite = [], []
    for i in auswahl:
        _, koeffizienten, richtung, grenze = BEDINGUNGEN[i]
        zeile = np.array(koeffizienten, dtype=float)
        # linprog kennt nur "<=", also ">=" durch Negation umdrehen
        matrix.append(zeile if richtung == "<=" else -zeile)
        rechte_seite.append(grenze if richtung == "<=" else -grenze)
    ergebnis = linprog(np.zeros(len(PRODUKTE)),
                       A_ub=np.array(matrix), b_ub=np.array(rechte_seite),
                       bounds=(0, None), method="highs")
    return ergebnis.status == 0


def deletion_filter(auswahl: list[int]) -> list[int]:
    """Verkleinert eine unloesbare Menge zu einer kleinsten unloesbaren Menge.

    Der Kern des Verfahrens ist eine einzige Regel: Nimm eine Bedingung
    versuchsweise heraus. Bleibt der Rest unloesbar, wurde sie fuer den
    Widerspruch nicht gebraucht - sie darf endgueltig weg. Wird der Rest
    loesbar, war sie beteiligt und muss bleiben.

    Nach genau einem Durchlauf ueber alle Bedingungen ist das Ergebnis
    unreduzierbar: n Solveraufrufe statt 2^n Teilmengen.
    """
    rest = list(auswahl)
    for i in list(auswahl):
        probe = [j for j in rest if j != i]
        if not ist_loesbar(probe):
            rest = probe
    return rest


def namen(auswahl: list[int]) -> list[str]:
    return [BEDINGUNGEN[i][0] for i in auswahl]


def teil_1_der_befund(alle: list[int]) -> None:
    print("=" * 68)
    print("1. Was der Solver sagt")
    print("=" * 68)
    print(f"Modell: {len(PRODUKTE)} Variablen, {len(BEDINGUNGEN)} Bedingungen")
    status = "OPTIMAL" if ist_loesbar(alle) else "INFEASIBLE"
    print(f"Status: {status}")
    print("\nMehr ist es nicht. Der Solver nennt keine Ursache, weil es die eine")
    print("Ursache nicht gibt: Unloesbarkeit ist eine Eigenschaft von Mengen von")
    print("Bedingungen, nicht von einzelnen Bedingungen.")


def teil_2_die_naive_suche(alle: list[int]) -> None:
    print("\n" + "=" * 68)
    print("2. Die naheliegende Idee - und warum sie hier scheitert")
    print("=" * 68)
    print("Jede Bedingung einmal weglassen und schauen, ob es dann geht:\n")
    treffer = [i for i in alle if ist_loesbar([j for j in alle if j != i])]
    for i in alle:
        print(f"   ohne {BEDINGUNGEN[i][0]:<26} {'loesbar' if i in treffer else 'weiter unloesbar'}")
    print(f"\nGefundene Schuldige: {len(treffer)}")
    print("Keine einzelne Bedingung ist schuld. Genau das ist der Normalfall -")
    print("und der Grund, warum diese Suche in der Praxis so oft im Nichts endet.")


def teil_3_der_filter(alle: list[int]) -> list[int]:
    global aufrufe
    print("\n" + "=" * 68)
    print("3. Der Deletion Filter")
    print("=" * 68)
    vorher = aufrufe
    konflikt = deletion_filter(alle)
    kosten = aufrufe - vorher
    print(f"{kosten} Solveraufrufe -> Konflikt aus {len(konflikt)} von "
          f"{len(BEDINGUNGEN)} Bedingungen:\n")
    for name in namen(konflikt):
        print(f"   * {name}")
    print("\nProbe auf Unreduzierbarkeit - jede einzelne davon weglassen:")
    for i in konflikt:
        rest_loesbar = ist_loesbar([j for j in konflikt if j != i])
        print(f"   ohne {BEDINGUNGEN[i][0]:<26} {'loesbar' if rest_loesbar else 'UNLOESBAR - nicht minimal!'}")
    print(f"\nAufwand: {kosten} Aufrufe. Alle Teilmengen durchzuprobieren waeren "
          f"2^{len(BEDINGUNGEN)} = {2 ** len(BEDINGUNGEN):,} gewesen.".replace(",", "."))
    return konflikt


def teil_4_der_naechste_konflikt(alle: list[int], konflikt: list[int]) -> None:
    print("\n" + "=" * 68)
    print("4. Ein Konflikt ist selten einer")
    print("=" * 68)
    entfernt = konflikt[0]
    rest = [i for i in alle if i != entfernt]
    print(f"Angenommen, '{BEDINGUNGEN[entfernt][0]}' laesst sich verhandeln")
    print("und wird aus dem Modell genommen. Dann ist das Modell ...")
    if ist_loesbar(rest):
        print("... loesbar. Fertig.")
        return
    print("... immer noch unloesbar. Der Filter erneut:\n")
    zweiter = deletion_filter(rest)
    for name in namen(zweiter):
        print(f"   * {name}")
    gemeinsam = set(zweiter) & set(konflikt)
    print(f"\nUeberschneidung mit dem ersten Konflikt: {len(gemeinsam)} Bedingungen")
    print("Ein zweiter, unabhaengiger Widerspruch, den der erste verdeckt hat.")
    print("Deshalb ist die Konfliktsuche eine Schleife, kein einzelner Aufruf:")
    print("reparieren, neu suchen, bis das Modell loesbar ist.")


def teil_5_die_reihenfolge(alle: list[int]) -> None:
    print("\n" + "=" * 68)
    print("5. Es gibt nicht DEN Konflikt")
    print("=" * 68)
    print("Derselbe Filter, nur eine andere Pruefreihenfolge - 200-mal gewuerfelt:\n")
    zufall = np.random.default_rng(0)
    haeufigkeit: dict[tuple[int, ...], int] = {}
    for _ in range(200):
        ordnung = [int(i) for i in zufall.permutation(alle)]
        schluessel = tuple(sorted(deletion_filter(ordnung)))
        haeufigkeit[schluessel] = haeufigkeit.get(schluessel, 0) + 1
    for schluessel, anzahl in sorted(haeufigkeit.items(), key=lambda p: -p[1]):
        print(f"   {anzahl:3d}x  {len(schluessel)} Bedingungen: "
              f"{', '.join(namen(list(schluessel)))}")
    print(f"\n{len(haeufigkeit)} verschiedene minimale Konflikte in EINEM Modell.")
    print("Der Filter liefert *einen* kleinsten Konflikt, nicht *den* kleinsten -")
    print("den gibt es nicht. Alle oben sind gleichermassen korrekt.")


def teil_6_den_befund_steuern(alle: list[int]) -> None:
    print("\n" + "=" * 68)
    print("6. Den Befund brauchbar machen")
    print("=" * 68)
    print("Der Filter wirft heraus, was er zuerst in die Hand bekommt. Wer die")
    print("unveraenderlichen Bedingungen zuerst pruefen laesst, bekommt sie eher")
    print("aus dem Befund heraus - und sieht dafuer mehr Stellschrauben.\n")
    fest = [i for i in alle if BEDINGUNGEN[i][0] not in VERHANDELBAR]
    frei = [i for i in alle if BEDINGUNGEN[i][0] in VERHANDELBAR]
    for titel, ordnung in (("unveraenderliche zuerst", fest + frei),
                           ("veraenderliche zuerst  ", frei + fest)):
        konflikt = sorted(deletion_filter(ordnung))
        stellschrauben = [i for i in konflikt if BEDINGUNGEN[i][0] in VERHANDELBAR]
        print(f"   {titel} -> {len(konflikt)} Bedingungen, davon "
              f"{len(stellschrauben)} veraenderlich")
        print(f"      {', '.join(namen(konflikt))}")
    print("\nBeide Befunde sind wahr. Nur einer davon nennt dem Planer etwas,")
    print("das er tatsaechlich tun kann.")


if __name__ == "__main__":
    alle = list(range(len(BEDINGUNGEN)))
    teil_1_der_befund(alle)
    teil_2_die_naive_suche(alle)
    konflikt = teil_3_der_filter(alle)
    teil_4_der_naechste_konflikt(alle, konflikt)
    teil_5_die_reihenfolge(alle)
    teil_6_den_befund_steuern(alle)
    print("\n" + "=" * 68)
    print(f"Insgesamt {aufrufe} Solveraufrufe fuer die gesamte Diagnose.")
    print("=" * 68)

Die Ausgabe:

====================================================================
1. Was der Solver sagt
====================================================================
Modell: 5 Variablen, 14 Bedingungen
Status: INFEASIBLE

Mehr ist es nicht. Der Solver nennt keine Ursache, weil es die eine
Ursache nicht gibt: Unloesbarkeit ist eine Eigenschaft von Mengen von
Bedingungen, nicht von einzelnen Bedingungen.

====================================================================
2. Die naheliegende Idee - und warum sie hier scheitert
====================================================================
Jede Bedingung einmal weglassen und schauen, ob es dann geht:

   ohne Kapazitaet Montage         weiter unloesbar
   ohne Kapazitaet Lackieren       weiter unloesbar
   ohne Kapazitaet Pruefung        weiter unloesbar
   ohne Liefervertrag Rahmen       weiter unloesbar
   ohne Liefervertrag Gehaeuse     weiter unloesbar
   ohne Liefervertrag Deckel       weiter unloesbar
   ohne Liefervertrag Traeger      weiter unloesbar
   ohne Marktgrenze Rahmen         weiter unloesbar
   ohne Marktgrenze Gehaeuse       weiter unloesbar
   ohne Marktgrenze Deckel         weiter unloesbar
   ohne Marktgrenze Halter         weiter unloesbar
   ohne Mindestumsatz              weiter unloesbar
   ohne Sortimentsbreite           weiter unloesbar
   ohne Lackierbudget Schicht 2    weiter unloesbar

Gefundene Schuldige: 0
Keine einzelne Bedingung ist schuld. Genau das ist der Normalfall -
und der Grund, warum diese Suche in der Praxis so oft im Nichts endet.

====================================================================
3. Der Deletion Filter
====================================================================
14 Solveraufrufe -> Konflikt aus 3 von 14 Bedingungen:

   * Kapazitaet Lackieren
   * Liefervertrag Rahmen
   * Liefervertrag Gehaeuse

Probe auf Unreduzierbarkeit - jede einzelne davon weglassen:
   ohne Kapazitaet Lackieren       loesbar
   ohne Liefervertrag Rahmen       loesbar
   ohne Liefervertrag Gehaeuse     loesbar

Aufwand: 14 Aufrufe. Alle Teilmengen durchzuprobieren waeren 2^14 = 16.384 gewesen.

====================================================================
4. Ein Konflikt ist selten einer
====================================================================
Angenommen, 'Kapazitaet Lackieren' laesst sich verhandeln
und wird aus dem Modell genommen. Dann ist das Modell ...
... immer noch unloesbar. Der Filter erneut:

   * Kapazitaet Montage
   * Liefervertrag Traeger
   * Mindestumsatz
   * Lackierbudget Schicht 2

Ueberschneidung mit dem ersten Konflikt: 0 Bedingungen
Ein zweiter, unabhaengiger Widerspruch, den der erste verdeckt hat.
Deshalb ist die Konfliktsuche eine Schleife, kein einzelner Aufruf:
reparieren, neu suchen, bis das Modell loesbar ist.

====================================================================
5. Es gibt nicht DEN Konflikt
====================================================================
Derselbe Filter, nur eine andere Pruefreihenfolge - 200-mal gewuerfelt:

    67x  3 Bedingungen: Kapazitaet Lackieren, Liefervertrag Rahmen, Liefervertrag Gehaeuse
    32x  4 Bedingungen: Kapazitaet Montage, Kapazitaet Lackieren, Marktgrenze Deckel, Mindestumsatz
    20x  4 Bedingungen: Kapazitaet Montage, Liefervertrag Traeger, Mindestumsatz, Sortimentsbreite
    19x  4 Bedingungen: Kapazitaet Montage, Liefervertrag Traeger, Mindestumsatz, Lackierbudget Schicht 2
    19x  4 Bedingungen: Kapazitaet Montage, Liefervertrag Traeger, Marktgrenze Gehaeuse, Mindestumsatz
    17x  5 Bedingungen: Kapazitaet Lackieren, Kapazitaet Pruefung, Liefervertrag Gehaeuse, Marktgrenze Deckel, Mindestumsatz
    11x  4 Bedingungen: Kapazitaet Montage, Liefervertrag Deckel, Liefervertrag Traeger, Mindestumsatz
     8x  4 Bedingungen: Kapazitaet Montage, Liefervertrag Rahmen, Liefervertrag Traeger, Mindestumsatz
     7x  4 Bedingungen: Kapazitaet Montage, Kapazitaet Lackieren, Liefervertrag Traeger, Mindestumsatz

9 verschiedene minimale Konflikte in EINEM Modell.
Der Filter liefert *einen* kleinsten Konflikt, nicht *den* kleinsten -
den gibt es nicht. Alle oben sind gleichermassen korrekt.

====================================================================
6. Den Befund brauchbar machen
====================================================================
Der Filter wirft heraus, was er zuerst in die Hand bekommt. Wer die
unveraenderlichen Bedingungen zuerst pruefen laesst, bekommt sie eher
aus dem Befund heraus - und sieht dafuer mehr Stellschrauben.

   unveraenderliche zuerst -> 4 Bedingungen, davon 2 veraenderlich
      Kapazitaet Montage, Kapazitaet Lackieren, Marktgrenze Deckel, Mindestumsatz
   veraenderliche zuerst   -> 3 Bedingungen, davon 1 veraenderlich
      Kapazitaet Lackieren, Liefervertrag Rahmen, Liefervertrag Gehaeuse

Beide Befunde sind wahr. Nur einer davon nennt dem Planer etwas,
das er tatsaechlich tun kann.

====================================================================
Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.
====================================================================

Vier Dinge sind daran wichtig.

  1. Die Zulässigkeitsprüfung braucht keine Zielfunktion. linprog bekommt einen Nullvektor als Ziel. Unlösbarkeit hängt nie an der Zielfunktion — wer beim Diagnostizieren die echte Zielfunktion mitschleppt, bezahlt Rechenzeit für nichts.
  2. Gefiltert werden nur die aufgeführten Bedingungen, nicht die Variablenschranken. Die Nichtnegativität steht in bounds und bleibt in jedem Teilmodell stehen. Sitzt Ihr Widerspruch in den Schranken (x >= 5 als Bound statt als Zeile), findet ihn der Filter nicht — schreiben Sie solche Grenzen dann als benannte Bedingung.
  3. Sprechende Namen sind kein Luxus. Der Befund ist genau so brauchbar wie die Namen, die darin vorkommen. constraint_47, constraint_112, constraint_9 ist kein Befund.
  4. Der Filter liefert einen kleinsten Konflikt, nicht den kleinsten. Dieses Modell mit vierzehn Bedingungen enthält neun verschiedene minimale Konflikte; welchen man zu sehen bekommt, entscheidet allein die Prüfreihenfolge. Das ist kein Mangel des Verfahrens, sondern eine Eigenschaft des Problems.

Aus Punkt 4 folgt der eigentliche Praxisgriff. Weil der Filter bevorzugt das hinauswirft, was er zuerst in die Hand bekommt, überlegen Sie vor dem Lauf, welche Bedingungen Sie im Ernstfall tatsächlich ändern könnten — und lassen Sie die unveränderlichen zuerst prüfen. Dann bleiben eher die Stellschrauben im Befund stehen. Im Beispiel oben liefert die Reihenfolge „Verträge und Marktgrenzen zuerst“ einen Befund mit zwei veränderlichen Kapazitäten, die umgekehrte Reihenfolge einen mit nur einer. Beide Befunde sind wahr; nur der erste nennt dem Planer etwas, das er tun kann.

⚠️ Was der Filter kostet Ein Konflikt kostet n Solverläufe. Bei 14 Bedingungen sind das 14 — bei 50 000 Restriktionen mit je zehn Sekunden Lösungszeit wären es knapp sechs Tage. Für große Modelle filtert man deshalb nicht einzeln, sondern gruppenweise: erst über Bedingungsblöcke (alle Kapazitäten, alle Verträge, alle Zeitfenster) laufen, dann den Filter nur noch innerhalb des einen Blocks anwenden, der übrig bleibt. Das ist derselbe Algorithmus auf einer gröberen Ebene und senkt die Zahl der Läufe um Größenordnungen.

Das Verhältnis zur Relaxation. Der Deletion Filter beantwortet die Frage „was widerspricht sich?“; die Schlupfvariablen aus Schritt 3 beantworten die Frage „was tun wir jetzt?“. Beide gehören in ein Produktionssystem, aber an verschiedene Stellen: der Filter in die Entwicklung und in die Fehlersuche, die Relaxation in den Betrieb (Infeasibility_Diagnose.py, Abschnitt 22.3). Ein System, das im Ernstfall den Konflikt benennt und trotzdem einen Notfallplan liefert, hat beides.

🎯 Die eigentliche Lehre INFEASIBLE in Produktion ist ein Entwurfsfehler, kein Betriebsfehler. Ein System, das im Ernstfall nur „geht nicht“ sagt, ist wertlos. Bauen Sie Schlupfvariablen von vornherein ein (Abschnitt 22.3).


C2 — UNBOUNDED

Bedeutung. Die Zielfunktion lässt sich unbegrenzt verbessern.

Praktisch immer eine dieser drei Ursachen:

  1. Eine Kapazitätsbedingung fehlt. Die häufigste Ursache. Prüfen Sie: Gibt es für jede Variable eine Obergrenze — entweder explizit oder implizit über eine Ressource?
  2. Vorzeichenfehler. Sie minimieren, wo Sie maximieren wollten (oder umgekehrt), und der Zielwert läuft in die falsche Richtung davon.
  3. Freie Variablen ohne Nichtnegativität. bounds=[(None, None)] statt [(0, None)].
# Diagnose: kuenstliche Schranke einziehen und sehen, wohin es laeuft
bounds = [(0, 1e6)] * n
res = linprog(...)
gross = [j for j in range(n) if res.x[j] > 1e5]
print(f"Diese Variablen laufen an die Kunstschranke: {gross}")

Die so gefundenen Variablen sind die, denen eine echte Beschränkung fehlt.


C3 — Zu langsam

Diagnosereihenfolge

1. Ist es überhaupt das Lösen?

import time
t0 = time.perf_counter(); modell_bauen(); t1 = time.perf_counter()
loesen();                                  t2 = time.perf_counter()
print(f"Aufbau {t1-t0:.2f}s | Loesen {t2-t1:.2f}s")

Bei CVXPY und bei Schleifen über Szenarien ist oft der Aufbau der Engpass, nicht der Solver (siehe Abschnitt 20.6).

2. Big-M zu groß? Der häufigste Grund für explodierende MILP (Gemischt-ganzzahlige Optimierung)-Laufzeiten. Setzen Sie M auf die kleinste gültige Schranke (Abschnitt 6.5).

3. Symmetrie im Modell? Identische Maschinen, austauschbare Mitarbeitende, gleichwertige Fahrzeuge — der Solver durchsucht alle Vertauschungen. Abhilfe: Ordnungsbedingungen (Muster B20).

4. Falsche Solverfamilie? Zuordnungsprobleme mit MILP, Routing mit CP (Constraint Programming)-SAT (Boolean Satisfiability) von Hand nachgebaut, konvexe Probleme mit scipy.optimize.minimize — jeweils Größenordnungen langsamer als das passende Werkzeug (Abschnitt 3.6).

5. Wird vektorisiert?

# langsam:                       # schnell:
for s in range(S):               constraints.append(u >= -(R @ w) - gamma)
    constraints.append(u[s] >= -R[s] @ w - gamma)

6. Gap akzeptieren.

loeser.parameters.relative_gap_limit = 0.02     # CP-SAT
loeser.parameters.max_time_in_seconds = 30.0

Bei ±10 % Datenunsicherheit sind die letzten 2 % Optimalität verlorene Zeit.


C4 — Unsinniges Ergebnis

Der Solver hat immer recht — bezogen auf das Modell, das Sie ihm gegeben haben. Wenn das Ergebnis unsinnig ist, beschreibt Ihr Modell nicht das Problem, das Sie meinen.

Checkliste

Die wirksamste Gegenmaßnahme

# Nach JEDEM Lösen: alle Regeln explizit nachprüfen
assert abs(w.sum() - 1) < 1e-6,       "Budget nicht eingehalten"
assert w.min() > -1e-9,               "negatives Gewicht"
assert w.max() <= grenze + 1e-9,      "Positionsgrenze verletzt"
for sektor, idx in sektoren.items():
    assert w[idx].sum() <= limit[sektor] + 1e-9, f"Sektor {sektor} verletzt"

Diese Zeilen kosten Sekunden und fangen die Fehlerklasse ab, die der Solver nicht melden kann: dass Ihr Modell etwas anderes beschreibt, als Sie glauben.


C5 — Instabile Lösung

Symptom. Ein zusätzlicher Handelstag, und das Portfolio sieht völlig anders aus.

Ursachen und Abhilfen:

Ursache Prüfung Abhilfe
Schlecht konditionierte Kovarianzmatrix np.linalg.cond(Sigma) Ledoit-Wolf-Shrinkage (Kapitel 18)
Zu wenige Beobachtungen T/N berechnen Historie verlängern oder Universum verkleinern
Alternativoptima Zielfunktion bei mehreren Lösungen gleich? Regularisierung: kleinen L_2-Term addieren
Fehlende Turnover-Dämpfung Turnover messen L_1-Strafe (Abschnitt 20.5)
eig = np.linalg.eigvalsh(Sigma)
print(f"Kondition {eig.max()/eig.min():.0f} | kleinster EW {eig.min():.2e}")
# Faustregel: Kondition > 1000 ist bedenklich, > 10000 kritisch

Derselbe Fehler außerhalb der Finanzwelt

Instabilität ist kein Portfolioproblem, sondern ein Skalierungsproblem — und es trifft jedes Modell, in dem Größen sehr verschiedener Ordnung nebeneinanderstehen. Euro-Beträge (10^7) und Tonnen-Angaben (10^{-3}) in derselben Matrix erzeugen eine Konditionszahl, die aus einer Datenunsicherheit von 0,1 % eine Lösungsänderung von 100 % machen kann. Die Konditionszahl ist genau die Obergrenze dieses Verstärkungsfaktors.

print(f"Kondition der Restriktionsmatrix: {np.linalg.cond(A):.1e}")
# Grobe Peilung: Wertebereiche der Koeffizienten anschauen
print(f"kleinster / groesster Betrag: {np.abs(A[A != 0]).min():.1e} "
      f"/ {np.abs(A).max():.1e}")

Klaffen die Beträge um mehr als drei, vier Größenordnungen auseinander, rechnen Sie in anderen Einheiten (Tausend Euro statt Euro, Kilogramm statt Tonnen) oder skalieren Sie Zeilen und Spalten automatisch. Skalierung_Kondition.py zeigt beides samt Ruiz-Equilibrierung (Abschnitt 2.7).


C6 — Falsche Dualwerte

Symptom. Schattenpreise sind negativ, wo sie positiv sein sollten — oder null bei einer offensichtlich knappen Ressource.

Die drei Prüfungen

1. Vorzeichenkonvention. Haben Sie zur Maximierung negiert?

schattenpreise = -res.ineqlin.marginals    # bei linprog nach Negation

Genau das ist die Vorzeichenfalle aus Abschnitt 5.7.

2. Komplementärer Schlupf.

for s, y in zip(res.slack, schattenpreise):
    assert abs(s * y) < 1e-6, "Komplementaerer Schlupf verletzt!"

Eine Ressource mit Reserve muss Schattenpreis 0 haben.

3. Numerische Gegenprobe — der zuverlässigste Test.

b_plus = b.copy(); b_plus[i] += 1.0
zuwachs = loese(b_plus) - loese(b)
assert abs(zuwachs - schattenpreise[i]) < 1e-6

Dieser Test ist unabhängig von jeder Vorzeichenkonvention. Nutzen Sie ihn.

4. Wenn alle drei Prüfungen bestehen und der Wert trotzdem seltsam ist: Entartung. Die drei Prüfungen oben setzen voraus, dass es einen richtigen Schattenpreis gibt. Bei einem entarteten Optimum — mehr aktive Nebenbedingungen als Variablen — gibt es den nicht. Der Wert ist dann nicht falsch, sondern mehrdeutig: zwei korrekte Solver liefern für dasselbe Optimum verschiedene Dualwerte, und die numerische Gegenprobe aus Schritt 3 schlägt fehl, weil der Zuwachs nach links und nach rechts unterschiedlich ausfällt.

aktiv = int(np.sum(res.slack < 1e-9))          # wie viele Bedingungen sind straff?
if aktiv > len(res.x):
    print(f"Entartet: {aktiv} aktive Bedingungen bei {len(res.x)} Variablen.")
    print("Einen einzelnen Schattenpreis zu berichten waere hier irrefuehrend.")

Die belastbare Antwort ist dann keine Zahl, sondern eine Spanne über alle optimalen Dualwerte — Toleranzen_und_Entartung.py rechnet sie vor (Abschnitt 5.9).


C7 — Widersprüchliche Solver

Symptom. linprog sagt 530, CVXPY sagt 529,8.

Diagnose: 1. Ist es nur Toleranz? Unterschiede unter 10^{-6} relativ sind normal. Vergleichen Sie nie mit ==, sondern mit np.isclose(). 2. Ist das Modell wirklich identisch? Häufigster Fall: In einer Formulierung fehlt eine Bedingung oder das Vorzeichen einer Ungleichung ist gedreht. 3. Alternativoptima. Verschiedene Lösungsvektoren bei gleichem Zielwert sind kein Widerspruch — das Problem hat mehrere Optima (semidefinite Matrix, parallele Zielfunktion). 4. Ist eines der Ergebnisse gar nicht optimal? Status prüfen: optimal_inaccurate bedeutet, der Solver hat aufgegeben.


C8 — DCPError

cvxpy.error.DCPError: Problem does not follow DCP rules.

Bedeutung. CVXPY kann die Konvexität Ihres Ausdrucks nicht beweisen — es lehnt ab, statt ein Ergebnis ohne Garantie zu liefern. Das ist ein Feature.

Problematischer Ausdruck Warum Lösung
x * y (beide Variablen) bilinear umformulieren oder MILP/NLP (Nichtlineare Programmierung)
a / x nicht konvex cp.inv_pos(x) bei x>0
cp.sqrt(cp.quad_form(w, S)) cp.sqrt ist konkav und verlangt ein konkaves Argument — quad_form ist konvex. Der Ausdruck ist nie DCP (Disciplined Convex Programming), egal wie sauber S ist Cholesky S = LL^\top, dann cp.norm2(L.T @ w): die 2-Norm eines affinen Ausdrucks (siehe CVaR_Portfolio.py)
cp.quad_form(w, S) bei numerisch unsauberem S Eigenwerte knapp unter null durch Rundung — CVXPY erkennt S nicht als PSD cp.psd_wrap(S), wenn S nachweislich PSD ist (z. B. eine Kovarianzmatrix). Behebt nur diese numerische Beanstandung, keine Regelverletzung im Aufbau
Quotient (z. B. Sharpe Ratio) nicht konvex Korn-Transformation (Abschnitt 19.4)
cp.log(x) maximieren konkav — das ist erlaubt in cp.Maximize verwenden
print(problem.is_dcp())                      # False?
for c in problem.constraints:
    print(c.is_dcp(), c)                     # zeigt die Schuldige
print(problem.objective.is_dcp())

C9 — Importfehler

ImportError: .../highspy/_core...so: undefined symbol: _ZN5Highs13releaseMemoryEv

Ursache. ortools und highspy bringen beide eine eigene HiGHS-Kopie mit; sie lassen sich auf vielen Systemen nicht im selben Prozess importieren (siehe Abschnitt 3.5). Der Konflikt entsteht auch indirekt: cvxpy importiert ein installiertes highspy bei der Solver-Erkennung selbst mit — ein Skript, das erst cvxpy und dann ortools importiert, crasht daher mit derselben Meldung.

Abhilfen (in dieser Reihenfolge): 1. Nur eines von beiden im selben Skript verwenden. 2. Getrennte Prozesse — ein ProcessPoolExecutor mit mp_context="spawn" und max_tasks_per_child=1, siehe Ein_System_Vier_Ansaetze.py. 3. Auf highspy verzichten: HiGHS ist ohnehin Backend von scipy.optimize.linprog und CVXPY. 4. Getrennte virtuelle Umgebungen.


C10 — Verdächtig guter Backtest

Faustregel: Eine Sharpe Ratio über 2 bei einer einfachen Strategie ist fast immer ein Fehler, kein Fund.

Prüfreihenfolge

1. Lookahead-Selbsttest.

signal_vorher = berechne_signal(stichtag)
kurse.loc[kurse.index > stichtag] *= 3.0
signal_nachher = berechne_signal(stichtag)
assert np.allclose(signal_vorher, signal_nachher), "LOOKAHEAD-BIAS!"

2. Zeitindizes prüfen. iloc[t] gegen iloc[t+1] — die Gewichte von heute gehören auf die Rendite von morgen.

3. Survivorship. Wurde das Universum nach Kriterien gefiltert, die erst am Ende bekannt waren (dropna() über den gesamten Zeitraum!)?

4. Kosten. Sind Gebühren, Spread und Slippage verbucht?

5. Wie viele Varianten haben Sie getestet? Bei 20 Versuchen findet man auch in reinem Rauschen eine „signifikante“ Strategie (Abschnitt 21.6).

6. Nach Teilzeiträumen aufschlüsseln. Stammt die gesamte Überrendite aus einem einzigen Quartal?


C11 — Vertauschte Spalten

Symptom. Ergebnisse sind plausibel, aber falsch beschriftet — oder eine Sektorgrenze greift auf die falschen Titel.

Ursache. yfinance und viele andere Datenquellen liefern Spalten alphabetisch sortiert, nicht in der Reihenfolge Ihrer Anfrage.

# IMMER nach dem Import:
kurse = roh["Close"][tickers].dropna()          # erzwingt die Reihenfolge
assert list(kurse.columns) == tickers, (
    f"Reihenfolge weicht ab!\n erwartet: {tickers}\n erhalten: {list(kurse.columns)}")

# Und Gruppen NIE ueber Positionsindizes definieren:
SEKTOREN = {"Technologie": ["AAPL", "MSFT", "NVDA", "AMZN"]}       # gut
tech_idx = [tickers.index(t) for t in SEKTOREN["Technologie"]]     # bricht bei Tippfehler
tech_indices = [0, 1, 2, 3]                                        # SCHLECHT

Die goldene Diagnoseregel

Wenn Sie nicht weiterkommen: Verkleinern Sie das Problem, bis Sie es von Hand nachrechnen können. Drei Mitarbeitende, zwei Schichten. Zwei Aktien, drei Tage. Fast jeder Modellierungsfehler wird an einer Instanz sichtbar, deren richtige Antwort Sie kennen — und fast keiner wird an einer Instanz sichtbar, deren Antwort Sie nicht unabhängig prüfen können.