Version 04 als eigenes Repository

Erster Commit des Strangs "Optimierte Entscheidungsfindung mit Python"
(Version 04). Die Historie der 71 Commits bis zur Trennung bleibt im
uebergeordneten Repository OR_mit_Python liegen, das ab jetzt nur noch
Version_03 (eingefroren) verwaltet und Version_04/ ignoriert.

Bewusst kein "git subtree split": Der Pfad Version_04/ existiert erst seit
der Verzeichnistrennung, ein Split braechte daher nur 7 der 41 einschlaegigen
Commits - eine Teilhistorie, die vollstaendig aussieht und es nicht ist.

Stand: 5 Teile, 23 Kapitel, 5 Anhaenge, 292 Abschnitte, 703 Querverweise,
325 Indexmarken, 73 Beispielprogramme, 32 SVGs, 4 Plotly-Figuren,
25 Notebooks, PDF mit 715 Seiten.

Zusaetzlich in diesem Commit:

* pyproject.toml mit Abhaengigkeitsgruppen finance, large-scale, api,
  figures, dev, empfehlungen. Die abgedruckte requirements.txt bleibt
  unveraendert daneben bestehen. ortools steht in der Grundausstattung,
  highspy erst in [large-scale] - so kann der HiGHS-Symbolkonflikt bei der
  schlanken Installation gar nicht erst auftreten.

* Dabei zwei Funde: graphviz wird von erzeuge_architektur_diagramme.py
  importiert, fehlt aber in requirements.txt (jetzt in [figures]); pymoo
  steht in requirements.txt, wird aber von keinem Programm importiert,
  sondern nur im Kapitel Metaheuristiken empfohlen (jetzt in
  [empfehlungen]).

* NEUER_TITEL.md nach Kritik_und_Verbesserungsvorschlaege/ verschoben - es
  ist die Vorlage des Titelblatts, kein Bestandteil des Werks. Die beiden
  Fundstellen in PROGRESS.md und erzeuge_titelseite.py nachgezogen.

* PROGRESS.md nannte noch den Untertitel der ersten Fassung; auf den
  tatsaechlichen aus erzeuge_titelseite.py korrigiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dschlueter 2026-09-08 01:20:09 +02:00
commit b7af2f1d9a
468 changed files with 262141 additions and 0 deletions

View file

@ -0,0 +1,520 @@
```{=latex}
\begin{center}
\includesvg[width=\linewidth]{bilder_04/titelseite.svg}
\end{center}
\newpage
```
```{=html}
<figure>
<img src="bilder_04/titelseite.svg" alt="Titelseite — Optimierte Entscheidungsfindung mit Python · Dieter Schlüter" />
<figcaption>Titelseite — Optimierte Entscheidungsfindung mit Python · Dieter Schlüter</figcaption>
</figure>
```
---
# Optimierte Entscheidungsfindung mit Python {-}
## Von der mathematischen Modellierung zu praktischen intelligenten Lösungen für Betrieb, Technik und Finanzmärkte {-}
**Ein praxisorientiertes Kompendium für Entscheider** mit Constraint Programming, Vektor-
und Matrixmethoden sowie HiGHS, SciPy, Google OR-Tools und CVXPY.
Von **Personaleinsatz-, Schicht- und Vertretungsplänen, Fahrtrouten, Logistik und
Maschinen- und Netzwerkauslastung** über **Energieverteilung und Ressourcenplanung** bis zur
**Portfoliooptimierung und besseren Entscheidungen an den Finanzmärkten** — mit Python
modellieren, optimieren und belegbar fundierte Entscheidungen treffen.
Autor / Herausgeber: Dieter Schlüter
<dieter(dot)schlueter(atsign)linix(dot)de>
Stand: {datum}
---
## Vorwort {-}
Dieses Buch hat ein einziges Ziel: **Sie sollen am Ende in der Lage sein, ein reales
Entscheidungsproblem aus Ihrem eigenen Umfeld in ein mathematisches Modell zu übersetzen,
es in Python zu lösen und das Ergebnis jemandem zu erklären, der kein Mathematiker ist.**
Dieses Kompendium versteht sich als **Arbeitsbuch**. Das zeigt sich an drei Stellen:
**Erstens: Formeln werden übersetzt.** Zu jeder nicht-trivialen Formel gehört in dieser
Ausgabe eine Lesehilfe, die jedes Symbol einzeln benennt, und eine Umschreibung in
Alltagssprache. Formeln sind eine Abkürzung für Menschen, die den Inhalt schon kennen —
wer ihn erst lernt, braucht den ausgeschriebenen Text daneben. Niemand sollte an der
Notation scheitern, wenn er den Gedanken versteht.
**Zweitens: Es wird geübt.** Über 130 Aufgaben mit vollständigen Lösungen begleiten die
Kapitel. Sie sind gestaffelt: von Verständnisfragen, die man im Kopf beantwortet, über
Handrechnungen bis zu Programmieraufgaben, die auf den Kapitelbeispielen aufbauen. Wer
Optimierung nur liest, kann anschließend über Optimierung reden. Wer die Aufgaben rechnet,
kann optimieren.
**Drittens: Der Weg zur eigenen Anwendung ist ausgeschildert.** Die Projektwerkstatt am Ende
enthält elf vollständig ausgearbeitete Projektaufträge — vom Schul-Vertretungsplaner über
die Tourenoptimierung eines Lieferdienstes bis zum eigenen Portfolio-Rebalancer. Jeder
Auftrag nennt Datenquellen, Modellskizze, Abnahmekriterien und Stolperfallen. Sie sind so
zugeschnitten, dass sie in 10 bis 25 Stunden zu einem vorzeigbaren Ergebnis führen.
---
## Ziel der Masterclass {-}
Ein umfassendes, didaktisch von Grund auf aufgebautes Lehrbuch zur mathematischen
Entscheidungsoptimierung (**Operations Research, OR**) mit praktischer Umsetzung in
**Python**. Dieser Kurs der **mathematischen Optimierung** setzt kein Vorwissen in OR voraus
und führt schrittweise von den algebraischen und kombinatorischen Grundlagen über
Scheduling- und Constraint-Modelle bis hin zur professionellen Anwendung im quantitativen
Aktienhandel, Portfoliomanagement und Risikocontrolling.
### Vorausgesetzte Kenntnisse {-}
Vorausgesetzt werden **sichere Python-Kenntnisse** (Funktionen, Klassen, NumPy, pandas)
sowie Mathematik auf Grundstudiumsniveau:
| Gebiet | Was Sie können sollten | Wo im Buch gebraucht |
| --- | --- | --- |
| **Lineare Algebra** | Vektoren, Matrizen, Matrix-Vektor-Produkt, Eigenwerte | ab {ref:kap:fundament}, zentral in {ref:kap:qp-nlp}, {ref:kap:finanzdaten}, {ref:kap:markowitz} |
| **Analysis** | Ableitung, Gradient, notwendige Bedingung erster Ordnung | {ref:kap:qp-nlp} |
| **Statistik** | Erwartungswert, Varianz, Standardabweichung, Quantil | {ref:kap:unsicherheit}, 1114 |
| **Python** | Listen, Dictionaries, Schleifen, Funktionen, Klassen, NumPy-Arrays | durchgängig |
> **Wenn Ihnen Mathematik-Bausteine fehlen:** {ref:kap:fundament} fasst die benötigte lineare Algebra
> vollständig zusammen, {ref:sec:qp-nlp-lernziele} wiederholt Gradienten. Sie können also einsteigen und
> die Lücken unterwegs schließen. Nur die Statistik ab {ref:teil:anwendungen} setzt wirklich Vorwissen
> voraus — dort hilft ein Blick in ein Einführungswerk zur Wahrscheinlichkeitsrechnung.
### Benötigte Ressourcen {-}
Ein Rechner mit Python 3.10 oder neuer genügt. **Spezial-Hardware (GPU) ist nicht nötig**
sämtliche Beispiele laufen auf einem gewöhnlichen Notebook, die meisten in unter zehn
Sekunden. Die {ref:kap:finanzdaten} bis 14 benötigen einen **Internetzugang**, da sie aktuelle
Kursdaten über `yfinance` laden.
---
## Setup & Installation {-}
Alle Beispielprogramme sind unter Python 3.10+ lauffähig. Legen Sie zunächst eine virtuelle
Umgebung an — so bleiben die Kurspakete von Ihrer Systeminstallation getrennt:
```bash
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install --upgrade pip
pip install ortools highspy cvxpy scikit-learn yfinance matplotlib pandas numpy scipy \
openpyxl polars plotly pyomo linopy pymoo pydantic
```
Alternativ liegt im Ordner `Operations_Research_mit_Python_Version_04_Programme/` eine
`requirements.txt`:
```bash
pip install -r Operations_Research_mit_Python_Version_04_Programme/requirements.txt
```
### Installationstest {-}
Prüfen Sie mit diesem Skript, ob alles bereitsteht, **bevor** Sie mit {ref:kap:einfuehrung} beginnen.
Es meldet für jedes Paket Version und Status und löst ein Mini-Optimierungsproblem.
Eine Besonderheit dabei — die Import-Reihenfolge ist bewusst gewählt: `highspy` und
`cvxpy` werden in der Paketübersicht nur auf Anwesenheit geprüft, und `ortools` lädt seine
native Bibliothek zuerst. Der Grund: `ortools` und `highspy` bringen jeweils eine eigene
HiGHS-Kopie mit, die sich nicht im selben Python-Prozess verträgt — und `cvxpy` importiert
ein installiertes `highspy` bei der Solver-Erkennung selbst mit. Lädt deren HiGHS-Kopie
zuerst, bricht der Test beim CP-SAT-Funktionstest mit einem kryptischen
`ImportError: undefined symbol` ab; so gewinnt die Kopie von `ortools`, und CVXPY verzichtet
lediglich auf sein HIGHS-Interface (für alle Buchprogramme folgenlos). Details und
Abhilfen: {ref:sec:oekosystem-ein-system-vier-programmieransaetze} und
{ref:anhang:fehlerdiagnose}.
```python
#!/usr/bin/env python3
# Installationstest.py
"""
Vorspann: Prüft die vollständige Kurs-Installation.
Ausgabe: eine Zeile pro Paket plus ein gelöstes Mini-Modell je Solver-Familie.
"""
import importlib.metadata
import importlib.util
import logging
import sys
PAKETE = [
("numpy", "Numerische Basis (Vektoren, Matrizen)"),
("scipy", "Wissenschaftliche Algorithmen, linprog/minimize"),
("pandas", "Tabellen und Zeitreihen"),
("matplotlib", "Diagramme"),
("ortools", "Google OR-Tools: CP-SAT und Routing"),
("highspy", "HiGHS-Solver, direkte Steuerung"),
("cvxpy", "Konvexe Optimierung (Portfolio, CVaR)"),
("sklearn", "Ledoit-Wolf-Shrinkage der Kovarianzmatrix"),
("yfinance", "Kursdatenbezug (nur die Finanzkapitel)"),
]
# Nur auf Anwesenheit prüfen, NICHT importieren (siehe Kapitel Ökosystem):
# - highspy: seine HiGHS-Bibliothek verträgt sich nicht mit der Kopie von
# ortools im selben Prozess.
# - cvxpy: importiert bei der Solver-Erkennung ein installiertes highspy
# selbst mit und löst so denselben Konflikt aus. Importiert wird cvxpy
# erst im Funktionstest, nachdem ortools bereits geladen ist.
NUR_PRUEFEN = {"highspy", "cvxpy"}
def paket_version(name: str) -> str:
"""Liefert die installierte Version; ImportError, falls das Paket fehlt."""
if name in NUR_PRUEFEN:
if importlib.util.find_spec(name) is None:
raise ImportError(name) # nicht installiert
try:
# Version aus den Metadaten — das Modul wird ja nicht geladen
return importlib.metadata.version(name)
except importlib.metadata.PackageNotFoundError:
return "unbekannt" # installiert, aber ohne Metadaten
modul = importlib.import_module(name)
return getattr(modul, "__version__", "unbekannt")
def pruefe_pakete() -> list[str]:
"""Prüft jedes Paket und meldet Version oder Fehlgrund."""
fehlend = []
print(f"Python-Version: {sys.version.split()[0]}\n")
print(f"{'Paket':<12} {'Version':<12} {'Zweck'}")
print("-" * 78)
for name, zweck in PAKETE:
try:
print(f"{name:<12} {paket_version(name):<12} {zweck}")
except ImportError:
print(f"{name:<12} {'FEHLT':<12} {zweck}")
fehlend.append(name)
return fehlend
def teste_cp_sat() -> bool:
"""Löst 'maximiere x+y unter x+2y<=10, x<=4' mit CP-SAT. Erwartet: x=4, y=3."""
from ortools.sat.python import cp_model
modell = cp_model.CpModel()
x = modell.NewIntVar(0, 4, "x")
y = modell.NewIntVar(0, 10, "y")
modell.Add(x + 2 * y <= 10)
modell.Maximize(x + y)
loeser = cp_model.CpSolver()
status = loeser.Solve(modell)
ok = status == cp_model.OPTIMAL and loeser.Value(x) == 4 and loeser.Value(y) == 3
print(f"CP-SAT : x={loeser.Value(x)}, y={loeser.Value(y)} -> {'OK' if ok else 'FEHLER'}")
return ok
def teste_scipy_linprog() -> bool:
"""Löst dasselbe Problem kontinuierlich mit HiGHS über SciPy. Erwartet: x=4, y=3."""
from scipy.optimize import linprog
# linprog minimiert -> Zielfunktion negieren, um zu maximieren
ergebnis = linprog(c=[-1, -1], A_ub=[[1, 2]], b_ub=[10],
bounds=[(0, 4), (0, 10)], method="highs")
ok = ergebnis.success and abs(ergebnis.x[0] - 4) < 1e-6 and abs(ergebnis.x[1] - 3) < 1e-6
print(f"SciPy/HiGHS : x={ergebnis.x[0]:.2f}, y={ergebnis.x[1]:.2f} -> {'OK' if ok else 'FEHLER'}")
return ok
def teste_cvxpy() -> bool:
"""Minimiert (x-2)^2 unter x<=1 mit CVXPY. Erwartet: x=1."""
# CVXPY warnt beim Import, wenn sein HIGHS-Interface wegen der
# HiGHS-Kollision (siehe Kapitel Ökosystem) nicht lädt — für diesen Test
# folgenlos, deshalb die Warnung kurz stillstellen.
logging.disable(logging.WARNING)
import cvxpy as cp
logging.disable(logging.NOTSET)
x = cp.Variable()
problem = cp.Problem(cp.Minimize(cp.square(x - 2)), [x <= 1])
problem.solve()
ok = problem.status == "optimal" and abs(x.value - 1.0) < 1e-6
print(f"CVXPY : x={x.value:.4f} -> {'OK' if ok else 'FEHLER'}")
return ok
if __name__ == "__main__":
# ortools' native Bibliothek zuerst laden (Kapitel Ökosystem): die zuerst
# geladene HiGHS-Kopie gewinnt — und das soll die von ortools sein.
if importlib.util.find_spec("ortools") is not None:
from ortools.sat.python import cp_model
fehlend = pruefe_pakete()
print("\nSolver-Funktionstest")
print("-" * 78)
if fehlend:
print(f"Abbruch: Es fehlen {len(fehlend)} Pakete: {', '.join(fehlend)}")
print("Installation: pip install " + " ".join(
"scikit-learn" if p == "sklearn" else p for p in fehlend))
sys.exit(1)
alle_ok = all([teste_cp_sat(), teste_scipy_linprog(), teste_cvxpy()])
print("-" * 78)
print("Alles bereit — Sie können mit dem ersten Kapitel beginnen."
if alle_ok else "Mindestens ein Solver arbeitet fehlerhaft.")
sys.exit(0 if alle_ok else 1)
```
**Erwartete Ausgabe (Versionsnummern können abweichen):**
```
Python-Version: 3.12.3
Paket Version Zweck
------------------------------------------------------------------------------
numpy 2.1.3 Numerische Basis (Vektoren, Matrizen)
scipy 1.14.1 Wissenschaftliche Algorithmen, linprog/minimize
...
Solver-Funktionstest
------------------------------------------------------------------------------
CP-SAT : x=4, y=3 -> OK
SciPy/HiGHS : x=4.00, y=3.00 -> OK
CVXPY : x=1.0000 -> OK
------------------------------------------------------------------------------
Alles bereit — Sie können mit dem ersten Kapitel beginnen.
```
> **Hinweis zur Reproduzierbarkeit:** Die Programme aus {ref:teil:anwendungen} ({ref:kap:finanzdaten} bis {ref:kap:handelsmaschine}) laden
> aktuelle Kursdaten live über `yfinance`. Die im Buchtext abgedruckten Zahlen dienen daher
> nur zur Illustration — bei eigenem Ausführen weichen sie je nach Abrufdatum und gewähltem
> Zeitfenster ab. Das ist beabsichtigt und selbst Teil der Lektion über die Instabilität
> empirischer Schätzungen ({ref:kap:finanzdaten}).
---
## Wie dieses Buch aufgebaut ist {-}
Jedes Kapitel folgt derselben Struktur. Wenn Sie wissen, wie die Bausteine funktionieren,
können Sie gezielt springen.
### Die Lernelemente {-}
> **Kapitel auf einen Blick**
> Zu Beginn jedes Kapitels: Worum geht es, was wird vorausgesetzt, was können Sie danach,
> wie lange dauert es. Nutzen Sie diese Box, um zu entscheiden, ob Sie das Kapitel
> gerade brauchen.
> **📐 Formel-Lesehilfe**
> Steht unter jeder wichtigen Formel und benennt jedes Symbol einzeln, gefolgt von einer
> Umschreibung in Alltagssprache („Ohne Formel gesagt: …“). Wer die Formel schon versteht,
> überspringt die Box.
> **✏️ Handrechnung**
> Ein kleines Zahlenbeispiel, das Sie mit Papier und Bleistift nachvollziehen können —
> ohne Computer. Diese Rechnungen sind bewusst winzig gehalten, damit der Mechanismus
> sichtbar wird, den der Solver später millionenfach ausführt.
> **💻 Code-Durchgang**
> Nach jedem längeren Programm eine Tabelle, die die entscheidenden Zeilen erklärt.
> Vollständige Programme sind bewusst am Stück abgedruckt und nicht in Fragmente zerlegt —
> Sie sollen sie kopieren und laufen lassen können.
> **⚠️ Typische Fehler**
> Die Fallen, in die erfahrungsgemäß viele tappen — mit dem Symptom, das sie erzeugen,
> und der Korrektur.
> **🎯 Merksatz**
> Die eine Aussage, die vom Abschnitt hängen bleiben soll.
> **📝 Übungsaufgaben**
> Am Kapitelende, gestaffelt nach Schwierigkeit:
> ⭐ Verständnis (im Kopf oder in zwei Sätzen) ·
> ⭐⭐ Handrechnung oder kleine Modelländerung ·
> ⭐⭐⭐ Eigenständige Programmieraufgabe.
> **Alle Lösungen** stehen in [`90_Anhang_Loesungen.md`](90_Anhang_Loesungen.md).
> **✅ Selbsttest**
> Fünf Fragen mit Kurzantworten zur schnellen Selbstkontrolle.
### Der Dreischritt jedes Kapitels {-}
1. **Problemstellung** — eine konkrete Entscheidungssituation, kein abstraktes Beispiel.
2. **Mathematische Formulierung** — mit Lesehilfe und, wo möglich, Handrechnung.
3. **Lauffähiges Python-Programm** — vollständig abgedruckt, mit erwarteter Ausgabe.
---
## Lernpfade {-}
Sie müssen nicht alles lesen, um etwas Nützliches bauen zu können.
### Pfad A — Vollständiger Lehrgang (100140 Stunden) {-}
{ref:kap:einfuehrung} bis {ref:kap:testing} in Reihenfolge, danach ein Projekt aus der
Projektwerkstatt. Empfohlen, wenn Sie OR systematisch lernen wollen. Rechnen Sie mit
46 Stunden je Kapitel inklusive Übungen.
### Pfad B — Planung, Disposition, Personaleinsatz (ca. 25 Stunden) {-}
{ref:kap:einfuehrung} (Bausteine) → {ref:kap:fundament} (bis
{ref:sec:fundament-konvexitaet-die-grenze-zwischen-leicht-und}) → {ref:kap:modellierung}
(vom Wunsch zum Modell) → {ref:kap:lp} (LP, Schattenpreise) → {ref:kap:milp}
(Ja/Nein-Entscheidungen) → {ref:kap:cpsat} (CP-SAT, Scheduling) → Projekt **P1**
(Vertretungsplaner) oder **P2** (Schichtplanung). Das ist der kürzeste Weg zu einem
einsetzbaren Dienstplan-Optimierer.
*Wenn der Solver bei Ihrer echten Instanz stehen bleibt:* {ref:kap:metaheuristiken} und Projekt **P9**.
### Pfad C — Logistik und Tourenplanung (ca. 25 Stunden) {-}
{ref:kap:einfuehrung} → {ref:kap:fundament} (bis
{ref:sec:fundament-der-zulaessige-loesungsraum-und-das-polyeder}) → {ref:kap:lp} →
{ref:kap:milp} → {ref:kap:graphen} (Graphen, VRP) → Projekt **P3** (Liefertouren) oder
**P4** (Lagernetzwerk).
*Wenn die Instanzen zu groß werden:* {ref:kap:metaheuristiken} (gute Lösung in fester Zeit)
und {ref:kap:dekomposition} (das Modell umbauen statt die Lösung raten) — dazu Projekt
**P9**, das beide Wege am selben Problem vergleicht.
### Pfad D — Quantitative Finance (ca. 30 Stunden) {-}
{ref:kap:einfuehrung} → {ref:kap:fundament} → {ref:kap:qp-nlp} (QP, KKT) →
{ref:kap:finanzdaten} (Daten, Shrinkage) → {ref:kap:markowitz} (Markowitz) → {ref:kap:cvar}
(CVaR) → {ref:kap:handelsmaschine} (Backtest) → Projekt **P6** (Portfolio-Rebalancer) oder
**P7** (Risikoreport). **Wichtig:** Überspringen Sie {ref:kap:finanzdaten} nicht. Wer direkt
bei Markowitz einsteigt, optimiert Schätzrauschen und wundert sich über absurde Gewichte.
### Pfad E — Ich habe morgen ein konkretes Problem (24 Stunden) {-}
Lesen Sie {ref:sec:einfuehrung-historischer-kontext-und-evolution} (die vier Bausteine), dann {ref:anhang:modellierungsmuster}
([Modellierungsmuster](91_Anhang_Modellierungsmuster.md)) und suchen Sie dort das Muster,
das zu Ihrem Problem passt. Von jedem Muster führt ein Verweis in das zuständige Kapitel.
Wie die gewählte Bibliothek es schreibt, steht in {ref:anhang:spickzettel}
([Spickzettel](93_Anhang_Spickzettel.md)); wenn etwas nicht läuft, in
{ref:anhang:fehlerdiagnose} ([Fehlerdiagnose](92_Anhang_Fehlerdiagnose.md)).
### Pfad F — Vom Prototyp in den Betrieb (ca. 15 Stunden) {-}
Für alle, deren Modell **rechnet** und die es jetzt jemand anderem übergeben müssen:
{ref:kap:praxisfallen} (die fünf Praxisfallen, `or_kern.py`, Erklärbarkeit) →
{ref:kap:testing} (Testsuite, Mutationstest, Benchmark, HTTP-Dienst) →
{ref:anhang:fehlerdiagnose} als Nachschlagewerk für den Ernstfall, dann Projekt **P11**
(Vom Skript zum Dienst). Setzt voraus, dass Sie mindestens einen der Pfade B, C oder D
hinter sich haben — P11 baut auf einem Modell auf, das Sie schon haben.
*Der Kern in einem Satz:* Ein Modell, das nur auf Ihrem Rechner und nur mit Ihren Daten
läuft, ist ein Prototyp — kein System.
---
## Verzeichnis der Beispielprogramme {-}
Alle Beispielprogramme dieses Buchs im Überblick, sortiert nach Kapitel — praktisch für
Pfad E oder um gezielt nach einem Thema zu suchen:
| Programm | Thema | Kapitel |
| --- | --- | --- |
| `Installationstest.py` | Installationsprüfung | Vorspann |
| `Bot_Allokation.py` | Erstes CP-SAT-Modell | {ref:kap:einfuehrung} |
| `Brute_Force_Vergleich.py` | Kombinatorische Explosion | {ref:kap:einfuehrung} |
| `Bausteine_Vorlage.py` | Vorlage für eigene Modelle | {ref:kap:einfuehrung} |
| `Excel_Bruecke.py` | Excel-Mappe lesen, lösen, zurückschreiben | {ref:kap:einfuehrung} |
| `Matrixform.py` | Matrixform und Zulässigkeit | {ref:kap:fundament} |
| `Konvexitaet_Demo.py` | Sehnen-Test, lokale Optima | {ref:kap:fundament} |
| `Visualisierung_Loesungsraum.py` | Polyeder mit Ecken | {ref:kap:fundament} |
| `Skalierung_Kondition.py` | Konditionszahl, Ruiz, Toleranzen | {ref:kap:fundament} |
| `Solver_Wahl.py` | Entscheidungshilfe | {ref:kap:oekosystem} |
| `Ein_System_Vier_Ansaetze.py` | Vier Bibliotheken | {ref:kap:oekosystem} |
| `Modellierungsschichten.py` | Pyomo und Linopy | {ref:kap:oekosystem} |
| `Vektorisierte_Modellgenerierung.py` | Aufbauzeit vs. Lösezeit | {ref:kap:oekosystem} |
| `Vom_Wunsch_zum_Modell.py` | Fünf Modelle auf denselben Daten | {ref:kap:modellierung} |
| `Simplex_Tableau_LP.py` | Simplex von Grund auf | {ref:kap:lp} |
| `Sensitivitaetsanalyse.py` | Schattenpreise | {ref:kap:lp} |
| `Dualitaet_Nachweis.py` | Primal-dual, starke Dualität | {ref:kap:lp} |
| `Toleranzen_und_Entartung.py` | Entartung, Toleranzen, Dualspannen | {ref:kap:lp} |
| `Runden_Gegenbeispiel.py` | Warum Runden scheitert | {ref:kap:milp} |
| `Rucksack.py` | Knapsack, LP-Schranke | {ref:kap:milp} |
| `MILP_Portfolio_Fixgebuehren.py` | Fixkosten, Kardinalität | {ref:kap:milp} |
| `Solverstatus_und_Gap.py` | MIP-Gap, Zeitlimit, Statusfälle | {ref:kap:milp} |
| `Warmstart_Effekt.py` | LPT-Hinweis für CP-SAT | {ref:kap:milp} |
| `Big_M_Falle.py` | Trickle Flow bei zu großem M | {ref:kap:milp} |
| `Propagation_Demo.py` | Propagation messbar | {ref:kap:cpsat} |
| `CP_SAT_Vertretungssystem.py` | Vertretungsplan | {ref:kap:cpsat} |
| `JobShop_Intervalle.py` | Job-Shop-Scheduling | {ref:kap:cpsat} |
| `CP_SAT_Statusfaelle.py` | Die fünf Solver-Antworten | {ref:kap:cpsat} |
| `Strafgewichte.py` | Gewichte als Wechselkurse | {ref:kap:cpsat} |
| `Min_Cost_Flow.py` | Netzwerkfluss | {ref:kap:graphen} |
| `Zuordnung_Ungarisch.py` | Zuordnung, Unimodularität | {ref:kap:graphen} |
| `VRP_Flotten_Routing.py` | CVRPTW | {ref:kap:graphen} |
| `VRP_Kapazitaetsfalle.py` | Vergessene Dimension im Routing | {ref:kap:graphen} |
| `Simulated_Annealing.py` | Lokale Suche, Temperatur kalibrieren | {ref:kap:metaheuristiken} |
| `Metaheuristik_vs_Exakt.py` | Der Umschlagpunkt, und die Schranke | {ref:kap:metaheuristiken} |
| `Large_Neighborhood_Search.py` | Zerstören und exakt reparieren | {ref:kap:metaheuristiken} |
| `Spaltengenerierung.py` | Muster statt Stücke, Master und Pricing | {ref:kap:dekomposition} |
| `QP_Grundlagen.py` | Konvexität, DCP-Check | {ref:kap:qp-nlp} |
| `KKT_Nachweis.py` | KKT numerisch prüfen | {ref:kap:qp-nlp} |
| `Entropie_Maximierte_Allokation.py` | NLP mit Entropie | {ref:kap:qp-nlp} |
| `Lokale_Optima_Multistart.py` | Lokale Optima, Multistart | {ref:kap:qp-nlp} |
| `Fluch_des_Durchschnitts.py` | Optimum ≠ Mittelwert | {ref:kap:unsicherheit} |
| `Monte_Carlo.py` | Monte-Carlo-Bewertung | {ref:kap:unsicherheit} |
| `Stochastische_Optimierung.py` | Two-Stage mit Recourse | {ref:kap:unsicherheit} |
| `Robuste_Optimierung.py` | Worst-Case-Absicherung | {ref:kap:unsicherheit} |
| `Bellman_Minimalbeispiel.py` | Rückwärtsinduktion | {ref:kap:dynamische-programmierung} |
| `Mehrziel_Pareto.py` | Pareto-Front, ε-Constraint, Gewichtslücke | {ref:kap:mehrziel} |
| `Predict_then_Optimize.py` | MSE gegen Entscheidungskosten | {ref:kap:prognose} |
| `Strukturbruecke.py` | Derselbe Code über Werkstatt und Depot | {ref:kap:bruecke} |
| `Kraftwerkseinsatz.py` | Unit Commitment unter Windunsicherheit | {ref:kap:supplychain} |
| `Mehrperiodige_Order_Execution.py` | Almgren-Chriss | {ref:kap:dynamische-programmierung} |
| `Renditen_Vergleich.py` | Diskret vs. logarithmisch | {ref:kap:finanzdaten} |
| `Schaetzrauschen_Demo.py` | Error-Maximizer messen | {ref:kap:finanzdaten} |
| `Finanzdaten_Ledoit_Wolf.py` | Datenpipeline, Shrinkage | {ref:kap:finanzdaten} |
| `Kovarianz_Falle.py` | Singuläre Kovarianz, Error-Maximizer | {ref:kap:finanzdaten} |
| `Markowitz_CVXPY.py` | GMV, Max Sharpe, Frontier | {ref:kap:markowitz} |
| `Diversifikation_Demo.py` | Korrelation und Portfoliorisiko | {ref:kap:markowitz} |
| `Renditeschaetzung_Falle.py` | Schätzfehler in erwarteten Renditen | {ref:kap:markowitz} |
| `VaR_CVaR_Demo.py` | Fat Tails, Subadditivität | {ref:kap:cvar} |
| `CVaR_Portfolio.py` | CVaR mit Reibung | {ref:kap:cvar} |
| `QuantitativeTradingEngine.py` | Walk-Forward-Backtest | {ref:kap:handelsmaschine} |
| `Backtest_Fallen.py` | Fünf Selbsttäuschungen | {ref:kap:handelsmaschine} |
| `Data_Snooping.py` | Bestes aus N Versuchen auf Rauschen | {ref:kap:handelsmaschine} |
| `Infeasibility_Diagnose.py` | Notfallplan statt Fehler | {ref:kap:praxisfallen} |
| `Erklaerbarkeit.py` | Constraint-Trace, Was-wäre-wenn | {ref:kap:praxisfallen} |
| `Betriebsueberwachung.py` | Status, Gap und Zeitausschöpfung | {ref:kap:praxisfallen} |
| `or_kern.py` | Gemeinsamer Unterbau: Domäne, Status, Prüfung | {ref:kap:praxisfallen} |
| `Solverwechsel_CPSAT_HiGHS.py` | Derselbe Fall in zwei Solvern | {ref:kap:praxisfallen} |
| `test_or_kern.py` | Testsuite für ein Optimierungsmodell | {ref:kap:testing} |
| `Mutationstest.py` | Findet die Lücken der eigenen Tests | {ref:kap:testing} |
| `Benchmark_Skalierung.py` | Aufbau gegen Lösen, vier Bibliotheken | {ref:kap:testing} |
| `Optimierungsdienst.py` | Das Modell als HTTP-Dienst | {ref:kap:testing} |
| `Konfliktsuche.py` | Deletion Filter: den Widerspruch einkreisen | {ref:anhang:fehlerdiagnose} |
---
## Was dieses Buch nicht ist {-}
Es ist **keine Anleitung zum Bau eigener Solver** — wir nutzen ausgereifte Implementierungen
und investieren die Zeit stattdessen in die Modellierung. Die einzige Ausnahme ist der
Simplex-Algorithmus in {ref:kap:lp}, den wir von Grund auf programmieren, weil man ihn
verstanden haben muss, um Dualität und Schattenpreise zu begreifen.
Es ist **kein Buch über maschinelles Lernen**. Prognosen kommen nur so weit vor, wie sie als
Eingangsdaten einer Optimierung nötig sind.
Und der vierte Teil ist **ausdrücklich keine Anlageberatung**. Die Finanzbeispiele
demonstrieren Methodik an realen Daten, nicht handelbare Strategien. {ref:kap:praxisfallen} erklärt
ausführlich, warum ein gut aussehender Backtest noch keine funktionierende Strategie ist.
---
## Ein Wort zur Arbeitsweise {-}
Die wirksamste Art, mit diesem Buch zu arbeiten, ist unbequem: **Lesen Sie die
Problemstellung, schließen Sie das Buch und versuchen Sie, das Modell selbst
aufzuschreiben** — Variablen, Zielfunktion, Nebenbedingungen. Vergleichen Sie erst dann.
Die Abweichungen zwischen Ihrem Entwurf und dem Text sind genau die Stellen, an denen Sie
etwas lernen. Wer stattdessen mitliest und nickt, hat am Ende des Kapitels das angenehme
Gefühl, es verstanden zu haben — und steht beim eigenen Problem vor einem leeren Blatt.
Zweitens: **Lassen Sie jedes Programm laufen und verändern Sie danach eine Zahl.** Was
passiert mit dem Vertretungsplan, wenn eine Lehrkraft ausfällt? Was mit dem Portfolio, wenn
die Obergrenze von 20 % auf 10 % sinkt? Diese Experimente kosten Sekunden und bauen genau
die Intuition auf, die man später beim Modellieren braucht.
Und drittens: **Nehmen Sie die Übungen ernst.** Sie sind nicht Beiwerk, sondern der Ort, an
dem aus Kenntnis Können wird.
---
*Weiter mit:* [`01_Notation_und_Abkuerzungen.md`](01_Notation_und_Abkuerzungen.md) —
oder direkt zu [{ref:kap:einfuehrung}](10_Einfuehrung_OR.md), wenn Sie die Notation
lieber nachschlagen, sobald sie auftaucht.

View file

@ -0,0 +1,260 @@
# Notation und Abkürzungen
Dieses Kapitel ist zum Nachschlagen gedacht, nicht zum Durchlesen. Schlagen Sie hier nach,
sobald Ihnen ein Symbol oder ein Kürzel begegnet, das Sie nicht sicher zuordnen können.
---
## 1. Die Symbole der Optimierung
### 1.1 Grundgrößen jedes Modells
| Symbol | Sprechweise | Bedeutung | Beispiel aus dem Buch |
| --- | --- | --- | --- |
| $x$ | „iks“ | **Entscheidungsvariable(n)** — das, was der Solver festlegen darf | Anzahl gestarteter Trading-Bots ({ref:kap:einfuehrung}) |
| $\mathbf{x}$ | „Vektor iks“ | Alle Entscheidungsvariablen als Spaltenvektor | $\mathbf{x} = (x_1, \dots, x_n)^\top$ |
| $n$ | „en“ | Anzahl der Entscheidungsvariablen | 10 Aktien $\Rightarrow n = 10$ |
| $m$ | „em“ | Anzahl der Nebenbedingungen | 3 Ressourcen $\Rightarrow m = 3$ |
| $\mathbf{c}$ | „Vektor ze“ | **Kosten- bzw. Ertragsvektor** der Zielfunktion | Gewinn je Bot-Typ: $(150, 250)^\top$ |
| $\mathbf{A}$ | „Matrix a“ | **Technologiematrix** — wie viel Ressource verbraucht welche Variable | Zeile „RAM“: $(4, 6)$ |
| $\mathbf{b}$ | „Vektor be“ | **Kapazitätsvektor**, „rechte Seite“ der Ungleichungen | $(40, 60)^\top$ vCPU und GB |
| $f(\mathbf{x})$ | „ef von iks“ | **Zielfunktion** — die eine Zahl, die bewertet wird | Tagesgewinn in Euro |
| $g_i(\mathbf{x})$ | „ge i von iks“ | $i$-te **Ungleichungs-Nebenbedingung**, Form $g_i(\mathbf{x}) \le 0$ | „RAM-Verbrauch minus 60 GB“ |
| $h_j(\mathbf{x})$ | „ha j von iks“ | $j$-te **Gleichungs-Nebenbedingung**, Form $h_j(\mathbf{x}) = 0$ | „Summe der Gewichte minus 1“ |
| $s_i$ | „es i“ | **Schlupfvariable** — ungenutzte Reserve der Ressource $i$ | 1 GB RAM übrig $\Rightarrow s_2 = 1$ |
| $y_i,\ \lambda_i$ | „y i“, „lambda i“ | **Dualvariable / Schattenpreis** der Bedingung $i$ | 33,33 € je zusätzlicher Prüfstunde |
| $M$ | „groß em“ | **Big-M** — hinreichend große Konstante zum Ein-/Ausschalten von Bedingungen | $x_j \le M \cdot y_j$ |
| $z_i,\ y_i$ | | **Binärvariable** $\in \{0,1\}$ — Ja/Nein-Schalter | „Position $i$ wird eröffnet“ |
| $\mathcal{F}$ | „kalligrafisches ef“ | **Zulässiger Bereich** — Menge aller erlaubten Lösungen | das Polyeder aus {ref:kap:fundament} |
| $Z,\ Z^*$ | „zet“, „zet Stern“ | Zielfunktionswert bzw. **optimaler** Zielfunktionswert | $Z^* = 2300$ € |
| $\mathbf{x}^*$ | „iks Stern“ | Die **optimale Lösung**. Der Stern markiert immer „im Optimum“ | $\mathbf{x}^* = (7, 5)^\top$ |
### 1.2 Mengen und Zahlbereiche
| Symbol | Bedeutung | Praktische Folge |
| --- | --- | --- |
| $\mathbb{R}$ | Reelle Zahlen (beliebig teilbar) | Variable darf 3,7 sein — **LP**, schnell lösbar |
| $\mathbb{R}^n$ | $n$-Tupel reeller Zahlen | Der Raum, in dem $\mathbf{x}$ lebt |
| $\mathbb{Z}$ | Ganze Zahlen | Variable muss 3 oder 4 sein — **MILP**, NP-schwer |
| $\mathbb{N}_0$ | Natürliche Zahlen einschließlich 0 | Stückzahlen, nie negativ |
| $\{0, 1\}$ | Nur zwei Werte | Ja/Nein-Entscheidung |
| $\in$ | „ist Element von“ | $x \in \mathbb{Z}$: „$x$ ist ganzzahlig“ |
| $\forall$ | „für alle“ | $w_i \ge 0\ \forall i$: jedes Gewicht ist nichtnegativ |
| $\exists$ | „es existiert“ | seltener; „es gibt mindestens ein …“ |
| $\subseteq$ | „ist Teilmenge von“ | $\mathcal{C} \subseteq \mathbb{R}^n$ |
| $[a, b]$ | Geschlossenes Intervall | $\theta \in [0,1]$: $\theta$ liegt zwischen 0 und 1 |
### 1.3 Operatoren und Schreibweisen
| Symbol | Bedeutung | Lesehilfe |
| --- | --- | --- |
| $\sum_{i=1}^n a_i$ | Summe | „addiere $a_1$ bis $a_n$“ |
| $\mathbf{c}^\top \mathbf{x}$ | Skalarprodukt | „$c_1 x_1 + c_2 x_2 + \dots$“ — eine einzige Zahl |
| $^\top$ | Transponiert | dreht Zeilen und Spalten; macht aus Spalten- einen Zeilenvektor |
| $\mathbf{A}\mathbf{x}$ | Matrix-Vektor-Produkt | „berechne alle Ressourcenverbräuche auf einmal“ |
| $\min_x,\ \max_x$ | Minimum/Maximum über $x$ | „wähle $x$ so, dass … kleinst-/größtmöglich wird“ |
| $\arg\min$ | Argument des Minimums | nicht der *Wert*, sondern die *Stelle* des Minimums |
| $\nabla f$ | „Nabla ef“, Gradient | Vektor aller partiellen Ableitungen — zeigt bergauf |
| $\partial Z / \partial b_i$ | Partielle Ableitung | „wie ändert sich $Z$, wenn nur $b_i$ sich ändert?“ |
| $\Vert \mathbf{v} \Vert_1$ | $L_1$-Norm | Summe der Beträge: $\vert v_1\vert + \vert v_2\vert + \dots$ |
| $\Vert \mathbf{v} \Vert_2$ | $L_2$-Norm (euklidisch) | die gewöhnliche Länge eines Pfeils |
| $\mathbf{P} \succeq 0$ | positiv **semi**definit | alle Eigenwerte $\ge 0$; Funktion ist konvex |
| $\mathbf{P} \succ 0$ | positiv **definit** | alle Eigenwerte $> 0$; Funktion ist *streng* konvex |
| $\mathbb{E}[\cdot]$ | Erwartungswert | gewichteter Mittelwert über alle Szenarien |
| $\mathbb{1}$ oder $\mathbf{1}$ | Einsvektor | $(1,1,\dots,1)^\top$; $\mathbf{1}^\top\mathbf{w}$ ist die Summe aller $w_i$ |
| $\mathbf{I}$ | Einheitsmatrix | Diagonale 1, sonst 0 |
| s.t. / u. d. N. | *subject to* / unter den Nebenbedingungen | leitet die Restriktionen ein |
### 1.4 Symbole der Finanzkapitel (Teil IV)
| Symbol | Bedeutung | Einheit / typischer Wert |
| --- | --- | --- |
| $\mathbf{w}$ | **Portfoliogewichte** — Kapitalanteile je Titel | dimensionslos, Summe $= 1$ |
| $w_i$ | Anteil von Titel $i$ | $0{,}15 \equiv 15\,\%$ |
| $\boldsymbol{\mu}$ | „mü“ — Vektor der **erwarteten Renditen** | z. B. $0{,}11$ = 11 % p. a. |
| $\boldsymbol{\Sigma}$ | „Sigma“ — **Kovarianzmatrix** der Renditen | $N \times N$, Einheit Rendite² |
| $\sigma$ | „sigma“ — **Volatilität** (Standardabweichung) | $0{,}20$ = 20 % p. a. |
| $\sigma^2$ | **Varianz** | Quadrat der Volatilität |
| $\mathbf{S}$ | **Stichproben**-Kovarianzmatrix (aus Daten geschätzt) | verrauscht |
| $\mathbf{F}$ | **Shrinkage-Target** — strukturierte Vergleichsmatrix | verzerrt, aber stabil |
| $\delta$ | „delta“ — Shrinkage-Intensität | $\in [0,1]$, z. B. $0{,}18$ |
| $r_f$ | *risk-free rate*, **risikoloser Zins** | z. B. $0{,}03$ = 3 % p. a. |
| $R_{i,t}$ | **Diskrete Rendite** von Titel $i$ am Tag $t$ | $P_t/P_{t-1} - 1$ |
| $r_{i,t}$ | **Logarithmische Rendite** (stetige Rendite) | $\ln(P_t/P_{t-1})$ |
| $P_{i,t}$ | **Kurs** (bereinigter Schlusskurs) von Titel $i$ | in Währungseinheiten |
| $\lambda$ | Risikoaversion / Gewichtung des Risikoterms | Modellparameter, frei wählbar |
| $\alpha$ | Konfidenzniveau bei VaR/CVaR | $0{,}95$ = „schlechteste 5 % der Tage“ |
| $\gamma$ | Hilfsvariable, schätzt den VaR ({ref:kap:cvar}); Diskontfaktor ({ref:kap:dynamische-programmierung}) | kontextabhängig |
| $\eta$ | „eta“ — Marktimpact-Koeffizient | {ref:kap:dynamische-programmierung}, Almgren-Chriss |
| $X_t$ | Verbleibender Aktienbestand zum Zeitpunkt $t$ | {ref:kap:dynamische-programmierung} |
| $n_t$ | In Periode $t$ verkaufte Stückzahl | {ref:kap:dynamische-programmierung} |
| $S$ (kursiv, Skalar) | Anzahl der **Szenarien** | {ref:kap:unsicherheit}, {ref:kap:cvar} |
| $T$ | Anzahl **Zeitschritte** bzw. Handelstage | 252 Handelstage/Jahr |
| $N$ | Anzahl **Titel** im Universum | z. B. 10 Aktien |
### 1.5 Symbole der Verfahrenskapitel (Teil II und III)
Diese Zeichen kommen erst in den Kapiteln vor, die über das klassische LP hinausgehen.
| Symbol | Sprechweise | Bedeutung | Kapitel |
| --- | --- | --- | --- |
| $\pi$ | „pi“ | **Reihenfolge (Permutation)**: $\pi(k)$ ist der Auftrag, der als $k$-ter läuft | {ref:kap:metaheuristiken} |
| $\Delta$ | „delta“ | **Zugbewertung** — um wie viel ein Nachbarschaftszug die Lösung verschlechtert ($\Delta > 0$) oder verbessert ($\Delta \le 0$) | {ref:kap:metaheuristiken} |
| $T$ | „te“ | **Temperatur** im Simulated Annealing — steuert, wie oft eine Verschlechterung angenommen wird | {ref:kap:metaheuristiken} |
| $e^{-\Delta/T}$ | | **Annahmewahrscheinlichkeit** (Metropolis-Kriterium): kleine Verschlechterungen fast immer, große fast nie | {ref:kap:metaheuristiken} |
| $\pi_i$ | „pi i“ | **Dualpreis** der Bedingung $i$ im Master-LP — der Preis, mit dem das Pricing rechnet | {ref:kap:dekomposition} |
| $a_i$ | „a i“ | Wie oft Stück $i$ in einem **Schnittmuster** vorkommt (eine Spalte des Masters) | {ref:kap:dekomposition} |
| $1 - \sum_i \pi_i a_i$ | | **Reduzierte Kosten** einer neuen Spalte. Negativ heißt: Das Muster lohnt sich, nimm es auf | {ref:kap:dekomposition} |
| „$x$ dominiert $y$“ | | **Pareto-Dominanz**: $x$ ist in keinem Ziel schlechter und in mindestens einem besser | {ref:kap:mehrziel} |
| $\varepsilon$ | „epsilon“ | **Schranke im $\varepsilon$-Constraint-Verfahren**: ein Ziel wird zur Nebenbedingung, $e(x) \le \varepsilon$ | {ref:kap:mehrziel} |
| $\hat{d}_t$ | „d Dach t“ | **Prognostizierter** Wert — das Dach unterscheidet die Schätzung vom später eintretenden Ist-Wert $d_t$ | {ref:kap:prognose} |
| $a_{k,t}$ | „a k t“ | **Anfahrvariable**: Anlage $k$ wird in Stunde $t$ angefahren | {ref:kap:supplychain} |
| $u_{k,t}$ | „u k t“ | **Einsatzvariable**: Anlage $k$ läuft in Stunde $t$ | {ref:kap:supplychain} |
| $L_k$ | „el k“ | **Mindestlaufzeit** von Anlage $k$ in Stunden | {ref:kap:supplychain} |
| $\tau$ | „tau“ | Laufender **Zeitindex innerhalb eines Fensters** — dort, wo $t$ schon den Fensteranfang bezeichnet | {ref:kap:supplychain} |
> **⚠️ Achtung, Doppelbelegungen.** In der Literatur — und deshalb auch hier — tragen einige
> Buchstaben je nach Kapitel verschiedene Bedeutungen. Die wichtigsten Fälle:
> $\lambda$ ist in {ref:kap:lp} und {ref:kap:qp-nlp} ein **Lagrange-Multiplikator/Schattenpreis**, in {ref:kap:markowitz}
> und {ref:kap:cvar} ein **frei gewählter Risikoaversionsparameter**. $\gamma$ ist in {ref:kap:dynamische-programmierung} der
> **Diskontfaktor**, in {ref:kap:cvar} die **VaR-Hilfsvariable**. $S$ bezeichnet als Matrix
> $\mathbf{S}$ die Stichprobenkovarianz, als Skalar $S$ die Szenarienzahl. $\pi$ ist in
> {ref:kap:metaheuristiken} eine **Reihenfolge**, in {ref:kap:dekomposition} ein
> **Dualpreis** — beide Bedeutungen sind so verbreitet, dass ein Ausweichbuchstabe mehr
> verwirren als helfen würde. Der Kontext ist
> jeweils eindeutig, und die Kapitel weisen an der betreffenden Stelle darauf hin.
---
## 2. Abkürzungen — vollständig ausgeschrieben
### 2.1 Problemklassen und Verfahren
| Kürzel | Ausgeschrieben | Deutsch | Kapitel |
| --- | --- | --- | --- |
| **OR** | Operations Research | Unternehmensforschung / mathematische Entscheidungsoptimierung | {ref:kap:einfuehrung} |
| **LP** | Linear Program(ming) | Lineare Programmierung/Optimierung | {ref:kap:lp} |
| **MILP** | Mixed-Integer Linear Program(ming) | Gemischt-ganzzahlige lineare Optimierung | {ref:kap:milp} |
| **MIP** | Mixed-Integer Program(ming) | Oberbegriff, meist synonym zu MILP | {ref:kap:milp} |
| **IP** | Integer Program(ming) | Rein ganzzahlige Optimierung | {ref:kap:milp} |
| **QP** | Quadratic Program(ming) | Quadratische Optimierung | {ref:kap:qp-nlp} |
| **MIQP** | Mixed-Integer Quadratic Program(ming) | Ganzzahlig-quadratische Optimierung | {ref:kap:markowitz} |
| **NLP** | Nonlinear Program(ming) | Nichtlineare Optimierung | {ref:kap:qp-nlp} |
| **MINLP** | Mixed-Integer Nonlinear Program(ming) | Ganzzahlig-nichtlineare Optimierung | {ref:kap:oekosystem} |
| **CP** | Constraint Programming | Bedingungsprogrammierung | {ref:kap:cpsat} |
| **SAT** | Boolean **Sat**isfiability Problem | Erfüllbarkeitsproblem der Aussagenlogik | {ref:kap:cpsat} |
| **CP-SAT** | Constraint Programming über SAT-Techniken | Solver-Name von Google OR-Tools | {ref:kap:cpsat} |
| **CDCL** | Conflict-Driven Clause Learning | konfliktgetriebenes Klausellernen | {ref:kap:cpsat} |
| **DP** | Dynamic Programming | Dynamische Programmierung | {ref:kap:dynamische-programmierung} |
| **B&B** | Branch and Bound | Verzweigen und Beschränken | {ref:kap:milp} |
| **B&C** | Branch and Cut | Verzweigen mit Schnittebenen | {ref:kap:milp} |
| **KKT** | KarushKuhnTucker (Bedingungen) | Optimalitätsbedingungen bei Restriktionen | {ref:kap:qp-nlp} |
| **SLSQP** | Sequential Least Squares Programming | sequentielle quadratische Optimierung | {ref:kap:qp-nlp} |
| **RMT** | Random Matrix Theory | Zufallsmatrizentheorie | {ref:kap:finanzdaten} |
| **SA** | Simulated Annealing | Simulierte Abkühlung — Metaheuristik, die Verschlechterungen mit fallender Wahrscheinlichkeit zulässt | {ref:kap:metaheuristiken} |
| **LNS** | Large Neighborhood Search | Große Nachbarschaftssuche: Teil der Lösung verwerfen, Rest exakt neu bauen | {ref:kap:metaheuristiken} |
| **CG** | Column Generation | Spaltengenerierung — Variablen erst erzeugen, wenn sie sich lohnen | {ref:kap:dekomposition} |
| **IIS** | Irreducible Infeasible Subset | Kleinste widersprüchliche Teilmenge von Bedingungen | {ref:anhang:fehlerdiagnose} |
| **MSE** | Mean Squared Error | Mittlerer quadratischer Fehler — das Standardmaß für Prognosegüte | {ref:kap:prognose} |
### 2.2 Probleme mit Eigennamen
| Kürzel | Ausgeschrieben | Deutsch | Kapitel |
| --- | --- | --- | --- |
| **TSP** | Traveling Salesperson Problem | Problem des Handlungsreisenden | {ref:kap:graphen} |
| **VRP** | Vehicle Routing Problem | Tourenplanungsproblem | {ref:kap:graphen} |
| **CVRP** | **C**apacitated VRP | VRP mit Fahrzeugkapazitäten | {ref:kap:graphen} |
| **VRPTW** | VRP with **T**ime **W**indows | VRP mit Zeitfenstern | {ref:kap:graphen} |
| **CVRPTW** | Capacitated VRP with Time Windows | mit Kapazitäten *und* Zeitfenstern | {ref:kap:graphen} |
| **MCNFP** | Minimum-Cost Network Flow Problem | Kostenminimales Flussproblem | {ref:kap:graphen} |
| **MTZ** | MillerTuckerZemlin (Formulierung) | Kurzzyklus-Eliminierung | {ref:kap:graphen} |
| **JSSP** | Job-Shop Scheduling Problem | Werkstattfertigungs-Reihenfolgeproblem | {ref:kap:cpsat} |
### 2.3 Finanz- und Risikokennzahlen
| Kürzel | Ausgeschrieben | Deutsch / Bedeutung | Kapitel |
| --- | --- | --- | --- |
| **MPT** | Modern Portfolio Theory | Moderne Portfoliotheorie (Markowitz) | {ref:kap:markowitz} |
| **GMV** | Global Minimum Variance (Portfolio) | Portfolio kleinstmöglicher Varianz | {ref:kap:markowitz} |
| **MVO** | Mean-Variance Optimization | Erwartungswert-Varianz-Optimierung | {ref:kap:markowitz} |
| **SR** | Sharpe Ratio | Überrendite je Einheit Volatilität | {ref:kap:markowitz} |
| **VaR** | Value at Risk | Verlustschwelle bei gegebener Wahrscheinlichkeit | {ref:kap:cvar} |
| **CVaR** | Conditional Value at Risk | mittlerer Verlust jenseits des VaR | {ref:kap:cvar} |
| **ES** | Expected Shortfall | anderes Wort für CVaR | {ref:kap:cvar} |
| **CAGR** | Compound Annual Growth Rate | durchschnittliche jährliche Wachstumsrate | {ref:kap:handelsmaschine} |
| **MDD** | Maximum Drawdown | größter Rückgang vom Höchststand | {ref:kap:handelsmaschine} |
| **LW** | LedoitWolf (Shrinkage) | Schrumpfungsverfahren für Kovarianzmatrizen | {ref:kap:finanzdaten} |
| **p. a.** | *per annum* | pro Jahr | {ref:teil:anwendungen} |
| **bp** | Basispunkt | 0,01 Prozentpunkt; 15 bp = 0,15 % | {ref:kap:cvar}, {ref:kap:handelsmaschine} |
### 2.4 Software, Solver und Schnittstellen
| Kürzel | Ausgeschrieben | Was es ist | Lizenz |
| --- | --- | --- | --- |
| **HiGHS** | **Hi**gh Performance **G**eneral-purpose **S**olver | LP-, MILP- und QP-Solver in C++ | Open Source (MIT) |
| **OR-Tools** | Google Operations Research Tools | Solver-Sammlung (CP-SAT, Routing, Wrapper) | Open Source (Apache 2.0) |
| **CVXPY** | **C**on**v**e**x** **Py**thon | Modellierungssprache für konvexe Optimierung | Open Source (Apache 2.0) |
| **SciPy** | **Sci**entific **Py**thon | Wissenschaftliches Python-Paket; `scipy.optimize` liefert LP- (über HiGHS), Zuordnungs- (`linear_sum_assignment`) und NLP-Solver (`minimize`) | Open Source (BSD) |
| **GLOP** | **G**oogle **L**inear **Op**timizer | LP-Solver in OR-Tools | Open Source |
| **SCIP** | Solving Constraint Integer Programs | MILP/MINLP-Solver | akademisch frei |
| **CBC** | **C**oin-or **B**ranch and **C**ut | MILP-Solver des COIN-OR-Projekts | Open Source |
| **GLPK** | GNU Linear Programming Kit | LP/MILP-Solver | Open Source (GPL) |
| **OSQP** | **O**perator **S**plitting **Q**uadratic **P**rogram | QP-Solver | Open Source |
| **ECOS** | Embedded Conic Solver | konischer Solver | Open Source |
| **SCS** | Splitting Conic Solver | konischer Solver | Open Source |
| **IPOPT** | **I**nterior **Po**int **Opt**imizer | NLP-Solver | Open Source (EPL) |
| **Gurobi**, **CPLEX**, **Xpress** | — | kommerzielle Hochleistungs-Solver | kostenpflichtig |
| **API** | Application Programming Interface | Programmierschnittstelle | — |
| **DSL** | Domain-Specific Language | fachspezifische Sprache (hier: Modellierungssprache) | — |
| **CSR** | Compressed Sparse Row | Speicherformat für dünnbesetzte Matrizen | — |
| **GIL** | Global Interpreter Lock | Pythons Sperre, die echte Parallelität von Python-Code verhindert — Solverbibliotheken in C++ geben sie frei ({ref:kap:testing}) | — |
| **JSON** | JavaScript Object Notation | Textformat für strukturierte Daten; Austauschformat des Optimierungsdienstes | — |
| **RHS** | Right-Hand Side | rechte Seite einer (Un-)Gleichung, der Vektor $\mathbf{b}$ | — |
| **FFI** | Foreign Function Interface | Aufruf von C/C++-Code aus Python | — |
### 2.5 Begriffe aus dem Solver-Alltag
| Kürzel / Begriff | Bedeutung | Was Sie tun sollten |
| --- | --- | --- |
| **OPTIMAL** | Beweisbar beste Lösung gefunden | Nichts — alles gut |
| **FEASIBLE** | Zulässige Lösung gefunden, Optimalität nicht bewiesen | Zeitlimit erhöhen oder Gap akzeptieren |
| **INFEASIBLE** | Keine Lösung erfüllt alle Bedingungen | {ref:anhang:fehlerdiagnose}: Diagnose durchführen |
| **UNBOUNDED** | Zielfunktion unbeschränkt verbesserbar | Fehlende Nebenbedingung — Modell prüfen |
| **MIP-Gap** | Relativer Abstand beste Lösung ↔ beste Schranke | 12 % sind in der Praxis meist genug |
| **Incumbent** | Beste bisher gefundene zulässige Lösung | Referenz für das Pruning |
| **Relaxation** | Modell mit weggelassener Ganzzahligkeit | liefert die Schranke |
| **Warm Start** | Solver startet von bekannter Lösung | beschleunigt wiederholte Läufe |
| **Slack** | Schlupf, ungenutzte Kapazität | Slack = 0 → Engpass |
| **Lookahead-Bias** | Nutzung von Daten aus der Zukunft | Backtest ist wertlos — {ref:kap:praxisfallen} |
---
## 3. Konventionen in diesem Buch
**Vektoren** sind grundsätzlich **Spaltenvektoren** und werden fett gesetzt: $\mathbf{x}$.
Ein transponierter Vektor $\mathbf{x}^\top$ ist eine Zeile. Skalare bleiben mager: $n$, $Z$.
**Matrizen** sind fett und groß: $\mathbf{A}$, $\boldsymbol{\Sigma}$.
**Der Stern** markiert immer Optimalwerte: $\mathbf{x}^*$, $Z^*$, $\lambda^*$.
**Minimierung ist die Standardform.** Jede Maximierung lässt sich durch Vorzeichenwechsel in
eine Minimierung überführen: $\max f(\mathbf{x}) = -\min\,(-f(\mathbf{x}))$. Die meisten
Solver — auch `scipy.optimize.linprog` — minimieren intern. Wer das vergisst, erhält
systematisch das Gegenteil des Gewünschten; siehe {ref:sec:lp-die-vorzeichenfalle-bei-schattenpreisen}.
**Dezimaltrennzeichen.** Im Fließtext und in Formeln steht das deutsche Komma
($3{,}14$), in Code und Programmausgaben der englische Punkt (`3.14`) — Python kennt
nichts anderes.
**Code-Sprache.** Kommentare und Ausgaben der Beispielprogramme sind deutsch,
Bezeichner überwiegend englisch, weil das der Konvention der verwendeten Bibliotheken
entspricht und die Programme so anschlussfähig an die Originaldokumentation bleiben.
**Anglizismen.** Fachbegriffe werden bei der ersten Verwendung deutsch eingeführt und der
englische Originalbegriff *kursiv* in Klammern ergänzt, weil Sie ihn für die Recherche
brauchen: „zulässiger Bereich (*feasible region*)“.
---
*Weiter mit:* [{ref:kap:einfuehrung}](10_Einfuehrung_OR.md).

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,725 @@
# Kapitel: Vom Management-Wunsch zum Modell {#kap:modellierung}
> **📌 Kapitel auf einen Blick**
>
> **Worum geht es?** Um den Schritt, der vor jedem Solver kommt und über den kaum ein
> Lehrbuch spricht: aus einem Satz in einer Besprechung ein Modell zu machen. Genau hier
> scheitern Projekte — nicht an der Mathematik.
>
> **Voraussetzungen:** {ref:kap:einfuehrung} (die vier Bausteine eines Modells). Kein
> Solverwissen nötig; das ganze Kapitel kommt mit einer einzigen `linprog`-Zeile aus.
>
> **Danach können Sie:** eine Anforderung daraufhin abklopfen, was sie *nicht* sagt; die
> Zielgröße so wählen, dass sie keinen Fehlanreiz setzt; harte von weichen Regeln
> unterscheiden — und begründen, warum diese Unterscheidung ein Jahr später über die
> Betriebsfähigkeit entscheidet.
>
> **Zeitbedarf:** ca. 3 Stunden.
>
> **Programme:**\
> `Vom_Wunsch_zum_Modell.py`
---
## In 5 Minuten gelöst {#sec:modellierung-schnellstart}
> **🚀 In 5 Minuten gelöst: „Maximieren Sie den Umsatz"**
>
> Eine Lohnfertigung hat fünf Anfragen und 60 freie Maschinenstunden. Die Anfragen
> verlangen zusammen 160 Stunden — es muss ausgewählt werden.
>
> | Auftrag | Stunden | Umsatz | Material | Deckungsbeitrag |
> | --- | ---: | ---: | ---: | ---: |
> | **A** | 60 | 60 000 € | 55 000 € | 5 000 € |
> | **B** | 30 | 22 000 € | 12 000 € | 10 000 € |
> | **C** | 30 | 20 000 € | 11 000 € | 9 000 € |
> | **D** | 25 | 18 000 € | 9 000 € | 9 000 € |
> | **E** | 15 | 12 000 € | 7 000 € | 5 000 € |
>
> ```python
> import numpy as np
> from scipy.optimize import linprog
>
> # Auftrag: A B C D E
> stunden = np.array([ 60, 30, 30, 25, 15], dtype=float)
> umsatz = np.array([ 60_000, 22_000, 20_000, 18_000, 12_000], dtype=float)
> material = np.array([ 55_000, 12_000, 11_000, 9_000, 7_000], dtype=float)
> KAPAZITAET = 60 # Maschinenstunden; die Anfragen verlangen 160
>
> waehle = lambda ziel: np.round(linprog(
> -ziel, A_ub=[stunden], b_ub=[KAPAZITAET],
> bounds=(0, 1), integrality=1, method="highs").x).astype(int)
>
> for wunsch, ziel in [("Umsatz maximieren", umsatz),
> ("Deckungsbeitrag maximieren", umsatz - material)]:
> plan = waehle(ziel)
> print(f"{wunsch:<27} -> {''.join(b for b, ja in zip('ABCDE', plan) if ja):<5}"
> f" Umsatz {umsatz @ plan:>7,.0f} DB {(umsatz - material) @ plan:>6,.0f}")
> ```
>
> **Ausgabe:**
>
> ```
> Umsatz maximieren -> A Umsatz 60,000 DB 5,000
> Deckungsbeitrag maximieren -> BD Umsatz 40,000 DB 19,000
> ```
**Und jetzt der Punkt.** Beide Modelle sind korrekt. Beide Pläne sind zulässig. Beide sind
*optimal* — nur für verschiedene Fragen.
Wer „Umsatz maximieren" sagt, bekommt Auftrag A: 60 000 € Umsatz und 5 000 € Deckungsbeitrag.
Wer „Deckungsbeitrag maximieren" sagt, bekommt B und D: 20 000 € weniger Umsatz und
**14 000 € mehr Ergebnis**. Der Umsatzplan verkauft die gesamte Kapazität für eine Marge von
8 %.
Niemand in der Besprechung hätte behauptet, Umsatz sei wichtiger als Ertrag. Der Satz
„maximieren Sie den Umsatz" ist trotzdem gefallen, weil Umsatz die Zahl ist, die im
Monatsbericht steht.
> **🎯 Merksatz**
> Ein Modell rechnet nicht aus, was man will, sondern was man aufgeschrieben hat. Die
> Differenz zwischen beidem ist die eigentliche Arbeit dieses Kapitels.
**Warum funktioniert das?** Weil eine Zielfunktion eine **vollständige** Aussage darüber ist,
was zählt. Alles, was nicht darin steht, ist dem Modell gleichgültig — nicht „weniger
wichtig", sondern **wertlos**. Der Umsatzplan opfert den Deckungsbeitrag nicht aus Bosheit;
er sieht ihn schlicht nicht.
---
## Lernziele {#sec:modellierung-lernziele}
Nach diesem Kapitel können Sie …
1. … erklären, warum die Zielgröße die folgenreichste Entscheidung eines OR-Projekts ist —
folgenreicher als die Wahl des Solvers.
2. … die vier Fragen stellen, die aus einer Anforderung ein Modell machen.
3. … erkennen, dass ein Satz wie „Stammkunden dürfen wir nicht verlieren" mehrere Lesarten
hat, und den Unterschied in Euro beziffern.
4. … begründen, wann eine Regel hart und wann sie weich sein muss — und was eine harte Regel
im Betrieb anrichtet, wenn sie einmal nicht erfüllbar ist.
5. … einen Kennzahlen-Fehlanreiz benennen, bevor er in die Zielfunktion gerät.
---
## Ein Satz ist noch kein Modell {#sec:modellierung-vier-fragen}
Was aus einer Besprechung mitkommt, klingt meist so:
> *„Wir müssen die Maschinen besser auslasten. Und die Stammkunden dürfen wir nicht
> verlieren."*
Zwei Sätze, aus denen ein Modell werden soll. Sie enthalten: keine Entscheidungsvariable,
keine messbare Zielgröße, keine Nebenbedingung. Sie enthalten nicht einmal genug, um zu
erkennen, *was* entschieden werden soll.
Der Weg dorthin führt über vier Fragen. Sie sind unspektakulär, und genau deshalb werden
sie übersprungen.
| # | Frage | Was ohne sie passiert |
| --- | --- | --- |
| 1 | **Worüber wird entschieden?** Welche Größen kann der Betrieb tatsächlich festlegen? | Man modelliert Dinge, die gar nicht zur Disposition stehen — oder übersieht die eine Stellschraube, um die es geht. |
| 2 | **Woran wird der Erfolg gemessen?** Welche Zahl steht am Jahresende im Bericht? | Man optimiert eine Kennzahl, die niemanden interessiert — oder eine, deren Maximierung schadet. |
| 3 | **Was ist unverhandelbar, was nur unerwünscht?** | Jede unnötig harte Regel ist eine Zeitbombe: Irgendwann ist sie nicht erfüllbar, und das System antwortet gar nicht mehr. |
| 4 | **Was passiert, wenn es nicht geht?** | Der Nachtlauf bricht ab, und niemand weiß, welche Regel schuld war. |
> **📖 Zur zweiten Frage**
>
> Sie ist absichtlich anders gestellt als „Was ist Ihnen wichtig?". Auf diese Frage antwortet
> jeder Betrieb mit einer Liste, in der alles wichtig ist. Die brauchbare Frage lautet:
> **Welche Zahl steht am Jahresende im Bericht, und wer wird danach beurteilt?**
>
> Die Antwort ist unbequem und aufschlussreich — und sie erklärt, warum in der Besprechung
> „Umsatz" gesagt wird, obwohl „Deckungsbeitrag" gemeint ist.
---
## Derselbe Datensatz, fünf Modelle {#sec:modellierung-fuenf-modelle}
Das Programm dieses Kapitels arbeitet die vier Fragen an einer Auftragsannahme ab: 14
Anfragen verlangen 800 Maschinenstunden, verfügbar sind 300.
Es ist bewusst das **einfachste Modell des ganzen Buchs** — eine Binärvariable je Auftrag,
eine Nebenbedingung. Alles, was sich zwischen den fünf Varianten ändert, ist die Frage.
```python
#!/usr/bin/env python3
# Vom_Wunsch_zum_Modell.py
"""
Kapitel Modellierung: Derselbe Datensatz, fuenf zulaessige Modelle, fuenf andere Plaene.
Eine Lohnfertigung kann nicht alle Anfragen annehmen: 14 Auftraege verlangen
zusammen 800 Maschinenstunden, verfuegbar sind 300. Es muss also ausgewaehlt
werden. Die Daten stehen fest - was FEHLT, ist die Frage.
Das Programm zeigt in vier Teilen, was daran haengt:
1. Drei naheliegende Ziele aus dem Managementgespraech ("Umsatz", "Auslastung",
"Deckungsbeitrag") - und wie weit die Plaene auseinanderlaufen.
2. Der Satz "Stammkunden duerfen wir nicht verlieren" in drei Lesarten. Alle
drei sind vertretbar, alle drei kosten unterschiedlich viel.
3. Was ein Strafgewicht daraus macht - und ab wann es dasselbe tut wie eine
harte Regel.
4. Was die harte Lesart ein Quartal spaeter anrichtet.
Der Punkt des Kapitels: Nicht die Daten bestimmen das Modell, sondern die
Frage. Und die steht selten in der Anforderung.
Benoetigt: numpy, scipy
"""
from __future__ import annotations
import numpy as np
from scipy.optimize import linprog
# (Nummer, Kunde, Stammkunde?, Maschinenstunden, Umsatz, Materialkosten)
AUFTRAEGE = [
("A-101", "Weber GmbH", True, 40, 28_000, 19_000),
("A-102", "Weber GmbH", True, 25, 15_000, 9_500),
("A-103", "Kranz AG", True, 60, 33_000, 24_000),
("A-104", "Kranz AG", True, 35, 19_000, 13_500),
("A-105", "Obermeier KG", True, 20, 12_000, 7_000),
("A-106", "Siedler & Co", False, 120, 84_000, 71_000),
("A-107", "Siedler & Co", False, 110, 74_000, 63_000),
("A-108", "Novatek", False, 95, 61_000, 52_000),
("A-109", "Novatek", False, 30, 31_000, 17_000),
("A-110", "Hellwig", False, 15, 17_000, 8_000),
("A-111", "Hellwig", False, 55, 41_000, 30_000),
("A-112", "Pfeiffer Werke", False, 80, 46_000, 39_000),
("A-113", "Obermeier KG", True, 45, 26_000, 17_000),
("A-114", "Pfeiffer Werke", False, 70, 52_000, 36_000),
]
KAPAZITAET = 300 # Maschinenstunden im Planungszeitraum
STUNDEN = np.array([a[3] for a in AUFTRAEGE], dtype=float)
UMSATZ = np.array([a[4] for a in AUFTRAEGE], dtype=float)
DECKUNGSBEITRAG = np.array([a[4] - a[5] for a in AUFTRAEGE], dtype=float)
IST_STAMM = np.array([a[2] for a in AUFTRAEGE])
STAMMKUNDEN = sorted({a[1] for a in AUFTRAEGE if a[2]})
def waehle_aus(ziel: np.ndarray, zusatz_ub=None, zusatz_b_ub=None,
zusatz_eq=None, zusatz_b_eq=None, auftraege=AUFTRAEGE,
stunden=STUNDEN, kapazitaet=KAPAZITAET):
"""Ein Auftrag wird angenommen oder nicht - mehr Modell ist das nicht.
Der ganze Unterschied zwischen den fuenf Varianten unten steckt in 'ziel'
und in den Zusatzbedingungen. Das Modell selbst bleibt dasselbe.
"""
ergebnis = linprog(
-ziel,
A_ub=[stunden] + (zusatz_ub or []),
b_ub=[kapazitaet] + (zusatz_b_ub or []),
A_eq=zusatz_eq, b_eq=zusatz_b_eq,
bounds=(0, 1), integrality=1, method="highs")
if not ergebnis.success:
return None
return np.round(ergebnis.x).astype(int)
def kennzahlen(plan, auftraege=AUFTRAEGE, stunden=STUNDEN, umsatz=UMSATZ,
db=DECKUNGSBEITRAG, ist_stamm=IST_STAMM, kapazitaet=KAPAZITAET):
"""Immer dieselben fuenf Zahlen - nur so sind die Varianten vergleichbar."""
abgelehnte_stammauftraege = int(((1 - plan) & ist_stamm.astype(int)).sum())
leer_ausgegangen = sum(
1 for kunde in sorted({a[1] for a in auftraege if a[2]})
if not any(plan[i] for i, a in enumerate(auftraege) if a[1] == kunde))
return {"umsatz": float(umsatz @ plan), "db": float(db @ plan),
"auslastung": float(stunden @ plan) / kapazitaet * 100,
"stamm_abgelehnt": abgelehnte_stammauftraege,
"kunden_leer": leer_ausgegangen, "angenommen": int(plan.sum())}
def zeile(name: str, plan) -> None:
if plan is None:
print(f" {name:<34} {'UNZULAESSIG - kein Plan moeglich':>44}")
return
k = kennzahlen(plan)
print(f" {name:<34} {k['umsatz']:>10,.0f} {k['db']:>9,.0f} "
f"{k['auslastung']:>6.0f}% {k['stamm_abgelehnt']:>7} "
f"{k['kunden_leer']:>6} {k['angenommen']:>7}")
def kopfzeile() -> None:
print(f" {'':<34} {'Umsatz':>10} {'DB':>9} {'Aus-':>7} {'Stamm-':>7} "
f"{'Kunden':>6} {'ange-':>7}")
print(f" {'':<34} {'':>10} {'':>9} {'lastung':>7} {'auftr.':>7} "
f"{'ohne':>6} {'nommen':>7}")
print(" " + "-" * 78)
if __name__ == "__main__":
print("=" * 84)
print(" VOM MANAGEMENT-WUNSCH ZUM MODELL")
print("=" * 84)
print(f"{len(AUFTRAEGE)} Anfragen verlangen {STUNDEN.sum():.0f} Maschinenstunden, "
f"verfuegbar sind {KAPAZITAET}.")
print(f"Es muss also ausgewaehlt werden. {len(STAMMKUNDEN)} der Kunden sind "
f"Stammkunden:")
print(f" {', '.join(STAMMKUNDEN)}\n")
# --- 1. Drei Saetze aus dem Gespraech, drei Ziele ---------------------
print("-" * 84)
print("1. Was im Gespraech gesagt wurde - drei naheliegende Ziele\n")
kopfzeile()
plaene = {}
for name, ziel in [("\"Umsatz maximieren\"", UMSATZ),
("\"Maschinen besser auslasten\"", STUNDEN),
("\"Deckungsbeitrag maximieren\"", DECKUNGSBEITRAG)]:
plaene[name] = waehle_aus(ziel)
zeile(name, plaene[name])
umsatzplan = kennzahlen(plaene["\"Umsatz maximieren\""])
auslastungsplan = kennzahlen(plaene["\"Maschinen besser auslasten\""])
dbplan = kennzahlen(plaene["\"Deckungsbeitrag maximieren\""])
mehr_umsatz = umsatzplan["umsatz"] - dbplan["umsatz"]
weniger_db = dbplan["db"] - umsatzplan["db"]
print(f"\n Der Umsatzplan bringt {mehr_umsatz:,.0f} EUR mehr Umsatz und "
f"{weniger_db:,.0f} EUR WENIGER")
print(f" Deckungsbeitrag. Fuer {mehr_umsatz:,.0f} EUR Umsatz werden also "
f"{weniger_db:,.0f} EUR Ergebnis")
print(f" aufgegeben - und {umsatzplan['stamm_abgelehnt']} von "
f"{int(IST_STAMM.sum())} Stammkundenauftraegen dazu.")
print()
print(" \"Auslastung\" ist der haeufigste Wunsch und der schaedlichste. Der")
print(f" darauf optimierte Plan bringt {dbplan['db'] - auslastungsplan['db']:,.0f} EUR weniger "
f"Deckungsbeitrag und")
print(f" {dbplan['umsatz'] - auslastungsplan['umsatz']:,.0f} EUR weniger Umsatz als der "
f"DB-Plan - und erreicht dabei")
print(f" nicht einmal mehr Auslastung: alle drei liegen bei "
f"{dbplan['auslastung']:.0f} %.")
print(" Eine volle Maschine ist eben kein Ziel, sondern ein Nebenprodukt.")
# --- 2. Ein Satz, drei Lesarten --------------------------------------
print("\n" + "-" * 84)
print("2. \"Stammkunden duerfen wir nicht verlieren\" - aber was heisst das?\n")
kopfzeile()
zeile("ohne die Regel (DB maximieren)", waehle_aus(DECKUNGSBEITRAG))
# Lesart A: jeder Stammkundenauftrag wird angenommen
alle_stamm = [[1.0 if IST_STAMM[i] else 0.0 for i in range(len(AUFTRAEGE))]]
lesart_a = waehle_aus(DECKUNGSBEITRAG, zusatz_eq=alle_stamm,
zusatz_b_eq=[float(IST_STAMM.sum())])
zeile("A: jeden Stammauftrag annehmen", lesart_a)
# Lesart B: kein Stammkunde geht leer aus (mindestens ein Auftrag je Kunde)
je_kunde_ub, je_kunde_b = [], []
for kunde in STAMMKUNDEN:
je_kunde_ub.append([-1.0 if a[1] == kunde else 0.0 for a in AUFTRAEGE])
je_kunde_b.append(-1.0)
lesart_b = waehle_aus(DECKUNGSBEITRAG, zusatz_ub=je_kunde_ub,
zusatz_b_ub=je_kunde_b)
zeile("B: kein Stammkunde geht leer aus", lesart_b)
a, b = kennzahlen(lesart_a), kennzahlen(lesart_b)
ohne = kennzahlen(waehle_aus(DECKUNGSBEITRAG))
print("\n Beide Lesarten erreichen dasselbe: kein Stammkunde geht leer aus.")
print(f" Lesart A kostet dafuer {ohne['db'] - a['db']:,.0f} EUR Deckungsbeitrag, "
f"Lesart B nur {ohne['db'] - b['db']:,.0f} EUR -")
print(f" ein Unterschied von {b['db'] - a['db']:,.0f} EUR fuer denselben Satz aus derselben")
print(" Besprechung. Wer nicht nachfragt, entscheidet das selbst, ohne es")
print(" zu merken.")
# --- 3. Die weiche Variante ------------------------------------------
print("\n" + "-" * 84)
print("3. Dieselbe Regel als Strafkosten je abgelehntem Stammauftrag\n")
kopfzeile()
for strafe in (3_000, 6_000, 10_000):
ziel = DECKUNGSBEITRAG + np.where(IST_STAMM, float(strafe), 0.0)
zeile(f"C: Strafe {strafe:>6,} EUR je Auftrag", waehle_aus(ziel))
print("\n Bei 3.000 EUR aendert sich nichts: Die Strafe liegt unter dem, was")
print(" die Auftraege an Deckungsbeitrag verdraengen wuerden. Bei 10.000 EUR")
print(" kommt genau Lesart A heraus. Eine harte Regel ist also nichts")
print(" anderes als eine weiche mit hinreichend grosser Strafe - was ein")
print(" Strafgewicht sonst noch bedeutet, steht im Kapitel CP-SAT.")
# --- 4. Was die harte Lesart ein Quartal spaeter anrichtet -----------
print("\n" + "-" * 84)
print("4. Ein Quartal spaeter: Weber fragt einen Grossauftrag an\n")
erweitert = AUFTRAEGE + [("A-115", "Weber GmbH", True, 320, 210_000, 150_000)]
stunden_neu = np.array([a[3] for a in erweitert], dtype=float)
db_neu = np.array([a[4] - a[5] for a in erweitert], dtype=float)
stamm_neu = np.array([a[2] for a in erweitert])
print(f" A-115: Weber GmbH, {erweitert[-1][3]} Maschinenstunden - allein mehr,")
print(f" als der ganze Zeitraum hergibt ({KAPAZITAET} h).\n")
alle_stamm_neu = [[1.0 if stamm_neu[i] else 0.0 for i in range(len(erweitert))]]
hart = waehle_aus(db_neu, zusatz_eq=alle_stamm_neu,
zusatz_b_eq=[float(stamm_neu.sum())],
auftraege=erweitert, stunden=stunden_neu)
weich = waehle_aus(db_neu + np.where(stamm_neu, 10_000.0, 0.0),
auftraege=erweitert, stunden=stunden_neu)
print(f" Lesart A (hart): "
f"{'UNZULAESSIG - der Nachtlauf bricht ab' if hart is None else 'loesbar'}")
if weich is not None:
k = kennzahlen(weich, erweitert, stunden_neu,
np.array([a[4] for a in erweitert], dtype=float),
db_neu, stamm_neu)
print(f" Lesart C (weich): loesbar - {k['db']:,.0f} EUR Deckungsbeitrag, "
f"{k['stamm_abgelehnt']} Stammauftrag abgelehnt")
print("\n" + "=" * 84)
print(" WORAUF ES ANKOMMT")
print("=" * 84)
print("Die Daten waren in allen vier Teilen dieselben. Was sich geaendert hat,")
print("war nur die Frage - und mit ihr der Plan, der Deckungsbeitrag und die")
print("Kundenbeziehung.")
print()
print("Drei Dinge, die deshalb vor dem ersten Modell geklaert gehoeren:")
print()
print(" 1. WORAN wird der Erfolg gemessen? Nicht 'was ist Ihnen wichtig',")
print(" sondern: Welche Zahl steht am Jahresende im Bericht? Umsatz,")
print(" Auslastung und Deckungsbeitrag fuehren hier zu drei")
print(" verschiedenen Plaenen.")
print(" 2. WAS GENAU heisst der Satz? 'Stammkunden nicht verlieren' hat")
print(f" mindestens drei Lesarten mit {b['db'] - a['db']:,.0f} EUR Unterschied. Die Frage")
print(" 'meinen Sie jeden Auftrag oder jeden Kunden?' dauert zehn")
print(" Sekunden.")
print(" 3. WAS PASSIERT, WENN ES NICHT GEHT? Eine harte Regel, die niemand")
print(" hinterfragt hat, macht das System eines Tages unloesbar - und")
print(" zwar genau dann, wenn ein besonders grosser Auftrag hereinkommt.")
print(" Weiche Regeln antworten auch dann noch.")
print("=" * 84)
```
**Erwartete Ausgabe:**
```
====================================================================================
VOM MANAGEMENT-WUNSCH ZUM MODELL
====================================================================================
14 Anfragen verlangen 800 Maschinenstunden, verfuegbar sind 300.
Es muss also ausgewaehlt werden. 3 der Kunden sind Stammkunden:
Kranz AG, Obermeier KG, Weber GmbH
------------------------------------------------------------------------------------
1. Was im Gespraech gesagt wurde - drei naheliegende Ziele
Umsatz DB Aus- Stamm- Kunden ange-
lastung auftr. ohne nommen
------------------------------------------------------------------------------
"Umsatz maximieren" 227,000 66,000 100% 5 2 6
"Maschinen besser auslasten" 205,000 54,000 100% 4 1 5
"Deckungsbeitrag maximieren" 222,000 78,500 100% 2 1 8
Der Umsatzplan bringt 5,000 EUR mehr Umsatz und 12,500 EUR WENIGER
Deckungsbeitrag. Fuer 5,000 EUR Umsatz werden also 12,500 EUR Ergebnis
aufgegeben - und 5 von 6 Stammkundenauftraegen dazu.
"Auslastung" ist der haeufigste Wunsch und der schaedlichste. Der
darauf optimierte Plan bringt 24,500 EUR weniger Deckungsbeitrag und
17,000 EUR weniger Umsatz als der DB-Plan - und erreicht dabei
nicht einmal mehr Auslastung: alle drei liegen bei 100 %.
Eine volle Maschine ist eben kein Ziel, sondern ein Nebenprodukt.
------------------------------------------------------------------------------------
2. "Stammkunden duerfen wir nicht verlieren" - aber was heisst das?
Umsatz DB Aus- Stamm- Kunden ange-
lastung auftr. ohne nommen
------------------------------------------------------------------------------
ohne die Regel (DB maximieren) 222,000 78,500 100% 2 1 8
A: jeden Stammauftrag annehmen 181,000 66,000 90% 0 0 8
B: kein Stammkunde geht leer aus 213,000 75,000 98% 2 0 8
Beide Lesarten erreichen dasselbe: kein Stammkunde geht leer aus.
Lesart A kostet dafuer 12,500 EUR Deckungsbeitrag, Lesart B nur 3,500 EUR -
ein Unterschied von 9,000 EUR fuer denselben Satz aus derselben
Besprechung. Wer nicht nachfragt, entscheidet das selbst, ohne es
zu merken.
------------------------------------------------------------------------------------
3. Dieselbe Regel als Strafkosten je abgelehntem Stammauftrag
Umsatz DB Aus- Stamm- Kunden ange-
lastung auftr. ohne nommen
------------------------------------------------------------------------------
C: Strafe 3,000 EUR je Auftrag 222,000 78,500 100% 2 1 8
C: Strafe 6,000 EUR je Auftrag 205,000 73,000 100% 1 0 8
C: Strafe 10,000 EUR je Auftrag 181,000 66,000 90% 0 0 8
Bei 3.000 EUR aendert sich nichts: Die Strafe liegt unter dem, was
die Auftraege an Deckungsbeitrag verdraengen wuerden. Bei 10.000 EUR
kommt genau Lesart A heraus. Eine harte Regel ist also nichts
anderes als eine weiche mit hinreichend grosser Strafe - was ein
Strafgewicht sonst noch bedeutet, steht im Kapitel CP-SAT.
------------------------------------------------------------------------------------
4. Ein Quartal spaeter: Weber fragt einen Grossauftrag an
A-115: Weber GmbH, 320 Maschinenstunden - allein mehr,
als der ganze Zeitraum hergibt (300 h).
Lesart A (hart): UNZULAESSIG - der Nachtlauf bricht ab
Lesart C (weich): loesbar - 66,000 EUR Deckungsbeitrag, 1 Stammauftrag abgelehnt
====================================================================================
WORAUF ES ANKOMMT
====================================================================================
Die Daten waren in allen vier Teilen dieselben. Was sich geaendert hat,
war nur die Frage - und mit ihr der Plan, der Deckungsbeitrag und die
Kundenbeziehung.
Drei Dinge, die deshalb vor dem ersten Modell geklaert gehoeren:
1. WORAN wird der Erfolg gemessen? Nicht 'was ist Ihnen wichtig',
sondern: Welche Zahl steht am Jahresende im Bericht? Umsatz,
Auslastung und Deckungsbeitrag fuehren hier zu drei
verschiedenen Plaenen.
2. WAS GENAU heisst der Satz? 'Stammkunden nicht verlieren' hat
mindestens drei Lesarten mit 9,000 EUR Unterschied. Die Frage
'meinen Sie jeden Auftrag oder jeden Kunden?' dauert zehn
Sekunden.
3. WAS PASSIERT, WENN ES NICHT GEHT? Eine harte Regel, die niemand
hinterfragt hat, macht das System eines Tages unloesbar - und
zwar genau dann, wenn ein besonders grosser Auftrag hereinkommt.
Weiche Regeln antworten auch dann noch.
====================================================================================
```
### Teil 1: Drei Ziele, drei Pläne
| Zielgröße | Umsatz | Deckungsbeitrag | Auslastung | abgelehnte Stammaufträge |
| --- | ---: | ---: | ---: | ---: |
| „Umsatz maximieren" | **227 000 €** | 66 000 € | 100 % | 5 von 6 |
| „Maschinen besser auslasten" | 205 000 € | 54 000 € | 100 % | 4 von 6 |
| „Deckungsbeitrag maximieren" | 222 000 € | **78 500 €** | 100 % | 2 von 6 |
Zwei Beobachtungen, die im Gespräch niemand vorhergesagt hätte:
**Der Umsatzplan kostet 12 500 € Ergebnis, um 5 000 € Umsatz zu gewinnen** — und nimmt
dabei fünf von sechs Stammkundenaufträgen aus dem Plan. Das Wechselkursverhältnis ist 1 : 2,5
gegen den Betrieb.
**Der Auslastungsplan ist in jeder Hinsicht der schlechteste — auch bei der Auslastung.**
Alle drei Pläne belegen die Maschinen zu 100 %; der eigens darauf optimierte Plan erreicht
nur nicht mehr, sondern verliert dabei 24 500 € Deckungsbeitrag. Der Grund ist einfach: Bei
knapper Kapazität ist eine volle Maschine keine Leistung, sondern die Voreinstellung. Zu
optimieren gibt es nur, **womit** sie voll wird.
> **⚠️ Auslastung ist ein Ergebnis, kein Ziel**
>
> „Wir müssen die Maschinen besser auslasten" ist der häufigste Satz in
> Produktionsbesprechungen und fast immer die falsche Zielgröße. Auslastung misst, wie viel
> Zeit vergeht — nicht, was dabei herauskommt. Wer sie maximiert, belohnt lange, schlecht
> bezahlte Aufträge.
>
> Die Ausnahme, die die Regel erklärt: Wenn Maschinen tatsächlich leer stehen, *weil* zu
> wenig hereinkommt, ist Auslastung ein sinnvolles **Symptom** — aber dann liegt das Problem
> im Vertrieb und nicht in der Planung.
### Teil 2: Ein Satz, drei Lesarten
„Stammkunden dürfen wir nicht verlieren." Jeder im Raum nickt. Niemand merkt, dass drei
verschiedene Modelle gemeint sein können:
| Lesart | Deckungsbeitrag | kostet gegenüber „ohne Regel" |
| --- | ---: | ---: |
| ohne die Regel | 78 500 € | — |
| **A:** jeden Stammkundenauftrag annehmen | 66 000 € | 12 500 € |
| **B:** kein Stammkunde geht leer aus | 75 000 € | 3 500 € |
**Beide Lesarten erreichen dasselbe Ziel** — kein Stammkunde geht leer aus. Lesart A kostet
dafür das Dreieinhalbfache. Der Unterschied von 9 000 € entsteht durch eine Frage, die zehn
Sekunden dauert:
> *„Meinen Sie jeden Auftrag oder jeden Kunden?"*
Wer sie nicht stellt, entscheidet sie trotzdem — nur unbewusst, beim Tippen.
### Teil 3: Hart oder weich
Dieselbe Regel als Strafkosten je abgelehntem Stammkundenauftrag:
| Strafe je Auftrag | Deckungsbeitrag | abgelehnte Stammaufträge |
| ---: | ---: | ---: |
| 3 000 € | 78 500 € | 2 |
| 6 000 € | 73 000 € | 1 |
| 10 000 € | 66 000 € | 0 |
Bei 3 000 € ändert sich **nichts**: Die Strafe liegt unter dem Deckungsbeitrag, den die
Stammkundenaufträge verdrängen würden. Bei 10 000 € kommt genau Lesart A heraus.
Daraus folgt eine Einsicht, die das Modellieren erleichtert: **Eine harte Regel ist eine
weiche mit hinreichend großer Strafe.** Man kann also immer weich anfangen und die Strafe
hochdrehen, bis die Regel greift — und sieht dabei, was sie kostet. Was ein Strafgewicht
darüber hinaus bedeutet und warum zwei Gewichte einen *Wechselkurs* festlegen und keine
Wichtigkeiten, steht in {ref:sec:cpsat-denkfehler}.
### Teil 4: Ein Quartal später
Die harte Lesart A wurde eingebaut, alles lief drei Monate lang. Dann fragt Weber einen
Auftrag über 320 Maschinenstunden an — mehr, als der ganze Zeitraum hergibt.
```
Lesart A (hart): UNZULAESSIG - der Nachtlauf bricht ab
Lesart C (weich): loesbar - 66,000 EUR Deckungsbeitrag, 1 Stammauftrag abgelehnt
```
Das ist kein konstruierter Sonderfall, sondern der Normalfall: **Harte Regeln scheitern
nicht bei ihrer Einführung, sondern Monate später** — und zwar bevorzugt dann, wenn etwas
Ungewöhnliches passiert, also genau dann, wenn man die Planung am dringendsten braucht.
Die weiche Variante antwortet auch dann noch. Sie lehnt den Großauftrag ab, weist das im
Bericht aus, und ein Mensch entscheidet. Das ist der ganze Unterschied — und er wird beim
Schreiben der Anforderung festgelegt, nicht beim Programmieren.
> **🎯 Merksatz**
> Hart ist nur, was rechtlich oder physikalisch unmöglich ist. Alles andere wird weich mit
> hoher Strafe. Dann liefert der Solver den am wenigsten schlechten Plan statt einer
> Fehlermeldung — und die Fehlermeldung ist das, was niemand gebrauchen kann.
---
## Der Gesprächsleitfaden {#sec:modellierung-leitfaden}
Was aus den vier Teilen als Handwerkszeug bleibt — Fragen, die man in der ersten Besprechung
stellt, bevor eine Zeile Code entsteht:
> **📎 Zwölf Fragen für das erste Gespräch**
>
> **Zur Entscheidung**
> 1. Was genau wird entschieden — und von wem heute?
> 2. Was steht dabei *nicht* zur Disposition?
> 3. Wie oft wird entschieden, und wie viel Zeit ist dafür?
>
> **Zum Ziel**
> 4. Welche Zahl steht am Jahresende im Bericht?
> 5. Wenn genau diese Zahl maximiert würde — welcher Plan käme heraus, und wäre er
> akzeptabel?
> 6. Gibt es eine zweite Zahl, die auch stimmen muss? Was ist Ihnen ein Prozent der ersten
> wert, um ein Prozent der zweiten zu gewinnen?
>
> **Zu den Regeln**
> 7. Welche dieser Regeln ist gesetzlich oder physikalisch zwingend?
> 8. Welche würden Sie in einer Ausnahmesituation brechen — und was wäre eine
> Ausnahmesituation?
> 9. Bei „darf nicht": Betrifft das jeden einzelnen Fall oder das Gesamtbild?
>
> **Zum Betrieb**
> 10. Was soll passieren, wenn es keine zulässige Lösung gibt?
> 11. Wer sieht das Ergebnis, und was muss er daran erkennen können?
> 12. Woran würden Sie in einem halben Jahr merken, dass das System nicht mehr das Richtige
> tut?
>
> Frage 5 ist die wichtigste. Sie verwandelt eine abstrakte Zielangabe in einen konkreten
> Plan, den man ablehnen kann — und Ablehnung ist die einzige Form von Anforderungsklärung,
> die zuverlässig funktioniert.
---
## Übungsaufgaben {#sec:modellierung-uebungsaufgaben}
> Lösungen: {ref:sec:loesungen-modellierung}.
**Aufgabe ⭐ — Die fehlende Lesart.**
Der Text nennt zwei Lesarten von „Stammkunden dürfen wir nicht verlieren". Formulieren Sie
eine dritte, die weder A noch B ist, und sagen Sie, welchen Betrieb sie beschreiben würde.
**Aufgabe ⭐ — Frage 5 anwenden.**
Ein Krankenhaus möchte „die Wartezeiten in der Notaufnahme senken". Wenden Sie Frage 5 an:
Welcher Plan käme heraus, wenn genau die durchschnittliche Wartezeit minimiert würde — und
was wäre daran nicht akzeptabel?
**Aufgabe ⭐⭐ — Der Kompromiss dazwischen.**
Zwischen Lesart A (12 500 €) und Lesart B (3 500 €) liegt Spielraum. Bauen Sie eine Variante,
in der jeder Stammkunde **mindestens die Hälfte** seiner Auftragsstunden zugeteilt bekommt.
Was kostet sie?
**Aufgabe ⭐⭐ — Zwei Ziele gleichzeitig.**
Maximieren Sie $\text{DB} + \lambda \cdot \text{Umsatz}$ für $\lambda \in \{0; 0{,}1; 0{,}3; 1\}$
und tragen Sie die Ergebnisse als Tabelle auf. Ab welchem $\lambda$ kippt der Plan von der
DB- in die Umsatzlösung? Was sagt dieser Wert dem Betrieb?
**Aufgabe ⭐⭐⭐ — Die Anforderung schreiben.**
Formulieren Sie für den Fall dieses Kapitels eine vollständige, prüfbare Anforderung: Ziel,
harte Regeln, weiche Regeln mit Strafgewichten, Verhalten bei Unlösbarkeit, auszuweisende
Kennzahlen. Zwei Seiten genügen — sie sind mehr wert als zwei Wochen Programmierung.
---
## Finde den Denkfehler {#sec:modellierung-denkfehler}
> **🐛 „Die Engpassmaschine soll zu mindestens 92 % ausgelastet sein"**
>
> Der Werkleiter formuliert eine klare, messbare, prüfbare Anforderung:
>
> > *„Die Engpassmaschine soll in jedem Planungszeitraum zu mindestens 92 % ausgelastet sein.
> > Das ist unsere teuerste Anlage, Leerlauf können wir uns nicht leisten."*
>
> Der Entwickler baut sie als harte Nebenbedingung ein — sie ist ja klar formuliert. Die
> Abnahme läuft mit den Daten des letzten Quartals: Der Plan hält die Vorgabe ein, der
> Deckungsbeitrag stimmt, alle Tests grün. Das System geht in Betrieb.
>
> **Diese Anforderung enthält zwei verschiedene Fehler. Welche?**
>
> Ein Hinweis für den zweiten: Sehen Sie sich an, was das Modell im Abnahmetest *tatsächlich*
> geprüft hat — und rechnen Sie einen Monat durch, in dem weniger Anfragen hereinkommen.
---
## Micro-Quiz {#sec:modellierung-quiz}
> **❓ Drei Fragen**
>
> **1. Warum liefert „Umsatz maximieren" hier einen schlechteren Plan als
> „Deckungsbeitrag maximieren", obwohl beide Modelle korrekt sind?**
> a) Weil der Solver bei Umsatzzielen ungenauer rechnet.
> b) Weil alles, was nicht in der Zielfunktion steht, für das Modell wertlos ist — nicht
> weniger wichtig.
> c) Weil Umsatzdaten in der Praxis unzuverlässiger sind als Kostendaten.
>
> **2. Eine Regel als harte Nebenbedingung statt als Strafkosten zu formulieren, ist vor
> allem deshalb riskant, weil …**
> a) … harte Nebenbedingungen den Solver verlangsamen.
> b) … das Modell unlösbar wird, sobald die Regel einmal nicht erfüllbar ist — und dann gar
> keine Antwort mehr liefert.
> c) … sich harte Regeln nachträglich nicht mehr ändern lassen.
>
> **3. Alle drei Zielgrößen erreichen 100 % Auslastung. Was folgt daraus?**
> a) Die Auslastung war schon vorher optimal, das Projekt ist überflüssig.
> b) Bei knapper Kapazität ist volle Auslastung die Voreinstellung; zu entscheiden ist nur,
> *womit* die Maschine voll wird.
> c) Die Kapazitätsdaten sind vermutlich falsch erfasst.
---
## Selbsttest {#sec:modellierung-selbsttest}
1. Warum ist „Was ist Ihnen wichtig?" eine schlechte Frage und „Welche Zahl steht im
Jahresbericht?" eine gute?
2. Nennen Sie zwei Kennzahlen aus Ihrem eigenen Arbeitsumfeld, deren Maximierung schaden
würde.
3. Erklären Sie in einem Satz, warum eine harte Regel eine weiche mit großer Strafe ist —
und wann der Unterschied trotzdem zählt.
4. Ein Kunde sagt: „Kein Mitarbeiter soll mehr als zwei Wochenenden im Monat arbeiten."
Nennen Sie zwei Lesarten und je eine Situation, in der sie auseinanderfallen.
5. Was antworten Sie auf die Frage „Was soll passieren, wenn es keine zulässige Lösung
gibt?" — und warum ist „das kommt nicht vor" keine Antwort?
---
## Zusammenfassung {#sec:modellierung-zusammenfassung}
* Ein Modell rechnet aus, was **aufgeschrieben** wurde, nicht was gemeint war. Die
Zielfunktion ist eine vollständige Aussage: Was nicht darin steht, ist wertlos.
* Die **Zielgröße** ist die folgenreichste Entscheidung eines OR-Projekts. Umsatz,
Auslastung und Deckungsbeitrag führten hier auf demselben Datensatz zu drei
verschiedenen Plänen mit 24 500 € Unterschied.
* **Auslastung ist ein Ergebnis, kein Ziel.** Bei knapper Kapazität ist die Maschine ohnehin
voll; entscheidend ist, womit.
* Ein Satz aus einer Besprechung hat meist **mehrere Lesarten**. „Stammkunden nicht
verlieren" kostete je nach Lesart 3 500 € oder 12 500 € — bei identischem Ergebnis für die
Kunden.
* **Hart ist nur, was rechtlich oder physikalisch unmöglich ist.** Eine harte Regel ist eine
weiche mit hinreichend großer Strafe; der Unterschied zeigt sich erst, wenn sie einmal
nicht erfüllbar ist — dann liefert die harte Variante gar nichts mehr.
* Die vier Fragen — worüber wird entschieden, woran gemessen, was ist unverhandelbar, was
bei Unlösbarkeit — gehören in die erste Besprechung. Der Leitfaden in
{ref:sec:modellierung-leitfaden} macht zwölf daraus.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,752 @@
# Kapitel: Spaltengenerierung — das Modell umbauen statt die Lösung raten {#kap:dekomposition}
> **📌 Kapitel auf einen Blick**
>
> **Worum geht es?** Um die andere Antwort auf die Frage aus {ref:kap:metaheuristiken}: Was
> tun, wenn der Solver an der Größe scheitert? Statt die Lösung zu approximieren, formuliert
> man das Modell so um, dass exaktes Lösen wieder möglich wird.
>
> **Voraussetzungen:** {ref:sec:lp-dualitaet-und-schattenpreise} (Dualwerte) und
> {ref:kap:milp} (`Rucksack.py`). Beides wird gebraucht, aber nicht neu erklärt.
>
> **Danach können Sie:** ein Problem in Master und Teilproblem zerlegen; die
> Spaltengenerierungsschleife selbst schreiben; und begründen, wann sich der Aufwand lohnt —
> die Antwort hängt an einer einzigen Kennzahl der Instanz.
>
> **Zeitbedarf:** ca. 3 Stunden.
>
> **Programme:**\
> `Spaltengenerierung.py`
---
## In 5 Minuten gelöst {#sec:dekomposition-schnellstart}
> **🚀 In 5 Minuten gelöst: Denken Sie in Mustern, nicht in Stücken**
>
> Eine Rolle ist 1 000 mm breit. Bestellt sind 9 Stück à 420 mm, 12 à 310 mm und 14 à
> 250 mm. Wie viele Rollen braucht man?
>
> Die naheliegende Frage — *„welches Stück kommt auf welche Rolle?"* — führt zu 35
> Einzelentscheidungen. Die bessere Frage lautet: *„Wie oft schneide ich welches Muster?"*
>
> ```python
> import itertools
> import numpy as np
> from scipy.optimize import linprog
>
> ROLLE = 1000 # mm Mutterrolle
> breiten, bedarf = [420, 310, 250], [9, 12, 14]
>
> # Alle Schnittmuster aufzaehlen, die in die Rolle passen
> muster = [m for m in itertools.product(*[range(ROLLE // b + 1) for b in breiten])
> if sum(a * b for a, b in zip(m, breiten)) <= ROLLE and sum(m) > 0]
>
> # Wie oft schneide ich welches Muster? min sum(x) u.d.N. A x >= bedarf
> loesung = linprog(np.ones(len(muster)), A_ub=-np.array(muster, float).T,
> b_ub=-np.array(bedarf, float), bounds=(0, None),
> integrality=1, method="highs")
>
> print(f"{len(muster)} zulaessige Muster, {int(round(loesung.fun))} Rollen noetig:")
> for anzahl, m in zip(np.round(loesung.x).astype(int), muster):
> if anzahl:
> rest = ROLLE - sum(a * b for a, b in zip(m, breiten))
> print(f" {anzahl:2d} x " + " ".join(f"{a}x{b}mm" for a, b in zip(m, breiten))
> + f" Verschnitt {rest} mm")
> ```
>
> **Ausgabe:**
>
> ```
> 16 zulaessige Muster, 12 Rollen noetig:
> 2 x 0x420mm 0x310mm 4x250mm Verschnitt 0 mm
> 1 x 0x420mm 3x310mm 0x250mm Verschnitt 70 mm
> 9 x 1x420mm 1x310mm 1x250mm Verschnitt 20 mm
> ```
**Und jetzt der Punkt.** Sehen Sie sich an, was da herauskommt: **drei Schnittmuster**. Kein
Zuordnungsplan für 35 Stücke, sondern eine Anweisung, die an der Maschine hängen kann —
neunmal dieses Muster, zweimal jenes, einmal das dritte.
Das ist kein kosmetischer Unterschied. Das Modell hat **eine Variable je Muster** statt einer
je Stück-und-Rolle, und es kennt gar keine einzelnen Rollen mehr. Damit verschwindet ein
Problem, das das naheliegende Modell praktisch unlösbar macht — dazu gleich mehr.
> **🎯 Merksatz**
> Die schwierigste Arbeit an einem Optimierungsmodell ist selten die Zielfunktion. Es ist die
> Frage, **worüber** die Variablen laufen.
**Warum funktioniert das?** Weil hier alle 16 möglichen Muster aufzählbar waren. Bei drei
Breiten und einer 1 000-mm-Rolle sind es 16; bei dreizehn Breiten und 5 600 mm sind es
zehntausende, und bei einer echten Papierfabrik mehr, als sich speichern lässt. Das ganze
Kapitel handelt davon, wie man mit Mustern rechnet, ohne sie aufzuschreiben.
---
## Lernziele {#sec:dekomposition-lernziele}
Nach diesem Kapitel können Sie …
1. … erklären, warum das naheliegende Zuordnungsmodell an **Symmetrie** scheitert.
2. … ein Problem in **Master** und **Pricing-Teilproblem** zerlegen.
3. … die Schleife schreiben: Master-LP lösen, Dualwerte auslesen, Teilproblem lösen, Spalte
hinzufügen.
4. … das Abbruchkriterium über die **reduzierten Kosten** begründen.
5. … an einer Kennzahl der Instanz abschätzen, ob sich das Verfahren lohnt.
---
## Warum das naheliegende Modell scheitert {#sec:dekomposition-symmetrie}
Der erste Entwurf eines Zuschnittmodells sieht fast immer so aus: eine Binärvariable
$z_{jr}$ für „Stück $j$ liegt auf Rolle $r$", dazu $y_r$ für „Rolle $r$ wird benutzt".
Zwei Dinge gehen dabei schief.
**Die Größe.** Bei 219 Zuschnitten und höchstens 82 Rollen sind das knapp 18 000
Binärvariablen — unangenehm, aber allein noch kein Hindernis.
**Die Symmetrie.** Das ist das eigentliche Problem. Alle Mutterrollen sind **gleich**. Jede
Lösung existiert deshalb in unzähligen Umbenennungen: Vertauscht man Rolle 3 und Rolle 47,
entsteht eine formal andere, inhaltlich identische Lösung. Branch-and-Bound weiß das nicht
und arbeitet sie einzeln ab.
> **⚠️ Woran man ein Symmetrieproblem erkennt**
>
> Der Suchbaum wächst, aber die **Schranke bewegt sich nicht.** Im Protokoll aus
> {ref:sec:milp-gap} sieht das so aus: Die Zahl der Knoten steigt in die Hunderttausende,
> der Incumbent verbessert sich hin und wieder, und der Dual Bound steht praktisch still.
>
> Das ist ein anderes Bild als „das Problem ist einfach zu groß". Bei einem großen, aber
> unsymmetrischen Problem nähern sich beide Werte einander an, nur langsam.
Das Mustermodell hat dieses Problem nicht, weil es **keine einzelnen Rollen kennt**. Es
zählt nur, wie oft welches Schnittmuster geschnitten wird:
$$
\min \sum_{p \in P} x_p
\qquad \text{unter} \qquad
\sum_{p \in P} a_{ip}\, x_p \ge d_i \quad \forall i,
\qquad x_p \ge 0 \ \text{ganzzahlig}
$$
> **🔤 Formel-Übersetzer**
>
> | Mathematik | Alltagssprache |
> | --- | --- |
> | $P$ | die Menge aller zulässigen Schnittmuster |
> | $a_{ip}$ | „Wie viele Stücke der Breite $i$ liefert Muster $p$?" |
> | $x_p$ | „Wie oft schneide ich Muster $p$?" — die einzige Entscheidung |
> | $d_i$ | die bestellte Stückzahl der Breite $i$ |
> | $\sum_p x_p$ | die Zahl der verbrauchten Mutterrollen |
>
> **Ohne Formel gesagt:** „Stelle eine Schnittliste zusammen, die alle Bestellungen deckt,
> und benutze dabei so wenige Rollen wie möglich."
>
> Das Modell ist verblüffend klein — eine Zeile je Breite. Sein ganzes Gewicht steckt in
> $P$, und das ist der Haken.
---
## Rechnen mit Mustern, ohne sie aufzuschreiben {#sec:dekomposition-schleife}
Die Idee ist alt (Gilmore und Gomory, 1961) und in einem Satz gesagt:
> Fange mit wenigen Mustern an. Frage nach jeder Lösung, ob es ein **noch nicht bekanntes**
> Muster gibt, das sich lohnen würde — und wenn ja, nimm nur dieses eine dazu.
Die Frage „lohnt sich noch ein Muster?" beantworten die **Dualwerte** des Master-LPs. Der
Dualwert $\pi_i$ zur Bedarfszeile $i$ ist genau der Schattenpreis aus
{ref:sec:lp-dualitaet-und-schattenpreise}: Er sagt, wie viele Rollen ein zusätzliches Stück
der Breite $i$ kostet.
Ein neues Muster $a$ lohnt sich, wenn seine **reduzierten Kosten** negativ sind:
$$
1 - \sum_i \pi_i\, a_i < 0
\qquad\Longleftrightarrow\qquad
\sum_i \pi_i\, a_i > 1
$$
> **🔤 Formel-Übersetzer**
>
> | Mathematik | Alltagssprache |
> | --- | --- |
> | die $1$ | „Ein zusätzliches Muster zu schneiden kostet eine Rolle." |
> | $\sum_i \pi_i a_i$ | „So viel ist das, was dabei herauskommt, zu den aktuellen Schattenpreisen wert." |
> | $> 1$ | „Es bringt mehr, als es kostet — dieses Muster nehmen wir dazu." |
> | $\le 1$ | „Kein Muster lohnt sich mehr. **Fertig** — und zwar beweisbar." |
>
> Die letzte Zeile ist der Grund, warum das Verfahren **exakt** ist und nicht heuristisch:
> Wenn kein Muster mehr lohnende reduzierte Kosten hat, ist die LP-Lösung über *allen*
> Mustern optimal — auch über den nie erzeugten.
**Und wie findet man das beste neue Muster?** Man sucht die Zusammenstellung von Stücken mit
dem größten Gesamtwert, die noch in die Rolle passt. Das ist ein **Rucksackproblem**:
| Rucksack ({ref:kap:milp}) | hier |
| --- | --- |
| Nutzen eines Gegenstands | Schattenpreis $\pi_i$ der Breite |
| Gewicht | Breite in mm |
| Kapazität des Rucksacks | Rollenbreite |
Das Teilproblem ist also ein Verfahren, das die Leser dieses Buchs schon kennen. **Neu ist
allein die Schleife.**
---
## Das Programm {#sec:dekomposition-programm}
```python
#!/usr/bin/env python3
# Spaltengenerierung.py
"""
Kapitel Dekomposition: Das Modell umbauen, statt die Loesung zu raten.
Eine Papierfabrik liefert Rollen von 5.600 mm Breite. Die Kunden bestellen
schmalere Breiten, und aus jeder Mutterrolle werden mehrere davon geschnitten.
Gesucht ist der Schnittplan, der mit den wenigsten Mutterrollen auskommt.
Der naheliegende Modellansatz - "welches Stueck kommt auf welche Rolle?" -
fuehrt in eine Sackgasse: Er braucht eine Binaervariable je Stueck-und-Rolle,
und weil alle Rollen gleich sind, ist er hochgradig symmetrisch. Der Solver
probiert dieselbe Loesung in tausend Umbenennungen durch.
Die Spaltengenerierung dreht die Frage um:
Nicht "welches Stueck auf welche Rolle",
sondern "wie oft schneide ich welches MUSTER".
Damit verschwindet die Symmetrie - aber es entsteht ein neues Problem: Die Zahl
der moeglichen Muster waechst kombinatorisch. Man kann sie nicht aufschreiben.
Der Trick besteht darin, sie auch nicht aufzuschreiben, sondern nur die wenigen
zu erzeugen, die tatsaechlich gebraucht werden - und zwar mit Hilfe der
Schattenpreise aus dem Kapitel LP.
Das Programm zeigt vier Dinge:
1. Die beiden Formulierungen desselben Problems und ihre Groesse.
2. Wie schnell die Musterzahl waechst - und wie wenige davon genuegen.
3. Die Schleife selbst: Master-LP, Dualwerte, Rucksack, neue Spalte.
4. Wann sich der Aufwand lohnt und wann nicht. Die Antwort haengt an einer
einzigen Kennzahl der Instanz, und sie erklaert nebenbei, warum eine
Faustregel bei manchen Zuschnittproblemen schon optimal ist.
Benoetigt: numpy, scipy
"""
from __future__ import annotations
import numpy as np
from scipy.optimize import linprog
ROLLENBREITE = 5600
# Zwei Instanzen mit demselben Bedarf, aber verschiedenen Breiten. Der
# Unterschied ist die eine Kennzahl, um die es in Teil 4 geht.
BEDARF = [22, 25, 12, 14, 18, 18, 20, 10, 12, 14, 16, 18, 20]
BREIT = [1380, 1520, 1560, 1710, 1820, 1880, 1930, 2000, 2050, 2100, 2140,
2150, 2200]
SCHMAL = [380, 520, 560, 710, 820, 880, 930, 1000, 1050, 1100, 1140,
1150, 1200]
# --- Die Musterzahl ---------------------------------------------------------
def zaehle_muster(breiten, rollenbreite: int = ROLLENBREITE) -> int:
"""Wie viele zulaessige Schnittmuster gibt es ueberhaupt?
Nur zum Zeigen - in einem produktiven Programm wuerde man das nie
ausrechnen, weil die Zahl bei realistischen Instanzen jede Vorstellung
sprengt. Genau das ist der Punkt.
"""
speicher: dict[tuple[int, int], int] = {}
def ab(i: int, rest: int) -> int:
if i == len(breiten):
return 1
if (i, rest) in speicher:
return speicher[(i, rest)]
summe = sum(ab(i + 1, rest - anzahl * breiten[i])
for anzahl in range(rest // breiten[i] + 1))
speicher[(i, rest)] = summe
return summe
return ab(0, rollenbreite)
# --- Die beiden Bausteine der Schleife --------------------------------------
def loese_master(muster, bedarf, ganzzahlig: bool = False):
"""Restringiertes Master: Wie oft schneide ich jedes bekannte Muster?
min sum_p x_p unter sum_p a_ip * x_p >= bedarf_i
Als LP gerechnet liefert es zusaetzlich die DUALWERTE - und die sind der
eigentliche Ertrag: Der Dualwert zu Breite i sagt, was ein zusaetzliches
Stueck dieser Breite an Rollen kostet. Es ist derselbe Schattenpreis wie
im Kapitel LP, nur dass er hier nicht berichtet, sondern weiterverarbeitet
wird.
"""
matrix = -np.array(muster, dtype=float).T # >= wird zu <= mit Minus
ergebnis = linprog(np.ones(len(muster)),
A_ub=matrix, b_ub=-np.array(bedarf, dtype=float),
bounds=(0, None),
integrality=(1 if ganzzahlig else 0), method="highs")
if not ergebnis.success:
raise SystemExit(f"Master nicht loesbar: {ergebnis.message}")
dual = None if ganzzahlig else -ergebnis.ineqlin.marginals
return ergebnis.fun, ergebnis.x, dual
def bestes_neues_muster(dual, breiten, rollenbreite: int = ROLLENBREITE):
"""Pricing: Gibt es ein Muster, das sich noch lohnt?
Gesucht ist das Muster mit dem groessten Gesamtwert zu den aktuellen
Schattenpreisen - unter der Bedingung, dass es in die Rolle passt. Das ist
ein RUCKSACKPROBLEM (Kapitel MILP, Rucksack.py): Preise sind der Nutzen,
Breiten das Gewicht, die Rollenbreite die Kapazitaet. Hier mit dynamischer
Programmierung geloest, weil die Breiten ganzzahlig sind.
Bei Gleichstand wird bewusst der KLEINSTE Index bevorzugt (>-Vergleich mit
Toleranz statt >=). Sonst haengt das erzeugte Muster von der Reihenfolge
der Gleitkommaoperationen ab, und das Programm liefert von Lauf zu Lauf
verschiedene Ausgaben.
"""
wert = np.zeros(rollenbreite + 1)
herkunft = [-1] * (rollenbreite + 1)
for platz in range(rollenbreite + 1):
for i, breite in enumerate(breiten):
if breite <= platz and wert[platz - breite] + dual[i] > wert[platz] + 1e-9:
wert[platz] = wert[platz - breite] + dual[i]
herkunft[platz] = i
muster = [0] * len(breiten)
platz = rollenbreite
while herkunft[platz] >= 0:
i = herkunft[platz]
muster[i] += 1
platz -= breiten[i]
return float(wert[rollenbreite]), muster
def spaltengenerierung(breiten, bedarf, rollenbreite: int = ROLLENBREITE):
"""Die Schleife. Startbasis: je ein Muster mit nur einer Breite.
Abbruch, wenn kein Muster mehr einen Wert ueber 1 hat: Eine zusaetzliche
Rolle kostet 1, also lohnt sich ein neues Muster nur, wenn es zu den
aktuellen Preisen mehr als 1 wert ist. Das ist das Kriterium der
reduzierten Kosten.
"""
anzahl = len(breiten)
muster = [[0] * anzahl for _ in range(anzahl)]
for i in range(anzahl):
muster[i][i] = rollenbreite // breiten[i]
runden = 0
while True:
zielwert, _, dual = loese_master(muster, bedarf)
wert, neu = bestes_neues_muster(dual, breiten, rollenbreite)
runden += 1
if wert <= 1 + 1e-6:
return muster, zielwert, runden
muster.append(neu)
def first_fit(breiten, bedarf, rollenbreite: int = ROLLENBREITE) -> int:
"""Die Faustregel: groesstes Stueck zuerst, auf die erste passende Rolle."""
stuecke = sorted([b for b, menge in zip(breiten, bedarf)
for _ in range(menge)], reverse=True)
rollen: list[list[int]] = []
for stueck in stuecke:
for rolle in rollen:
if sum(rolle) + stueck <= rollenbreite:
rolle.append(stueck)
break
else:
rollen.append([stueck])
return len(rollen)
def auswerten(name: str, breiten, bedarf) -> dict:
muster, schranke, runden = spaltengenerierung(breiten, bedarf)
ganz, _, _ = loese_master(muster, bedarf, ganzzahlig=True)
return {"name": name, "muster_gesamt": zaehle_muster(breiten),
"muster_erzeugt": len(muster), "runden": runden,
"schranke": schranke, "ganzzahlig": int(round(ganz)),
"first_fit": first_fit(breiten, bedarf),
"stuecke_je_rolle": ROLLENBREITE / np.mean(breiten)}
if __name__ == "__main__":
print("=" * 84)
print(" SPALTENGENERIERUNG: NUR DIE MUSTER ERZEUGEN, DIE MAN BRAUCHT")
print("=" * 84)
print(f"Mutterrolle {ROLLENBREITE:,} mm, {len(BREIT)} bestellte Breiten, "
f"{sum(BEDARF)} Zuschnitte.\n")
# --- 1. Zwei Formulierungen ------------------------------------------
stuecke = sum(BEDARF)
# Wie viele Rollen braucht man hoechstens? Die Faustregel liefert eine
# brauchbare Obergrenze - mehr Rollen als das wird niemand benoetigen.
rollen_obergrenze = first_fit(BREIT, BEDARF)
print("1. Zwei Modelle fuer dieselbe Aufgabe\n")
print(f" {'Formulierung':<34} {'Binaervariablen':>16} {'symmetrisch?':>16}")
print(" " + "-" * 70)
print(f" {'Stueck -> Rolle (naheliegend)':<34} "
f"{stuecke * rollen_obergrenze:>16,} {'ja':>16}")
print(f" {'wie oft welches Muster':<34} "
f"{'eine je Muster':>16} {'nein':>16}")
print(f"\n Der naheliegende Ansatz braucht eine Variable je Stueck und Rolle:")
print(f" {stuecke} Zuschnitte x {rollen_obergrenze} Rollen (Obergrenze aus der Faustregel)")
print(f" = {stuecke * rollen_obergrenze:,} Binaervariablen.")
print("\n Schlimmer als die Zahl ist die Symmetrie: Alle Mutterrollen sind")
print(" gleich, also beschreibt jede Loesung dieselbe Schnittvorschrift in")
print(" unzaehligen Umbenennungen. Branch-and-Bound probiert sie einzeln")
print(" durch und kommt nicht voran - der Suchbaum waechst, ohne dass sich")
print(" die Schranke bewegt.")
print("\n Das Mustermodell hat je Muster genau eine Variable und kennt keine")
print(" einzelnen Rollen mehr. Damit ist die Symmetrie weg. Sein Problem ist")
print(" ein anderes - und zwar das folgende.")
# --- 2. Die Musterzahl ------------------------------------------------
print("\n" + "-" * 84)
print("2. Warum man die Muster nicht aufschreiben kann\n")
gross = auswerten("breite Zuschnitte (1.380-2.200 mm)", BREIT, BEDARF)
schmal = auswerten("schmale Zuschnitte (380-1.200 mm)", SCHMAL, BEDARF)
print(f" {'Instanz':<36} {'Muster':>12} {'davon erzeugt':>14} {'Anteil':>9}")
print(" " + "-" * 76)
for fall in (gross, schmal):
anteil = fall["muster_erzeugt"] / fall["muster_gesamt"]
print(f" {fall['name']:<36} {fall['muster_gesamt']:>12,} "
f"{fall['muster_erzeugt']:>14} {anteil:>8.1%}")
print(f"\n Schon das Halbieren der Breiten laesst die Musterzahl von "
f"{gross['muster_gesamt']:,} auf")
print(f" {schmal['muster_gesamt']:,} springen - Faktor "
f"{schmal['muster_gesamt'] / gross['muster_gesamt']:.0f}. Bei einer echten "
f"Papierfabrik mit")
print(" vierzig Breiten und Millimeterschritten sind es mehr, als sich")
print(" speichern liesse.")
print(f"\n Gebraucht werden davon {schmal['muster_erzeugt']} - "
f"{schmal['muster_erzeugt'] / schmal['muster_gesamt']:.2%} der Gesamtzahl.")
# --- 3. Die Schleife --------------------------------------------------
print("\n" + "-" * 84)
print("3. Die Schleife an der breiten Instanz\n")
print(f" Startbasis: {len(BREIT)} triviale Muster (je Rolle nur eine Breite)")
print(f" Runden bis kein Muster mehr lohnt: {gross['runden']}")
print(f" Muster am Ende: {gross['muster_erzeugt']}")
print(f" LP-Schranke: {gross['schranke']:.4f} Rollen")
print(f"\n Die LP-Schranke ist eine ZUSAGE: Weniger als "
f"{np.ceil(gross['schranke'] - 1e-9):.0f} Rollen sind")
print(" nicht moeglich - unabhaengig davon, wie clever man weiterschneidet.")
print(" Genau diese Aussage fehlt einer Heuristik (Kapitel Metaheuristiken).")
# --- 4. Wann es sich lohnt --------------------------------------------
print("\n" + "-" * 84)
print("4. Wann sich der Aufwand lohnt - und wann nicht\n")
print(f" {'Instanz':<36} {'Faustregel':>11} {'exakt':>8} {'Schranke':>10} "
f"{'Ersparnis':>11}")
print(" " + "-" * 80)
for fall in (gross, schmal):
ersparnis = (fall["first_fit"] - fall["ganzzahlig"]) / fall["first_fit"]
print(f" {fall['name']:<36} {fall['first_fit']:>11} "
f"{fall['ganzzahlig']:>8} {fall['schranke']:>10.2f} "
f"{ersparnis:>10.0%}")
print(f"\n Bei den breiten Zuschnitten spart die Spaltengenerierung "
f"{gross['first_fit'] - gross['ganzzahlig']} von")
print(f" {gross['first_fit']} Rollen. Bei den schmalen spart sie NICHTS - dort ist die")
print(" Faustregel bereits optimal.")
print(f"\n Der Unterschied haengt an einer einzigen Kennzahl:\n")
print(f" {'Instanz':<36} {'Stuecke je Rolle (etwa)':>24}")
print(" " + "-" * 62)
for fall in (gross, schmal):
print(f" {fall['name']:<36} {fall['stuecke_je_rolle']:>24.1f}")
print("\n Passen nur zwei bis drei Stuecke auf eine Rolle, entscheidet jede")
print(" einzelne Zuordnung viel, und eine kurzsichtige Regel verschenkt")
print(" ganze Rollen. Passen sechs oder mehr darauf, gleichen sich die")
print(" Fehler aus - die Reste sind klein gegen die Rollenbreite, und die")
print(" Faustregel trifft es fast immer.")
print("\n" + "=" * 84)
print(" WAS MAN DARAUS MITNIMMT")
print("=" * 84)
print("1. Der Perspektivwechsel ist die eigentliche Arbeit: nicht 'welches")
print(" Stueck wohin', sondern 'wie oft welches Muster'. Damit verschwindet")
print(" die Symmetrie, die das naheliegende Modell unloesbar macht.")
print("2. Der Preis dafuer ist eine unaufschreibbare Zahl von Variablen. Die")
print(" Spaltengenerierung zahlt ihn nicht, sondern erzeugt nur die wenigen")
print(" Spalten, die die Dualwerte als lohnend ausweisen.")
print("3. Das Teilproblem ist ein Rucksack - ein Verfahren, das die Leser")
print(" dieses Buchs schon kennen. Neu ist allein die Schleife.")
print("4. Und die unbequeme Erkenntnis: Ob sich das alles lohnt, entscheidet")
print(" die Instanz, nicht die Methode. Bei sechs Stuecken je Rolle ist die")
print(" Faustregel so gut wie das Optimum - und der Ertrag der")
print(" Spaltengenerierung liegt dann allein in der SCHRANKE, die beweist,")
print(" dass man aufhoeren kann zu suchen.")
print("=" * 84)
```
**Erwartete Ausgabe:**
```
====================================================================================
SPALTENGENERIERUNG: NUR DIE MUSTER ERZEUGEN, DIE MAN BRAUCHT
====================================================================================
Mutterrolle 5,600 mm, 13 bestellte Breiten, 219 Zuschnitte.
1. Zwei Modelle fuer dieselbe Aufgabe
Formulierung Binaervariablen symmetrisch?
----------------------------------------------------------------------
Stueck -> Rolle (naheliegend) 17,958 ja
wie oft welches Muster eine je Muster nein
Der naheliegende Ansatz braucht eine Variable je Stueck und Rolle:
219 Zuschnitte x 82 Rollen (Obergrenze aus der Faustregel)
= 17,958 Binaervariablen.
Schlimmer als die Zahl ist die Symmetrie: Alle Mutterrollen sind
gleich, also beschreibt jede Loesung dieselbe Schnittvorschrift in
unzaehligen Umbenennungen. Branch-and-Bound probiert sie einzeln
durch und kommt nicht voran - der Suchbaum waechst, ohne dass sich
die Schranke bewegt.
Das Mustermodell hat je Muster genau eine Variable und kennt keine
einzelnen Rollen mehr. Damit ist die Symmetrie weg. Sein Problem ist
ein anderes - und zwar das folgende.
------------------------------------------------------------------------------------
2. Warum man die Muster nicht aufschreiben kann
Instanz Muster davon erzeugt Anteil
----------------------------------------------------------------------------
breite Zuschnitte (1.380-2.200 mm) 309 38 12.3%
schmale Zuschnitte (380-1.200 mm) 46,408 28 0.1%
Schon das Halbieren der Breiten laesst die Musterzahl von 309 auf
46,408 springen - Faktor 150. Bei einer echten Papierfabrik mit
vierzig Breiten und Millimeterschritten sind es mehr, als sich
speichern liesse.
Gebraucht werden davon 28 - 0.06% der Gesamtzahl.
------------------------------------------------------------------------------------
3. Die Schleife an der breiten Instanz
Startbasis: 13 triviale Muster (je Rolle nur eine Breite)
Runden bis kein Muster mehr lohnt: 26
Muster am Ende: 38
LP-Schranke: 72.9167 Rollen
Die LP-Schranke ist eine ZUSAGE: Weniger als 73 Rollen sind
nicht moeglich - unabhaengig davon, wie clever man weiterschneidet.
Genau diese Aussage fehlt einer Heuristik (Kapitel Metaheuristiken).
------------------------------------------------------------------------------------
4. Wann sich der Aufwand lohnt - und wann nicht
Instanz Faustregel exakt Schranke Ersparnis
--------------------------------------------------------------------------------
breite Zuschnitte (1.380-2.200 mm) 82 73 72.92 11%
schmale Zuschnitte (380-1.200 mm) 35 35 33.60 0%
Bei den breiten Zuschnitten spart die Spaltengenerierung 9 von
82 Rollen. Bei den schmalen spart sie NICHTS - dort ist die
Faustregel bereits optimal.
Der Unterschied haengt an einer einzigen Kennzahl:
Instanz Stuecke je Rolle (etwa)
--------------------------------------------------------------
breite Zuschnitte (1.380-2.200 mm) 3.0
schmale Zuschnitte (380-1.200 mm) 6.4
Passen nur zwei bis drei Stuecke auf eine Rolle, entscheidet jede
einzelne Zuordnung viel, und eine kurzsichtige Regel verschenkt
ganze Rollen. Passen sechs oder mehr darauf, gleichen sich die
Fehler aus - die Reste sind klein gegen die Rollenbreite, und die
Faustregel trifft es fast immer.
====================================================================================
WAS MAN DARAUS MITNIMMT
====================================================================================
1. Der Perspektivwechsel ist die eigentliche Arbeit: nicht 'welches
Stueck wohin', sondern 'wie oft welches Muster'. Damit verschwindet
die Symmetrie, die das naheliegende Modell unloesbar macht.
2. Der Preis dafuer ist eine unaufschreibbare Zahl von Variablen. Die
Spaltengenerierung zahlt ihn nicht, sondern erzeugt nur die wenigen
Spalten, die die Dualwerte als lohnend ausweisen.
3. Das Teilproblem ist ein Rucksack - ein Verfahren, das die Leser
dieses Buchs schon kennen. Neu ist allein die Schleife.
4. Und die unbequeme Erkenntnis: Ob sich das alles lohnt, entscheidet
die Instanz, nicht die Methode. Bei sechs Stuecken je Rolle ist die
Faustregel so gut wie das Optimum - und der Ertrag der
Spaltengenerierung liegt dann allein in der SCHRANKE, die beweist,
dass man aufhoeren kann zu suchen.
====================================================================================
```
---
## Was die Zahlen zeigen {#sec:dekomposition-befund}
### Die Musterzahl explodiert, der Bedarf nicht
| Instanz | mögliche Muster | erzeugt | Anteil |
| --- | ---: | ---: | ---: |
| breite Zuschnitte (1 3802 200 mm) | 309 | 38 | 12,3 % |
| schmale Zuschnitte (3801 200 mm) | 46 408 | 28 | **0,06 %** |
Schon das Halbieren der Breiten lässt die Musterzahl um den **Faktor 150** springen. Die
Zahl der *gebrauchten* Muster bleibt dagegen konstant — sie steigt sogar nicht, sie sinkt
leicht. Das ist der ganze Ertrag des Verfahrens: Der Aufwand hängt an der Zahl der
**Bedarfszeilen**, nicht an der Zahl der Muster.
### Der unbequeme Teil: Es lohnt sich nicht immer
| Instanz | Faustregel | exakt | Schranke | Ersparnis |
| --- | ---: | ---: | ---: | ---: |
| breite Zuschnitte | 82 Rollen | **73** | 72,92 | **11 %** |
| schmale Zuschnitte | 35 Rollen | 35 | 33,60 | **0 %** |
Bei den breiten Zuschnitten spart die Spaltengenerierung neun von 82 Rollen. Bei den
schmalen spart sie **nichts** — dort ist First-Fit-Decreasing bereits optimal.
Der Unterschied hängt an einer einzigen Kennzahl:
| Instanz | Stücke je Rolle |
| --- | ---: |
| breite Zuschnitte | 3,0 |
| schmale Zuschnitte | 6,4 |
**Passen nur zwei bis drei Stücke auf eine Rolle**, entscheidet jede einzelne Zuordnung viel,
und eine kurzsichtige Regel verschenkt ganze Rollen. **Passen sechs oder mehr darauf**,
gleichen sich die Fehler aus: Die Reste sind klein gegen die Rollenbreite, und die Faustregel
trifft es fast immer.
> **💡 Das erklärt einen Befund aus {ref:kap:metaheuristiken}**
>
> Dort steht, dass Bin Packing als Aufhänger für Metaheuristiken **nicht** taugt, weil
> First-Fit in den geprüften Größen bereits optimal war. Der Grund ist jetzt benennbar: Die
> dortige Instanz hatte Stücke von 700 bis 2 600 mm bei 5 600 mm Rollenbreite — im Mittel
> gut drei je Rolle, aber mit viel kleineren Stücken durchsetzt, so dass die Reste sich
> auffüllen ließen.
>
> Die brauchbare Faustregel lautet also nicht „Bin Packing ist einfach", sondern: **Je
> weniger Stücke auf einen Behälter passen, desto mehr ist mit exakter Optimierung zu
> holen.** Das ist eine Zahl, die man vor dem Projekt ausrechnen kann.
Und selbst dort, wo die Ersparnis null ist, liefert das Verfahren etwas, das die Faustregel
nicht kann: die **Schranke 33,60**. Sie beweist, dass 34 Rollen das Minimum wären und 35
höchstens eine daneben liegen — also dass man aufhören kann zu suchen.
---
## Übungsaufgaben {#sec:dekomposition-uebungsaufgaben}
> Lösungen: {ref:sec:loesungen-dekomposition}.
**Aufgabe ⭐ — Das Abbruchkriterium.**
Warum lautet die Schwelle beim Pricing genau `1` und nicht `0`? Woher kommt die Eins?
**Aufgabe ⭐ — Die Startbasis.**
Das Programm startet mit Mustern, die je nur eine Breite enthalten. Warum ist das immer
zulässig, und warum wäre eine leere Startmenge ein Problem?
**Aufgabe ⭐⭐ — Die Kennzahl prüfen.**
Erzeugen Sie Instanzen mit 2, 4, 8 und 16 Stücken je Rolle und tragen Sie die Ersparnis
gegenüber First-Fit auf. Bestätigt sich der Zusammenhang aus
{ref:sec:dekomposition-befund}?
**Aufgabe ⭐⭐ — Dienstplanung.**
Formulieren Sie die Wochendienstplanung als Spaltengenerierung: Ein „Muster" ist ein
zulässiger Wochenplan **einer** Person. Was ist das Teilproblem, und welche Nebenbedingungen
stehen im Master, welche im Teilproblem?
**Aufgabe ⭐⭐⭐ — Branch-and-Price.**
Das ganzzahlige Master über die erzeugten Spalten ist nicht garantiert optimal — es könnte
Muster geben, die erst *nach* einer Verzweigung lohnend werden. Recherchieren Sie, was
Branch-and-Price daran ändert, und erklären Sie, warum man nicht einfach auf den erzeugten
Spalten verzweigen kann.
---
## Finde den Denkfehler {#sec:dekomposition-denkfehler}
> **🐛 „Die LP-Lösung sagt 72,92 — also runden wir auf"**
>
> Ein Kollege hat die Spaltengenerierung implementiert und ist zufrieden:
>
> > *„Das Master-LP liefert 72,92 Rollen, verteilt auf 13 Muster. Halbe Rollen kann man
> > nicht schneiden, also runde ich jedes Muster auf die nächste ganze Zahl auf. Damit sind
> > alle Bestellungen sicher gedeckt — aufrunden kann ja nur zu viel liefern, nie zu wenig.
> > Ergebnis: ein zulässiger Schnittplan, und die Schranke sagt mir, dass ich höchstens eine
> > Rolle daneben liege."*
>
> Der erste Teil stimmt: Aufrunden liefert tatsächlich einen zulässigen Plan. Der zweite Satz
> über die Schranke ist der Fehler — und er ist teuer.
>
> **Wie viele Rollen kostet das Aufrunden hier wirklich, und was hätte der Kollege
> stattdessen tun müssen?**
>
> Ein Hinweis: Er hat alles, was er dafür braucht, bereits vorliegen.
---
## Micro-Quiz {#sec:dekomposition-quiz}
> **❓ Drei Fragen**
>
> **1. Warum ist das Mustermodell dem Zuordnungsmodell überlegen?**
> a) Weil es weniger Nebenbedingungen hat.
> b) Weil es keine einzelnen Rollen kennt und damit die Symmetrie verschwindet.
> c) Weil Musterprobleme immer ganzzahlige LP-Lösungen haben.
>
> **2. Was ist das Pricing-Teilproblem beim Zuschnitt?**
> a) Ein zweites LP über dieselben Variablen.
> b) Ein Rucksackproblem: Welche Stücke passen in eine Rolle und sind zu den aktuellen
> Schattenpreisen am meisten wert?
> c) Eine Heuristik, die neue Muster zufällig erzeugt.
>
> **3. Bei der schmalen Instanz spart die Spaltengenerierung null Rollen gegenüber
> First-Fit. War der Aufwand umsonst?**
> a) Ja — wo nichts gespart wird, hat sich das Verfahren nicht gelohnt.
> b) Nein — sie liefert die untere Schranke und damit den Beweis, dass die Faustregel
> höchstens eine Rolle danebenliegt.
> c) Nein — sie wird bei der nächsten Instanz mehr sparen.
---
## Selbsttest {#sec:dekomposition-selbsttest}
1. Erklären Sie Symmetrie in einem MILP an einem Beispiel aus Ihrem Arbeitsumfeld.
2. Was ist beim Zuschnitt eine „Spalte", und was steht darin?
3. Woher kommen die Preise, mit denen das Teilproblem rechnet?
4. Warum ist das Verfahren exakt, obwohl es fast alle Muster nie ansieht?
5. Sie sollen vor dem Projekt abschätzen, ob sich Spaltengenerierung lohnt. Welche eine Zahl
rechnen Sie aus?
---
## Zusammenfassung {#sec:dekomposition-zusammenfassung}
* Die folgenreichste Entscheidung an einem Modell ist, **worüber die Variablen laufen**. Beim
Zuschnitt schlägt „wie oft welches Muster" das naheliegende „welches Stück auf welche
Rolle" — weil damit die **Symmetrie** verschwindet, an der Branch-and-Bound scheitert.
* Der Preis dafür ist eine Variablenmenge, die man nicht aufschreiben kann. Die
**Spaltengenerierung** zahlt ihn nicht: Sie erzeugt nur die Muster, die die **Dualwerte**
als lohnend ausweisen — im Beispiel 28 von 46 408.
* Das Abbruchkriterium sind die **reduzierten Kosten**: Solange ein Muster zu den aktuellen
Schattenpreisen mehr als eine Rolle wert ist, lohnt es sich. Danach ist die Lösung
beweisbar optimal — auch über die nie erzeugten Muster.
* Das **Teilproblem ist ein Rucksack**. Neu ist allein die Schleife.
* **Ob sich das lohnt, entscheidet die Instanz.** Bei drei Stücken je Rolle bringt das
Verfahren 11 %, bei sechs nichts. Diese Kennzahl lässt sich vor dem Projekt ausrechnen.
* Auch wo es nichts spart, liefert es die **Schranke** — und damit die Erlaubnis,
aufzuhören.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,962 @@
# Kapitel: Optimierung unter Unsicherheit — Monte-Carlo, Stochastik, Robustheit {#kap:unsicherheit}
> **📌 Kapitel auf einen Blick**
>
> **Worum geht es?** Bisher waren alle Parameter bekannt. In der Realität sind Nachfrage,
> Rendite und Fahrzeit **Zufallsgrößen**. Dieses Kapitel zeigt drei Wege, damit umzugehen —
> und warum „einfach den Mittelwert einsetzen“ der schlechteste davon ist.
>
> **Voraussetzungen:** {ref:kap:lp}, {ref:kap:qp-nlp}. Grundbegriffe der Statistik (Erwartungswert, Quantil).
>
> **Danach können Sie:** Ein zweistufiges stochastisches Modell aufstellen, Monte-Carlo zur
> Risikomessung einsetzen, ein robustes Modell für den Worst Case formulieren — und
> erkennen, wann Rechnen mit Mittelwerten zulässig ist und wann es systematisch danebengeht.
>
> **Zeitbedarf:** ca. 5,5 Stunden.
>
> **Programme:**\
> `Fluch_des_Durchschnitts.py`\
> `Monte_Carlo.py`\
> `Stochastische_Optimierung.py`\
> `Robuste_Optimierung.py`
---
## In 5 Minuten gelöst {#sec:unsicherheit-schnellstart}
> **🚀 In 5 Minuten gelöst: Wie viele Ersatzteile bestellen?**
>
> Ein Betrieb bestellt vor der Wintersaison Ersatzteile — **einmal**, danach ist die
> Lieferkette bis zum Frühjahr zu. Der Bedarf schwankt, im Mittel werden 30 Stück
> gebraucht (Poisson-verteilt).
>
> | Fall | Kosten je Stück |
> | --- | ---: |
> | **Zu viel bestellt** — Teil bleibt liegen und wird abgeschrieben | 120 € |
> | **Zu wenig bestellt** — Maschine steht bis zur Nachlieferung | 1 400 € |
>
> Wie viele bestellen Sie?
>
> ```python
> import numpy as np
>
> KOSTEN_ZUVIEL, KOSTEN_ZUWENIG = 120.0, 1400.0
> bedarf = np.random.default_rng(7).poisson(30, 200_000) # 200.000 Winter
>
> def kosten(menge):
> zuviel = np.maximum(menge - bedarf, 0)
> zuwenig = np.maximum(bedarf - menge, 0)
> return (KOSTEN_ZUVIEL * zuviel + KOSTEN_ZUWENIG * zuwenig).mean()
>
> kandidaten = np.arange(20, 61)
> werte = np.array([kosten(m) for m in kandidaten])
> print("Optimum:", kandidaten[werte.argmin()], "Stueck,", f"{werte.min():,.0f} EUR")
> print("Mittlerer Bedarf 30:", f"{kosten(30):,.0f} EUR")
> ```
>
> **Ausgabe:**
>
> ```
> Optimum: 38 Stueck, 1,270 EUR
> Mittlerer Bedarf 30: 3,306 EUR
> ```
**Die naheliegende Antwort — „im Mittel brauchen wir 30, also bestellen wir 30“ — kostet
das 2,6-Fache.** 3 306 € statt 1 270 €, ein Aufschlag von 160 %.
Der Grund liegt in der **Asymmetrie**: Ein Teil zu viel kostet 120 €, ein Teil zu wenig
kostet 1 400 €. Beides ist nicht gleich schlimm, also darf man auch nicht in der Mitte
landen. Man muss sich bewusst auf die günstigere Seite des Fehlers stellen.
Wie weit? Dafür gibt es eine geschlossene Formel — das **kritische Verhältnis**:
$$
q^{*} = \frac{c_{\text{zu wenig}}}{c_{\text{zu wenig}} + c_{\text{zu viel}}}
= \frac{1400}{1400 + 120} = 0{,}921
$$
> **🔤 Formel-Übersetzer**
>
> | Mathematik | Alltagssprache |
> | --- | --- |
> | $c_{\text{zu wenig}}$ | „Was kostet mich ein Stück, das ich gebraucht hätte und nicht habe?“ |
> | $c_{\text{zu viel}}$ | „Was kostet mich ein Stück, das ich habe und nicht brauche?“ |
> | $q^{*} = 0{,}921$ | „Bestelle so viel, dass der Bedarf in **92,1 %** aller Winter gedeckt ist.“ |
> | Bestellmenge $= F^{-1}(q^{*})$ | „Nimm das 92,1-%-Quantil der Bedarfsverteilung“ — hier 38 Stück. |
>
> **In einem Satz:** *Je teurer der Engpass gegenüber dem Überbestand, desto weiter über den
> Mittelwert hinaus muss man bestellen.*
Das Quantil zu 0,921 liegt bei genau **38 Stück** — dasselbe Ergebnis, das die Simulation
oben gefunden hat. Zwei völlig verschiedene Wege, dieselbe Zahl.
> **🎯 Merksatz**
> Die optimale Entscheidung unter Unsicherheit ist fast nie die Entscheidung, die für den
> Mittelwert optimal wäre. Sie hängt davon ab, **welcher der beiden Fehler teurer ist**
> und verschiebt sich zu der Seite, auf der Irren billiger ist. Wer mit dem Mittelwert
> plant, hat diese Frage nie gestellt.
Dieses Muster heißt **Newsvendor-Problem**{idx:Newsvendor-Problem} (deutsch:
Zeitungsjungen-Problem) und taucht überall dort auf, wo einmal entschieden und danach
beobachtet wird: Ersatzteile, Frischwaren, Saisonware, Personalreserve, Kraftwerksvorhaltung.
Der Rest dieses Kapitels verallgemeinert es — von einer Zahl auf ganze Modelle, und von
einer bekannten Verteilung auf den Fall, dass man nicht einmal die kennt.
---
## Lernziele {#sec:unsicherheit-lernziele}
Nach diesem Kapitel können Sie …
1. … den **Fluch des Durchschnitts**{idx:Fluch des Durchschnitts} (*Flaw of Averages*) an einem Beispiel erklären.
2. … ein **zweistufiges stochastisches Programm** mit Recourse formulieren.
3. … Monte-Carlo einsetzen, um Verteilungen statt Punktschätzungen zu bewerten.
4. … ein **robustes** Modell mit Unsicherheitsmenge aufstellen und seinen Preis beziffern.
5. … begründen, welcher der drei Ansätze für ein gegebenes Problem passt.
6. … das **kritische Verhältnis** eines Newsvendor-Problems aufstellen und daraus die
optimale Bestellmenge als Quantil ablesen.
7. … eine Terminzusage als Quantil formulieren statt als Mittelwert.
---
## Der Fluch des Durchschnitts {#sec:unsicherheit-der-fluch-des-durchschnitts}
Der naheliegende Umgang mit Unsicherheit lautet: „Wir setzen den Erwartungswert ein und
rechnen deterministisch.“ Das ist **fast immer falsch** — und zwar nicht ungenau, sondern
systematisch verzerrt.
> **✏️ Handrechnung: Warum der Mittelwert in die Irre führt**
>
> Ein Betreiber braucht Serverkapazität. Der Bedarf ist:
>
> | Szenario | Wahrscheinlichkeit | Bedarf |
> | --- | --- | --- |
> | Ruhig | 50 % | 100 |
> | Volatil | 30 % | 250 |
> | Crash | 20 % | 500 |
>
> **Erwartungswert:** $0{,}5\cdot100 + 0{,}3\cdot250 + 0{,}2\cdot500 = 50+75+100 = 225$.
>
> Kosten: Vorabkauf 40 €/Einheit; fehlende Kapazität muss kurzfristig für 120 € zugekauft
> werden; überschüssige Kapazität kostet 5 € Verwaltung.
>
> **Naive Planung mit $x = 225$:**
> * Ruhig (Bedarf 100): 125 **zu viel** → $40\cdot225 + 5\cdot125 = 9000 + 625 = 9625$
> * Volatil (250): 25 **zu wenig** → $9000 + 120\cdot25 = 9000 + 3000 = 12\,000$
> * Crash (500): 275 zu wenig → $9000 + 120\cdot275 = 9000 + 33\,000 = 42\,000$
> * **Erwartete Kosten:** $0{,}5\cdot9625 + 0{,}3\cdot12\,000 + 0{,}2\cdot42\,000
> = 4812{,}5 + 3600 + 8400 = \mathbf{16\,812{,}50}$
>
> **Stochastisch optimale Planung mit $x = 250$:**
> * Ruhig: 150 zu viel → $10\,000 + 750 = 10\,750$
> * Volatil: passt genau → $10\,000$
> * Crash: 250 zu wenig → $10\,000 + 30\,000 = 40\,000$
> * **Erwartete Kosten:** $0{,}5\cdot10\,750 + 0{,}3\cdot10\,000 + 0{,}2\cdot40\,000
> = 5375 + 3000 + 8000 = \mathbf{16\,375{,}00}$
>
> **Die naive Planung kostet 437,50 € mehr — rund 2,7 % Aufschlag** für das Einsetzen eines
> Mittelwerts, den es als Szenario gar nicht gibt. Beachten Sie: Der Aufschlag wirkt klein,
> weil bei nur drei Szenarien 225 und 250 nahe beieinanderliegen. Entscheidend ist die
> **Richtung**: Das Optimum liegt systematisch **über** dem Mittelwert, und zwar bei genau
> jenem Szenariowert, an dem die Kostenbalance kippt.
Dass $x = 250$ tatsächlich das Minimum ist und nicht nur eine plausible Vermutung, zeigt
ein vollständiger Scan über alle möglichen Kapazitäten:
```python
#!/usr/bin/env python3
# Fluch_des_Durchschnitts.py
"""
Kapitel Unsicherheit: Der Fluch des Durchschnitts (die Handrechnung dazu) als Scan ueber alle
moeglichen Kapazitaeten - zeigt, dass das Optimum nicht beim Mittelwert liegt.
"""
import numpy as np
SZENARIEN = np.array([100, 250, 500])
WAHRSCHEINLICHKEITEN = np.array([0.5, 0.3, 0.2])
PREIS_VORAB = 40.0
PREIS_ZUKAUF = 120.0
PREIS_VERWALTUNG = 5.0
ERWARTUNGSWERT = float(SZENARIEN @ WAHRSCHEINLICHKEITEN)
def erwartete_kosten(x):
kosten = np.where(
SZENARIEN >= x,
PREIS_VORAB * x + PREIS_ZUKAUF * (SZENARIEN - x),
PREIS_VORAB * x + PREIS_VERWALTUNG * (x - SZENARIEN),
)
return float(kosten @ WAHRSCHEINLICHKEITEN)
if __name__ == "__main__":
print("=" * 70)
print(" DER FLUCH DES DURCHSCHNITTS")
print("=" * 70)
print(f"Erwarteter Bedarf (Mittelwert): {ERWARTUNGSWERT:.1f}")
print(f"Kosten bei naiver Planung x={ERWARTUNGSWERT:.0f}: "
f"{erwartete_kosten(ERWARTUNGSWERT):,.2f} EUR\n")
x_werte = np.arange(0, 501, 1)
kosten_werte = np.array([erwartete_kosten(x) for x in x_werte])
x_optimal = x_werte[np.argmin(kosten_werte)]
kosten_optimal = kosten_werte.min()
print(f"Optimales x (durch Scan gefunden): {x_optimal}")
print(f"Kosten beim Optimum: {kosten_optimal:,.2f} EUR")
print(f"Ersparnis gegenueber naiver Planung: "
f"{erwartete_kosten(ERWARTUNGSWERT) - kosten_optimal:,.2f} EUR\n")
print("Kosten fuer ausgewaehlte x zum Vergleich:")
for x in [100, 225, 250, 300, 500]:
markierung = " <- Mittelwert" if x == 225 else (" <- Optimum" if x == 250 else "")
print(f" x={x:4d}: {erwartete_kosten(x):>12,.2f} EUR{markierung}")
print("\nDas Optimum liegt exakt auf einem Szenariowert (250 = 'Volatil'),")
print("nicht beim Mittelwert 225 - typisch fuer asymmetrische Kostenfunktionen.")
```
**Erwartete Ausgabe:**
```
======================================================================
DER FLUCH DES DURCHSCHNITTS
======================================================================
Erwarteter Bedarf (Mittelwert): 225.0
Kosten bei naiver Planung x=225: 16,812.50 EUR
Optimales x (durch Scan gefunden): 250
Kosten beim Optimum: 16,375.00 EUR
Ersparnis gegenueber naiver Planung: 437.50 EUR
Kosten fuer ausgewaehlte x zum Vergleich:
x= 100: 19,000.00 EUR
x= 225: 16,812.50 EUR <- Mittelwert
x= 250: 16,375.00 EUR <- Optimum
x= 300: 17,375.00 EUR
x= 500: 21,375.00 EUR
Das Optimum liegt exakt auf einem Szenariowert (250 = 'Volatil'),
nicht beim Mittelwert 225 - typisch fuer asymmetrische Kostenfunktionen.
```
Kein Rechentrick, sondern brute-force über alle ganzzahligen $x$ von 0 bis 500 — das
Optimum landet exakt dort, wo es die Handrechnung vorhersagt.
**Warum?** Die Kostenfunktion ist **asymmetrisch**: Unterdeckung kostet 120, Überdeckung
nur 5. Bei asymmetrischen Kosten liegt das Optimum nie beim Mittelwert, sondern
verschoben in die „billigere“ Richtung. Formal ist das die **Jensensche Ungleichung**{idx:Jensensche Ungleichung}:
Für konvexe Kostenfunktionen gilt $\mathbb{E}[f(X)] \ge f(\mathbb{E}[X])$ — der
Erwartungswert der Kosten ist größer als die Kosten des Erwartungswerts.
> **🎯 Merksatz**
> Der Durchschnittskunde kauft nie. Das Durchschnittsszenario tritt nie ein. Wer mit
> Mittelwerten plant, optimiert eine Welt, die es nicht gibt.
### Die drei Ansätze im Überblick
![Ansätze für Optimierung unter Unsicherheit](bilder_04/kap09_unsicherheit_ansaetze.svg)
| | **Monte-Carlo** | **Stochastische Programmierung** | **Robuste Optimierung** |
| --- | --- | --- | --- |
| Braucht | Verteilung | Szenarien **mit Wahrscheinlichkeiten** | nur eine **Menge** möglicher Werte |
| Optimiert | nichts (bewertet nur) | **Erwartungswert** über Szenarien | **Worst Case** in der Menge |
| Typische Frage | „Wie riskant ist dieser Plan?“ | „Was ist im Mittel am besten?“ | „Was hält auch im schlimmsten Fall?“ |
| Stärke | beliebige Kennzahlen, sehr flexibel | nutzt Wahrscheinlichkeiten voll aus | keine Verteilungsannahme nötig |
| Schwäche | keine Optimierung | Wahrscheinlichkeiten müssen stimmen | oft übervorsichtig, kostet Ertrag |
| Kapitel | 9.2 | 9.3 | 9.4 |
---
## Monte-Carlo-Simulation {#sec:unsicherheit-monte-carlo-simulation}
**Monte-Carlo**{idx:Monte-Carlo-Simulation} heißt schlicht: Ziehe sehr viele Zufallsszenarien, werte für jedes den Plan
aus, und betrachte die **Verteilung** der Ergebnisse statt eines einzigen Werts.
```python
#!/usr/bin/env python3
# Monte_Carlo.py
"""
Kapitel Unsicherheit: Monte-Carlo-Bewertung von Kapazitaetsplaenen.
Monte Carlo OPTIMIERT nicht - es BEWERTET. Der Nutzen liegt darin, dass man
beliebige Kennzahlen ablesen kann: Erwartungswert, Quantile, Ausfallwahr-
scheinlichkeit, Worst Case. Genau diese Groessen braucht man, um zwischen
Plaenen zu entscheiden.
"""
import numpy as np
KOSTEN_VORAB = 40.0 # EUR je Einheit, im Voraus gekauft
KOSTEN_SPOT = 120.0 # EUR je Einheit, kurzfristig zugekauft
KOSTEN_LEERLAUF = 5.0 # EUR je ungenutzter Einheit
ANZAHL_ZIEHUNGEN = 100_000
def ziehe_bedarf(rng, ziehungen):
"""
Bedarfsmodell: Mischverteilung aus Normalbetrieb und seltenen Lastspitzen.
Realistischer als eine reine Normalverteilung - Krisen sind selten,
aber extrem (fat tail).
"""
normal = rng.normal(loc=150, scale=40, size=ziehungen)
spitze = rng.normal(loc=450, scale=80, size=ziehungen)
ist_spitze = rng.random(ziehungen) < 0.15 # 15 % Lastspitzen
return np.maximum(np.where(ist_spitze, spitze, normal), 0.0)
def kosten_fuer(kapazitaet, bedarf):
"""Gesamtkosten je Szenario fuer eine gegebene Vorabkapazitaet."""
unterdeckung = np.maximum(bedarf - kapazitaet, 0.0)
ueberdeckung = np.maximum(kapazitaet - bedarf, 0.0)
return (KOSTEN_VORAB * kapazitaet
+ KOSTEN_SPOT * unterdeckung
+ KOSTEN_LEERLAUF * ueberdeckung)
if __name__ == "__main__":
rng = np.random.default_rng(2026)
bedarf = ziehe_bedarf(rng, ANZAHL_ZIEHUNGEN)
print("=" * 88)
print(f" MONTE-CARLO-BEWERTUNG ({ANZAHL_ZIEHUNGEN:,} Szenarien)")
print("=" * 88)
print(f"Bedarfsverteilung: Mittelwert {bedarf.mean():.1f} | "
f"Median {np.median(bedarf):.1f} | "
f"95%-Quantil {np.percentile(bedarf, 95):.1f} | "
f"Maximum {bedarf.max():.1f}")
print("Der Median liegt deutlich unter dem Mittelwert - die Verteilung ist")
print("rechtsschief. Genau hier fuehrt Planung mit dem Mittelwert in die Irre.\n")
print(f"{'Kapazitaet':>10} | {'Erw. Kosten':>12} | {'Median':>10} | "
f"{'95%-Quantil':>12} | {'Unterdeckung':>12}")
print("-" * 88)
kandidaten = [150, 200, 225, 250, 300, 350, 400]
ergebnisse = []
for kapazitaet in kandidaten:
kosten = kosten_fuer(kapazitaet, bedarf)
p_unterdeckung = float(np.mean(bedarf > kapazitaet))
ergebnisse.append((kapazitaet, kosten.mean(), p_unterdeckung))
print(f"{kapazitaet:>10} | {kosten.mean():>12,.0f} | "
f"{np.median(kosten):>10,.0f} | {np.percentile(kosten, 95):>12,.0f} | "
f"{p_unterdeckung*100:>11.1f} %")
beste = min(ergebnisse, key=lambda t: t[1])
print("-" * 88)
print(f"Bester Kandidat: Kapazitaet {beste[0]} mit erwarteten Kosten "
f"{beste[1]:,.0f} EUR")
# --- Feinsuche ueber ein Raster ---------------------------------------
raster = np.arange(100, 500, 5)
erwartete = np.array([kosten_fuer(k, bedarf).mean() for k in raster])
optimum = raster[int(np.argmin(erwartete))]
print(f"Feinsuche (Raster 100..500): Optimum bei Kapazitaet {optimum}, "
f"Kosten {erwartete.min():,.0f} EUR")
# --- Vergleich mit der naiven Mittelwertplanung ----------------------
naiv = int(round(bedarf.mean()))
kosten_naiv = kosten_fuer(naiv, bedarf).mean()
kosten_opt = kosten_fuer(optimum, bedarf).mean()
print("\n--- Fluch des Durchschnitts, gemessen ---")
print(f" Planung mit Mittelwert ({naiv}): {kosten_naiv:,.0f} EUR")
print(f" Monte-Carlo-Optimum ({optimum}): {kosten_opt:,.0f} EUR")
print(f" Mehrkosten der naiven Planung: {kosten_naiv - kosten_opt:,.0f} EUR "
f"({(kosten_naiv/kosten_opt - 1)*100:.1f} %)")
print("=" * 88)
```
**Erwartete Ausgabe (gekürzt):**
```
========================================================================================
MONTE-CARLO-BEWERTUNG (100,000 Szenarien)
========================================================================================
Bedarfsverteilung: Mittelwert 195.7 | Median 159.2 | 95%-Quantil 485.2 | Maximum 753.3
Der Median liegt deutlich unter dem Mittelwert - die Verteilung ist
rechtsschief. Genau hier fuehrt Planung mit dem Mittelwert in die Irre.
Kapazitaet | Erw. Kosten | Median | 95%-Quantil | Unterdeckung
----------------------------------------------------------------------------------------
150 | 13,169 | 7,104 | 46,220 | 57.8 %
200 | 12,989 | 8,345 | 42,220 | 24.1 %
225 | 13,475 | 9,432 | 40,220 | 17.8 %
250 | 14,085 | 10,546 | 38,220 | 15.6 %
300 | 15,391 | 12,791 | 34,220 | 14.8 %
350 | 16,746 | 15,033 | 30,220 | 13.6 %
400 | 18,214 | 17,266 | 26,220 | 11.1 %
----------------------------------------------------------------------------------------
Bester Kandidat: Kapazitaet 200 mit erwarteten Kosten 12,989 EUR
Feinsuche (Raster 100..500): Optimum bei Kapazitaet 180, Kosten 12,810 EUR
--- Fluch des Durchschnitts, gemessen ---
Planung mit Mittelwert (196): 12,934 EUR
Monte-Carlo-Optimum (180): 12,810 EUR
Mehrkosten der naiven Planung: 124 EUR (1.0 %)
========================================================================================
```
> **🎯 Eine wichtige Einschränkung — und was man daraus lernt**
>
> Hier kostet die naive Mittelwertplanung nur **1 %**. In der Handrechnung zum Fluch des Durchschnitts waren es 2,7 %,
> in Lehrbüchern liest man von 20 % und mehr. **Wie groß der Effekt ist, hängt von der
> Verteilung ab** — und das ist selbst die Lektion.
>
> Bei dieser Mischverteilung liegt zwischen dem Normalbetrieb (um 150) und den Lastspitzen
> (um 450) ein Bereich, in dem kaum Wahrscheinlichkeitsmasse liegt. Zusätzliche Kapazität
> zwischen 200 und 400 nützt daher wenig: Sie ist im Normalfall Leerlauf und reicht im
> Krisenfall trotzdem nicht. Deshalb ist die Kostenkurve dort flach, und die Wahl zwischen
> 180 und 196 macht kaum einen Unterschied.
>
> **Was Sie mitnehmen sollten:** Die Frage „Wie teuer ist Mittelwertplanung?“ hat keine
> allgemeine Antwort — **rechnen Sie es aus.** Genau dafür ist Monte-Carlo da. Und beachten
> Sie die Spalte „Unterdeckung“: Bei Kapazität 200 wird in **24 % der Fälle** nachgekauft.
> Ob das akzeptabel ist, entscheidet kein Optimierer, sondern ein Servicelevel-Ziel
> (Übung 9.5).
> **💡 Wann Monte-Carlo das richtige Werkzeug ist**
> Immer dann, wenn Sie **eine Kennzahl brauchen, die keine Formel hat**: die
> Wahrscheinlichkeit, ein Servicelevel zu verfehlen; der erwartete Verlust in den
> schlechtesten 5 % der Fälle; die Verteilung der Projektdauer. Der Preis ist, dass
> Monte-Carlo nur **bewertet** — die Optimierung müssen Sie durch Rastersuche oder ein
> eigenes Modell ergänzen.
---
## Zweistufige stochastische Programmierung {#sec:unsicherheit-zweistufige-stochastische-programmierung}
Das **zweistufige Modell mit Recourse**{idx:Stochastische Programmierung} bildet ab, dass Entscheidungen zeitlich gestaffelt
sind:
* **Stufe 1 (*here and now*, $\mathbf{x}$):** muss **jetzt** getroffen werden, bevor der
Zufall sich zeigt — Serverkapazität kaufen, Produktion planen, Portfolio aufsetzen.
* **Stufe 2 (*wait and see*, $\mathbf{y}_s$):** Korrekturmaßnahmen, **nachdem** Szenario $s$
eingetreten ist — Spot-Zukauf, Überstunden, Notverkauf.
$$
\min_{\mathbf{x}} \quad \mathbf{c}^\top\mathbf{x} \;+\; \sum_{s=1}^S p_s\,\mathbf{q}_s^\top\mathbf{y}_s
$$
$$
\begin{aligned}
\mathbf{A}\mathbf{x} &\le \mathbf{b} && \text{(Stufe-1-Restriktionen)}\\
\mathbf{T}_s\mathbf{x} + \mathbf{W}_s\mathbf{y}_s &\le \mathbf{h}_s \quad \forall s
&& \text{(Kopplung je Szenario)}\\
\mathbf{x} \ge \mathbf{0},\ \mathbf{y}_s &\ge \mathbf{0} \quad \forall s
\end{aligned}
$$
> **📐 Formel-Lesehilfe**
> * $\mathbf{c}^\top\mathbf{x}$ — die Kosten der Vorabentscheidung. Sie fallen **immer** an.
> * $p_s$ — Wahrscheinlichkeit von Szenario $s$; $\sum_s p_s = 1$.
> * $\mathbf{q}_s^\top\mathbf{y}_s$ — die Korrekturkosten **in** Szenario $s$.
> * $\mathbf{T}_s\mathbf{x} + \mathbf{W}_s\mathbf{y}_s \le \mathbf{h}_s$ — die
> **Kopplungsbedingung**: Sie verbindet die Vorabentscheidung mit dem, was danach möglich
> ist. Genau hier steckt die Modellierungsarbeit.
>
> **Ohne Formel gesagt:** „Entscheide jetzt so, dass die Summe aus heutigen Kosten und
> **durchschnittlich zu erwartenden** Nachbesserungskosten minimal wird.“
>
> **Der entscheidende Punkt:** Es gibt **ein** $\mathbf{x}$, aber **je Szenario ein eigenes**
> $\mathbf{y}_s$. Das ist keine Rechentrickserei, sondern bildet die Realität ab: Man legt
> sich einmal fest und reagiert dann unterschiedlich.
```python
#!/usr/bin/env python3
# Stochastische_Optimierung.py
"""
Kapitel Unsicherheit: Two-Stage Stochastic Programming mit CVXPY.
Eigenschaften:
* Die Analyse am Ende wird BERECHNET statt fest verdrahtet
* Vergleich gegen drei Alternativen: Mittelwert, Worst Case, perfekte Voraussicht
* Kennzahl EVPI (Wert perfekter Information) wird ausgewiesen
"""
import cvxpy as cp
import numpy as np
import pandas as pd
SZENARIEN = ["Ruhig", "Volatil", "Crash"]
WAHRSCHEINLICHKEIT = np.array([0.50, 0.30, 0.20])
BEDARF = np.array([100.0, 250.0, 500.0])
KOSTEN_VORAB = 40.0
KOSTEN_SPOT = 120.0
KOSTEN_LEERLAUF = 5.0
S = len(SZENARIEN)
def loese_stochastisch():
"""Zweistufiges Modell: eine Vorabentscheidung, szenarioabhaengige Korrektur."""
x = cp.Variable(nonneg=True, name="Basiskapazitaet") # Stufe 1
y_spot = cp.Variable(S, nonneg=True, name="Spot_Zukauf") # Stufe 2
y_leer = cp.Variable(S, nonneg=True, name="Leerlauf") # Stufe 2
# Kopplung: Basis + Zukauf - Leerlauf == Bedarf (je Szenario)
nebenbedingungen = [x + y_spot[s] - y_leer[s] == BEDARF[s] for s in range(S)]
erwartete_korrektur = sum(
WAHRSCHEINLICHKEIT[s] * (KOSTEN_SPOT * y_spot[s] + KOSTEN_LEERLAUF * y_leer[s])
for s in range(S))
problem = cp.Problem(cp.Minimize(KOSTEN_VORAB * x + erwartete_korrektur),
nebenbedingungen)
problem.solve()
return problem, x, y_spot, y_leer
def kosten_bei(kapazitaet):
"""Erwartete Gesamtkosten fuer eine fest vorgegebene Kapazitaet."""
unter = np.maximum(BEDARF - kapazitaet, 0.0)
ueber = np.maximum(kapazitaet - BEDARF, 0.0)
je_szenario = KOSTEN_VORAB * kapazitaet + KOSTEN_SPOT * unter + KOSTEN_LEERLAUF * ueber
return float(WAHRSCHEINLICHKEIT @ je_szenario)
if __name__ == "__main__":
problem, x, y_spot, y_leer = loese_stochastisch()
kapazitaet = float(x.value)
mittelwert = float(WAHRSCHEINLICHKEIT @ BEDARF)
print("=" * 82)
print(" STOCHASTISCHE TWO-STAGE OPTIMIERUNG (KAPAZITAETSPLANUNG)")
print("=" * 82)
print(f"Status: {problem.status}")
print(f"Erwarteter Bedarf (Mittelwert): {mittelwert:.1f} Einheiten")
print(f"Optimale Stufe-1-Kapazitaet x*: {kapazitaet:.1f} Einheiten")
print(f"Minimale erwartete Gesamtkosten: {problem.value:,.2f} EUR\n")
tabelle = pd.DataFrame({
"Szenario": SZENARIEN,
"Wahrsch.": [f"{p*100:.0f} %" for p in WAHRSCHEINLICHKEIT],
"Bedarf": BEDARF,
"Basis genutzt": [min(kapazitaet, b) for b in BEDARF],
"Spot-Zukauf": np.round(y_spot.value, 1),
"Leerlauf": np.round(y_leer.value, 1),
"Kosten (EUR)": [f"{KOSTEN_VORAB*kapazitaet + KOSTEN_SPOT*y_spot.value[s] + KOSTEN_LEERLAUF*y_leer.value[s]:,.0f}"
for s in range(S)],
})
print(tabelle.to_string(index=False))
# --- Vergleich mit Alternativstrategien (berechnet, nicht behauptet) --
print("\n" + "-" * 82)
print("Vergleich verschiedener Planungsstrategien:")
print(f"{'Strategie':<34} {'Kapazitaet':>11} {'Erw. Kosten':>14} {'Mehrkosten':>13}")
print("-" * 82)
optimal = problem.value
strategien = [
("Stochastisch optimal", kapazitaet),
("Naiv: Mittelwert einsetzen", mittelwert),
("Vorsichtig: Worst Case abdecken", float(BEDARF.max())),
("Optimistisch: Bestfall", float(BEDARF.min())),
]
for name, kap in strategien:
kosten = kosten_bei(kap)
print(f"{name:<34} {kap:>11.1f} {kosten:>14,.0f} "
f"{kosten - optimal:>+13,.0f}")
# --- EVPI: Was waere perfekte Voraussicht wert? ----------------------
# Bei perfekter Information wuerde man je Szenario genau den Bedarf kaufen.
kosten_perfekt = float(WAHRSCHEINLICHKEIT @ (KOSTEN_VORAB * BEDARF))
evpi = optimal - kosten_perfekt
print("-" * 82)
print(f"Kosten bei perfekter Voraussicht: {kosten_perfekt:>10,.0f} EUR")
print(f"Wert perfekter Information (EVPI): {evpi:>10,.0f} EUR "
f"({evpi/optimal*100:.1f} % der Kosten)")
print(" -> So viel duerfte eine perfekte Bedarfsprognose hoechstens kosten.")
# --- Automatische Interpretation --------------------------------------
print("-" * 82)
if kapazitaet > mittelwert + 1e-6:
print(f"Analyse: Der Solver waehlt {kapazitaet:.0f} Einheiten und damit MEHR als")
print(f"den Mittelwert ({mittelwert:.0f}), weil Unterdeckung ({KOSTEN_SPOT:.0f} EUR)")
print(f"deutlich teurer ist als Leerlauf ({KOSTEN_LEERLAUF:.0f} EUR).")
elif kapazitaet < mittelwert - 1e-6:
print(f"Analyse: Der Solver waehlt {kapazitaet:.0f} und damit WENIGER als den")
print(f"Mittelwert ({mittelwert:.0f}) - Leerlauf ist hier teurer als Zukauf.")
else:
print("Analyse: Kapazitaet entspricht dem Mittelwert (symmetrische Kosten).")
print("=" * 82)
```
**Erwartete Ausgabe:**
```
==================================================================================
STOCHASTISCHE TWO-STAGE OPTIMIERUNG (KAPAZITAETSPLANUNG)
==================================================================================
Status: optimal
Erwarteter Bedarf (Mittelwert): 225.0 Einheiten
Optimale Stufe-1-Kapazitaet x*: 250.0 Einheiten
Minimale erwartete Gesamtkosten: 16,375.00 EUR
Szenario Wahrsch. Bedarf Basis genutzt Spot-Zukauf Leerlauf Kosten (EUR)
Ruhig 50 % 100.0 100.0 0.0 150.0 10,750
Volatil 30 % 250.0 250.0 0.0 0.0 10,000
Crash 20 % 500.0 250.0 250.0 0.0 40,000
----------------------------------------------------------------------------------
Vergleich verschiedener Planungsstrategien:
Strategie Kapazitaet Erw. Kosten Mehrkosten
----------------------------------------------------------------------------------
Stochastisch optimal 250.0 16,375 -0
Naiv: Mittelwert einsetzen 225.0 16,813 +437
Vorsichtig: Worst Case abdecken 500.0 21,375 +5,000
Optimistisch: Bestfall 100.0 19,000 +2,625
----------------------------------------------------------------------------------
Kosten bei perfekter Voraussicht: 9,000 EUR
Wert perfekter Information (EVPI): 7,375 EUR (45.0 % der Kosten)
-> So viel duerfte eine perfekte Bedarfsprognose hoechstens kosten.
----------------------------------------------------------------------------------
Analyse: Der Solver waehlt 250 Einheiten und damit MEHR als
den Mittelwert (225), weil Unterdeckung (120 EUR)
deutlich teurer ist als Leerlauf (5 EUR).
==================================================================================
```
**Lesen Sie die Vergleichstabelle von unten nach oben.** Die beiden extremen Haltungen sind
die teuersten: Wer den Worst Case abdeckt (500 Einheiten), zahlt 5 000 € zu viel für
Kapazität, die in 80 % der Fälle brachliegt. Wer optimistisch plant (100), zahlt 2 625 €
Strafe für ständige Notzukäufe. Die naive Mittelwertplanung liegt dazwischen — aber eben
auch nicht optimal.
Der **EVPI von 7 375 €** ist bemerkenswert hoch: 45 % der Gesamtkosten entstehen allein
daraus, dass man die Zukunft nicht kennt. In so einem Fall lohnt sich Investition in bessere
Prognosen tatsächlich — anders als in dem Beispiel aus Übung 9.4.
> **💡 Was ist der EVPI?**
> Der **Expected Value of Perfect Information**{idx:EVPI} beziffert, wie viel eine perfekte Prognose
> wert wäre: die Differenz zwischen den Kosten unter Unsicherheit und den Kosten bei
> vollständigem Wissen. Er ist eine **Obergrenze für jedes Prognoseprojekt**. Wenn der EVPI
> bei 12 000 € pro Jahr liegt, lohnt sich keine Prognosesoftware für 50 000 € — selbst wenn
> sie perfekt wäre. Diese Zahl bewahrt Projekte vor teuren Fehlinvestitionen.
---
## Robuste Optimierung: gegen den Worst Case absichern {#sec:unsicherheit-robuste-optimierung-gegen-den-worst-case}
Wenn Wahrscheinlichkeiten unbekannt oder instabil sind — Marktcrashs, Lieferkettenabrisse,
Pandemien —, hilft die **robuste Optimierung**{idx:Robuste Optimierung}. Sie fragt nicht nach dem Mittel, sondern
nach dem Schlimmsten:
$$
\min_{\mathbf{x}\in\mathcal{X}}\ \max_{\mathbf{u}\in\mathcal{U}}\ f(\mathbf{x},\mathbf{u})
$$
> **📐 Formel-Lesehilfe**
> Zwei ineinandergeschachtelte Optimierungen:
> * Das **innere** $\max$ ist der „Gegner“: Er wählt aus der Unsicherheitsmenge
> $\mathcal{U}$ die für Sie ungünstigsten Parameter.
> * Das **äußere** $\min$ sind Sie: Sie wählen $\mathbf{x}$ so, dass Sie selbst gegen diesen
> Gegner am besten dastehen.
>
> **Ohne Formel gesagt:** „Entscheide so, dass du auch dann noch gut dastehst, wenn alles
> gegen dich läuft.“ Das ist Spieltheorie gegen die Natur.
**Der Trick, der es lösbar macht.** Ein Min-Max-Problem klingt unlösbar — man müsste ja
unendlich viele Parameterkombinationen prüfen. Für einfache Unsicherheitsmengen lässt sich
das innere Maximum aber **geschlossen ausrechnen**. Beispiel Box-Unsicherheit: Die Renditen
liegen in Intervallen $\mu_i \in [\hat\mu_i - \delta_i,\ \hat\mu_i + \delta_i]$. Dann gilt
für $\mathbf{w} \ge 0$:
$$
\min_{\boldsymbol{\mu}\in\mathcal{U}} \boldsymbol{\mu}^\top\mathbf{w}
= \sum_i (\hat\mu_i - \delta_i)w_i
= \hat{\boldsymbol{\mu}}^\top\mathbf{w} - \boldsymbol{\delta}^\top\mathbf{w}
$$
Das Min-Max-Problem wird damit zu einem **gewöhnlichen** Optimierungsproblem mit einem
Abzugsterm. Genau das macht robuste Optimierung praktikabel.
```python
#!/usr/bin/env python3
# Robuste_Optimierung.py
"""
Kapitel Unsicherheit: Robuste Portfolio-Optimierung.
Vergleicht drei Haltungen zur Unsicherheit:
(a) nominal - vertraut den Punktschaetzungen blind
(b) robust - sichert gegen Box-Unsicherheit ab (Worst Case)
(c) stochastisch - optimiert den Erwartungswert ueber Szenarien
und misst den "Preis der Robustheit": Wie viel Ertrag kostet die Absicherung
im Normalfall - und wie viel Verlust erspart sie im Ernstfall?
"""
import cvxpy as cp
import numpy as np
ASSETS = ["Aktien Welt", "Anleihen", "Rohstoffe", "Immobilien"]
MU_SCHAETZUNG = np.array([0.085, 0.030, 0.055, 0.060]) # Punktschaetzung
UNSICHERHEIT = np.array([0.040, 0.008, 0.045, 0.025]) # +/- delta je Titel
VOLA = np.array([0.17, 0.05, 0.22, 0.12])
KORR = np.array([
[1.00, -0.15, 0.35, 0.55],
[-0.15, 1.00, -0.05, 0.10],
[0.35, -0.05, 1.00, 0.25],
[0.55, 0.10, 0.25, 1.00],
])
SIGMA = np.diag(VOLA) @ KORR @ np.diag(VOLA)
LAMBDA = 4.0 # Risikoaversion
N = len(ASSETS)
def optimiere(mu_effektiv):
"""Standard-Mean-Variance mit vorgegebenem Renditevektor."""
w = cp.Variable(N, nonneg=True)
ziel = cp.Maximize(mu_effektiv @ w - 0.5 * LAMBDA * cp.quad_form(w, SIGMA))
problem = cp.Problem(ziel, [cp.sum(w) == 1])
problem.solve()
return w.value
def kennzahlen(w, mu):
ertrag = float(mu @ w)
risiko = float(np.sqrt(w @ SIGMA @ w))
return ertrag, risiko
if __name__ == "__main__":
print("=" * 88)
print(" ROBUSTE vs. NOMINALE PORTFOLIO-OPTIMIERUNG")
print("=" * 88)
print(f"{'Asset':<14} {'Erw. Rendite':>14} {'Unsicherheit':>14} "
f"{'Worst Case':>12} {'Volatilitaet':>13}")
print("-" * 88)
for i, name in enumerate(ASSETS):
print(f"{name:<14} {MU_SCHAETZUNG[i]*100:>13.1f} % "
f"{'+/- ' + format(UNSICHERHEIT[i]*100, '.1f') + ' %':>14} "
f"{(MU_SCHAETZUNG[i]-UNSICHERHEIT[i])*100:>11.1f} % "
f"{VOLA[i]*100:>12.1f} %")
# (a) nominal: vertraut den Schaetzungen
w_nominal = optimiere(MU_SCHAETZUNG)
# (b) robust: rechnet mit dem Worst Case der Box-Unsicherheitsmenge
w_robust = optimiere(MU_SCHAETZUNG - UNSICHERHEIT)
print("\n" + "-" * 88)
print(f"{'':<14} {'nominal':>22} {'robust':>22}")
print("-" * 88)
for i, name in enumerate(ASSETS):
print(f"{name:<14} {w_nominal[i]*100:>21.1f} % {w_robust[i]*100:>21.1f} %")
# --- Bewertung in beiden Welten --------------------------------------
mu_worst = MU_SCHAETZUNG - UNSICHERHEIT
e_nom_gut, r_nom = kennzahlen(w_nominal, MU_SCHAETZUNG)
e_rob_gut, r_rob = kennzahlen(w_robust, MU_SCHAETZUNG)
e_nom_schlecht, _ = kennzahlen(w_nominal, mu_worst)
e_rob_schlecht, _ = kennzahlen(w_robust, mu_worst)
print("\n" + "-" * 88)
print(f"{'Bewertung':<34} {'nominales Portfolio':>22} {'robustes Portfolio':>22}")
print("-" * 88)
print(f"{'Ertrag, wenn Schaetzung stimmt':<34} {e_nom_gut*100:>21.2f} % "
f"{e_rob_gut*100:>21.2f} %")
print(f"{'Ertrag im Worst Case':<34} {e_nom_schlecht*100:>21.2f} % "
f"{e_rob_schlecht*100:>21.2f} %")
print(f"{'Volatilitaet':<34} {r_nom*100:>21.2f} % {r_rob*100:>21.2f} %")
print("\n" + "-" * 88)
print(f"Preis der Robustheit (Ertragsverzicht im Normalfall): "
f"{(e_nom_gut - e_rob_gut)*100:+.2f} Prozentpunkte")
print(f"Nutzen der Robustheit (Vorteil im Worst Case): "
f"{(e_rob_schlecht - e_nom_schlecht)*100:+.2f} Prozentpunkte")
verhaeltnis = ((e_rob_schlecht - e_nom_schlecht)
/ max(e_nom_gut - e_rob_gut, 1e-9))
print(f"Verhaeltnis Nutzen/Preis: {verhaeltnis:.2f}")
print(" -> Werte > 1 bedeuten: Die Absicherung bringt im Ernstfall mehr,")
print(" als sie im Normalfall kostet.")
print("=" * 88)
```
> **⚠️ Der Preis der Robustheit**
> Robuste Modelle sind **konservativ**. Wer gegen den absoluten Worst Case absichert, zahlt
> im Normalfall drauf — oft erheblich. Drei Gegenmittel:
> 1. **Unsicherheitsmenge realistisch wählen.** Nicht „alles kann passieren“, sondern
> „Abweichungen bis zu einer Standardabweichung“.
> 2. **Budgeted Uncertainty (Bertsimas/Sim)**{idx:Budgeted Uncertainty}: Man nimmt an, dass höchstens $\Gamma$ von $n$
> Parametern gleichzeitig ihren Worst Case annehmen. $\Gamma$ steuert die Vorsicht stufenlos.
> 3. **Nutzen und Preis immer beziffern** — genau wie im Programm oben. Ohne diese zwei
> Zahlen ist die Diskussion „robust oder nicht“ Glaubenssache.
---
## Übungsaufgaben {#sec:unsicherheit-uebungsaufgaben}
> Lösungen: {ref:sec:loesungen-unsicherheit}.
**Aufgabe ⭐ — Fluch des Durchschnitts erkennen.**
Ein Bauunternehmer plant mit der *durchschnittlichen* Bauzeit von 8 Monaten. Warum ist die
*erwartete* Fertigstellung trotzdem später als 8 Monate, wenn Verzögerungen wahrscheinlicher
sind als Beschleunigungen? Nennen Sie zwei weitere Alltagsbeispiele.
**Aufgabe ⭐ — Ansatz wählen.**
Welcher der drei Ansätze passt? Begründen Sie:
(a) Wie viele Notstromaggregate für ein Krankenhaus?
(b) Wie viel Weizen einkaufen bei bekannter Preisverteilung?
(c) Wie hoch ein Deich gebaut werden muss?
(d) Wie viele Saisonkräfte einstellen bei bekannten Nachfrageszenarien?
**Aufgabe ⭐⭐ — Zweistufiges Modell rechnen.**
Wiederholen Sie die Handrechnung *Warum der Mittelwert in die Irre führt* mit geänderten Kosten: Spot 60 € statt 120 €.
(a) Wie ändert sich die optimale Kapazität?
(b) Erklären Sie die Richtung der Änderung.
(c) Bei welchem Spot-Preis wäre die Mittelwert-Planung optimal?
**Aufgabe ⭐⭐ — EVPI interpretieren.**
Ihr Modell liefert EVPI = 8 400 € pro Jahr. Ein Anbieter verlangt 15 000 € jährlich für eine
Prognoselösung, die „80 % Treffsicherheit“ verspricht. Wie argumentieren Sie?
**Aufgabe ⭐⭐⭐ — Monte-Carlo erweitern.**
Erweitern Sie `Monte_Carlo.py`:
(a) Fügen Sie ein Servicelevel-Kriterium hinzu: „Die Unterdeckung darf höchstens in 5 % der
Fälle auftreten.“ Welche Kapazität ist dafür nötig, und was kostet sie zusätzlich?
(b) Berechnen Sie den CVaR der Kosten (Mittelwert der schlechtesten 5 %).
(c) Zeichnen Sie ein Histogramm der Kosten für drei Kapazitäten.
**Aufgabe ⭐⭐⭐ — Budgeted Uncertainty.**
Erweitern Sie `Robuste_Optimierung.py` um den Ansatz von Bertsimas/Sim: Höchstens
$\Gamma$ Titel nehmen gleichzeitig ihren Worst Case an. Variieren Sie
$\Gamma \in \{0, 1, 2, 3, 4\}$ und stellen Sie den Verlauf von Ertrag und Absicherung dar.
(Hinweis: Bei $\Gamma$ ganzzahlig genügt es, die $\Gamma$ größten $\delta_i w_i$ abzuziehen —
das lässt sich in CVXPY mit `cp.sum_largest` formulieren.)
---
## Finde den Denkfehler {#sec:unsicherheit-denkfehler}
> **🐛 Finde den Denkfehler 9.1: Warum jedes Projekt zu spät fertig wird**
>
> Ein Projektleiter plant eine Umbaumaßnahme. Fünf Gewerke arbeiten **parallel** und
> unabhängig voneinander; fertig ist der Umbau, wenn das letzte fertig ist. Für jedes
> Gewerk liegt eine Schätzung vor: mindestens 6 Tage, wahrscheinlich 10, im schlechtesten
> Fall 18.
>
> Er rechnet: *„Jedes Gewerk braucht im Mittel $(6 + 10 + 18)/3 = 11{,}3$ Tage. Alle laufen
> gleichzeitig. Also ist der Umbau nach 11,3 Tagen fertig — ich plane großzügig 12 ein.“*
>
> Nachgerechnet mit 200 000 simulierten Umbauten:
>
> ```python
> import numpy as np
>
> rng = np.random.default_rng(3)
> dauer = rng.triangular(6, 10, 18, size=(200_000, 5)) # 5 Gewerke, je eine Schaetzung
> projekt = dauer.max(axis=1) # fertig, wenn das LETZTE fertig ist
>
> print(f"Mittelwert einer Einzeldauer: {dauer.mean():.2f} Tage")
> print(f"Tatsaechliche Projektdauer: {projekt.mean():.2f} Tage")
> print(f"90-%-Quantil: {np.quantile(projekt, 0.9):.2f} Tage")
> print(f"Anteil ueber 11,34 Tage: {(projekt > 11.34).mean() * 100:.1f} %")
> ```
>
> **Ausgabe:**
>
> ```
> Mittelwert einer Einzeldauer: 11.34 Tage
> Tatsaechliche Projektdauer: 14.38 Tage
> 90-%-Quantil: 16.59 Tage
> Anteil ueber 11,34 Tage: 95.5 %
> ```
>
> **Ihre Aufgabe:** (a) Der Projektleiter hat richtig gerechnet — jedes Gewerk braucht
> tatsächlich im Mittel 11,34 Tage. Warum dauert das Projekt trotzdem 14,38 Tage?
> (b) Erklären Sie in einem Satz, warum ausgerechnet **95,5 %** aller Umbauten den Plan
> reißen. Was müsste gelten, damit es 50 % wären? (c) Was passiert mit der Projektdauer,
> wenn statt fünf Gewerken zehn parallel arbeiten — bei unveränderter Einzelschätzung?
> (d) Welche Zahl gehört in den Projektplan, wenn der Auftraggeber eine Terminzusage mit
> 90 % Sicherheit verlangt?
>
> *Auflösung: {ref:sec:loesungen-unsicherheit}.*
> **🎯 Merksatz**
> Der Fluch des Durchschnitts hat zwei Gesichter. Beim Ersatzteil aus
> {ref:sec:unsicherheit-schnellstart} führt der Mittelwert in die Irre, weil die **Kosten**
> asymmetrisch sind. Hier führt er in die Irre, weil die **Verknüpfung** asymmetrisch ist:
> Ein Gewerk, das früher fertig wird, hilft niemandem — ein Gewerk, das sich verspätet,
> verzögert alles. Beide Male gilt: *Der Mittelwert einer Funktion ist nicht die Funktion
> des Mittelwerts.*
---
## Micro-Quiz {#sec:unsicherheit-quiz}
> **❓ Micro-Quiz 9: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-unsicherheit).
>
> **1. Bei einem Ersatzteil kostet ein Stück zu wenig 1 400 €, ein Stück zu viel 120 €.
> Der Bedarf beträgt im Mittel 30 Stück. Wie viel bestellen Sie?**
> (a) 30 — das ist der Erwartungswert des Bedarfs.
> (b) Deutlich mehr als 30, nämlich das 92,1-%-Quantil des Bedarfs. Das kritische
> Verhältnis $1400/(1400+120)$ sagt, wie weit man sich auf die günstigere Fehlerseite
> stellen soll.
> (c) Weniger als 30, weil Lagerhaltung Kapital bindet.
>
> **2. Was unterscheidet **stochastische** von **robuster** Optimierung?**
> (a) Stochastische Optimierung ist genauer, robuste ist eine Näherung für schnelle
> Rechnungen.
> (b) Stochastische Optimierung braucht **Wahrscheinlichkeiten** und optimiert den
> Erwartungswert; robuste Optimierung braucht nur **Bandbreiten** und sichert den
> schlechtesten Fall darin ab.
> (c) Robuste Optimierung berücksichtigt mehr Szenarien.
>
> **3. Ein Kollege ersetzt in seinem Modell alle unsicheren Größen durch ihre Mittelwerte
> und rechnet deterministisch. Wann ist das unproblematisch?**
> (a) Immer — der Erwartungswert ist die beste Einzelschätzung.
> (b) Nie.
> (c) Wenn Zielfunktion und Nebenbedingungen in den unsicheren Größen **linear** sind und
> keine Entscheidung erst nach Beobachtung fällt. Sobald Minimum, Maximum, Betrag oder eine
> Nachbesserungsentscheidung im Spiel ist, gilt es nicht mehr.
---
## Selbsttest {#sec:unsicherheit-selbsttest}
> Antworten: [Anhang A](90_Anhang_Loesungen.md#selbsttest-loesung-unsicherheit).
1. Warum ist Planung mit Erwartungswerten bei asymmetrischen Kosten systematisch falsch?
2. Was unterscheidet Stufe-1- von Stufe-2-Variablen?
3. Was misst der EVPI, und wofür ist er praktisch nützlich?
4. Warum braucht robuste Optimierung keine Wahrscheinlichkeiten?
5. Wie macht man ein Min-Max-Problem mit Box-Unsicherheit lösbar?
---
## Zusammenfassung {#sec:unsicherheit-zusammenfassung}
* **Der Fluch des Durchschnitts** ist kein Randphänomen und hat zwei Gesichter. Bei
asymmetrischen **Kosten** liegt das Optimum systematisch neben dem Mittelwert — das
Newsvendor-Verhältnis $c_{-}/(c_{-}+c_{+})$ sagt, wie weit. Bei asymmetrischer
**Verknüpfung** (Maximum über parallele Vorgänge) ist der Erwartungswert des Ganzen
größer als das Ganze der Erwartungswerte. Beide Male gilt: Der Mittelwert einer Funktion
ist nicht die Funktion des Mittelwerts (Jensensche Ungleichung).
* **Eine Terminzusage ist ein Quantil, kein Mittelwert.** Wer den Erwartungswert zusagt,
reißt den Termin bei fünf parallelen Vorgängen in 95 % der Fälle.
* **Monte-Carlo** bewertet, optimiert aber nicht. Sein Wert liegt in Kennzahlen, für die es
keine Formel gibt.
* **Zweistufige stochastische Programme** trennen die Festlegung von der Reaktion — ein
$\mathbf{x}$, aber je Szenario ein $\mathbf{y}_s$.
* **Der EVPI** begrenzt, was eine perfekte Prognose wert sein darf.
* **Robuste Optimierung** braucht keine Wahrscheinlichkeiten, nur eine Unsicherheitsmenge —
und wird für einfache Mengen zu einem gewöhnlichen Problem mit Abzugsterm.
* **Beziffern Sie immer Preis und Nutzen der Absicherung.** Ohne beide Zahlen ist die
Entscheidung nicht begründbar.
**Ausblick.** {ref:kap:dynamische-programmierung} fügt die Zeitdimension hinzu: Entscheidungen, die über viele
Perioden aufeinander aufbauen — gelöst mit der Bellman-Gleichung.
---
*Weiter mit:* [{ref:kap:dynamische-programmierung} — Dynamische Programmierung](32_Dynamische_Programmierung.md)

View file

@ -0,0 +1,865 @@
# Kapitel: Dynamische Programmierung — Die Bellman-Gleichung und Order-Execution {#kap:dynamische-programmierung}
> **📌 Kapitel auf einen Blick**
>
> **Worum geht es?** Um Entscheidungsketten: Was heute klug ist, hängt davon ab, was morgen
> noch möglich sein wird. Die Bellman-Gleichung löst solche Probleme durch Rückwärtsrechnen.
>
> **Voraussetzungen:** {ref:kap:unsicherheit} (Erwartungswerte). Rekursion in Python.
>
> **Danach können Sie:** Ein mehrperiodiges Problem in Zustände und Stufen zerlegen, die
> Wertfunktion durch Rückwärtsinduktion berechnen, die optimale Strategie ableiten — und
> prüfen, ob Ihr Zustand vollständig genug ist.
>
> **Zeitbedarf:** ca. 5 Stunden.
>
> **Programme:**\
> `Bellman_Minimalbeispiel.py`\
> `Mehrperiodige_Order_Execution.py`
---
## In 5 Minuten gelöst {#sec:dynamische-programmierung-schnellstart}
> **🚀 In 5 Minuten gelöst: Der Weg, der falsch anfängt**
>
> Ein Fahrer soll vom Depot zum Kunden. Es gibt zwei Zwischenetappen, jeweils mit zwei
> Möglichkeiten. Die Zahlen sind Fahrminuten:
>
> | von | nach | Minuten | | von | nach | Minuten |
> | --- | --- | ---: | --- | --- | --- | ---: |
> | Start | **Nord** | **4** | | Süd | Ost | 3 |
> | Start | Süd | 6 | | Süd | West | 7 |
> | Nord | Ost | 14 | | Ost | Ziel | 5 |
> | Nord | West | 9 | | West | Ziel | 8 |
>
> ```python
> kanten = {
> "Start": {"Nord": 4, "Sued": 6},
> "Nord": {"Ost": 14, "West": 9},
> "Sued": {"Ost": 3, "West": 7},
> "Ost": {"Ziel": 5},
> "West": {"Ziel": 8},
> "Ziel": {},
> }
>
> wert, plan = {"Ziel": 0}, {} # Restkosten ab jedem Ort
> for ort in ["Ost", "West", "Nord", "Sued", "Start"]: # RUECKWAERTS!
> wert[ort], plan[ort] = min((k + wert[z], z) for z, k in kanten[ort].items())
>
> weg, ort = ["Start"], "Start"
> while ort != "Ziel":
> ort = plan[ort]
> weg.append(ort)
> print(" -> ".join(weg), "=", wert["Start"], "min")
> print("Restkosten je Ort:", wert)
> ```
>
> **Ausgabe:**
>
> ```
> Start -> Sued -> Ost -> Ziel = 14 min
> Restkosten je Ort: {'Ziel': 0, 'Ost': 5, 'West': 8, 'Nord': 17, 'Sued': 8, 'Start': 14}
> ```
**Der optimale Weg beginnt mit dem *teureren* ersten Abschnitt.** Start → Süd kostet 6
Minuten, Start → Nord nur 4. Wer vorne spart, landet bei 21 Minuten statt 14 — **50 %
mehr**:
| Vorgehen | Route | Dauer |
| --- | --- | ---: |
| Gierig: „nimm immer die kürzeste nächste Strecke“ | Start → Nord → West → Ziel | 21 min |
| **Rückwärtsinduktion** | Start → **Süd** → Ost → Ziel | **14 min** |
Der Grund steht in der zweiten Ausgabezeile. `Restkosten je Ort` ist die **Wertfunktion**:
Sie sagt für jeden Ort, was der ganze Rest von dort aus noch kostet, wenn man ab dort
optimal weiterfährt. Und dort steht das Entscheidende: Von Nord aus sind es noch **17**
Minuten, von Süd nur **8**. Die vier gesparten Minuten am Anfang werden später mit neun
zusätzlichen bezahlt.
> **🎯 Merksatz**
> Eine Entscheidung ist nie für sich zu bewerten, sondern nur **zusammen mit allem, was
> danach kommt**. Genau das leistet die Wertfunktion: Sie fasst die gesamte Zukunft eines
> Zustands in einer einzigen Zahl zusammen. Und weil man die Zukunft kennen muss, bevor man
> die Gegenwart bewerten kann, rechnet dynamische Programmierung **rückwärts**.
**Warum funktioniert das?** Weil man den Rest des Weges nicht neu durchdenken muss, sobald
man einmal an einem Ort steht: Wie man dorthin gekommen ist, ist für die Zukunft egal. Diese
Eigenschaft heißt **Bellmansches Optimalitätsprinzip** und ist der Grund, warum aus einem
Problem mit exponentiell vielen Wegen eine Rechnung mit wenigen Zeilen wird. Bei fünf Orten
fällt das kaum auf — bei fünfzig ist es der Unterschied zwischen Sekunden und Jahren.
---
## Lernziele {#sec:dynamische-programmierung-lernziele}
Nach diesem Kapitel können Sie …
1. … das Bellmansche Optimalitätsprinzip{idx:Optimalitätsprinzip} formulieren und erklären, warum es gilt.
2. … Zustand, Stufe, Aktion und Wertfunktion für ein gegebenes Problem benennen.
3. … eine Rückwärtsinduktion{idx:Rückwärtsinduktion} von Hand für ein kleines Problem durchführen.
4. … das Almgren-Chriss-Ausführungsproblem lösen und die Lösung interpretieren.
5. … einschätzen, wann DP funktioniert und wann der „Fluch der Dimensionalität“ zuschlägt.
6. … die Wertfunktion als **Nachschlagetabelle** lesen: Sie liefert keine Planfolge, sondern
eine Regel, die auch bei Abweichungen gilt.
7. … einen Zustandsraum auf Vollständigkeit prüfen und erkennen, wann eine Kostenart
Vorgeschichte erfordert.
---
## Das Bellmansche Optimalitätsprinzip {#sec:dynamische-programmierung-das-bellmansche-optimalitaetsprinzip}
Richard Bellman{idx:Bellman, Richard} formulierte 1957 das Grundprinzip der **Dynamischen Programmierung (DP)**{idx:Dynamische Programmierung}:
> **Optimalitätsprinzip.** Eine optimale Strategie hat die Eigenschaft, dass — unabhängig
> vom Anfangszustand und der Anfangsentscheidung — die verbleibenden Entscheidungen eine
> optimale Strategie bezüglich des Zustands bilden, der aus der ersten Entscheidung
> resultiert.
**Ohne Fachsprache:** *Jedes Teilstück einer optimalen Route ist selbst eine optimale
Route.* Wenn der beste Weg von Hamburg nach München über Kassel führt, dann ist der Teil
von Kassel nach München auch der beste Weg von Kassel nach München. Sonst könnte man ihn
ersetzen und wäre insgesamt besser.
![Sequenzielle Zustands-/Aktionskette](bilder_04/kap10_zustandskette.svg)
### Die vier Bausteine eines DP-Modells
| Baustein | Symbol | Frage | Beispiel Orderausführung |
| --- | --- | --- | --- |
| **Stufe** | $t$ | Wann wird entschieden? | Handelsperiode 0, 1, …, 5 |
| **Zustand** | $S_t$ | Was beschreibt die Lage vollständig? | Verbleibende Aktien $X_t$ |
| **Aktion** | $a_t$ | Was darf man tun? | Verkaufe $n_t$ Stück |
| **Wertfunktion** | $V_t(S_t)$ | Was kosten die optimalen Restentscheidungen? | Minimale Restkosten |
> **🎯 Die Kunst liegt in der Zustandsdefinition**
> Der Zustand muss **alles** enthalten, was für die Zukunft relevant ist — und **nichts
> mehr**. Zu wenig: Das Modell ist falsch (Markov-Eigenschaft verletzt). Zu viel: Der
> Zustandsraum explodiert. Diese Abwägung ist die eigentliche Modellierungsleistung bei DP.
### Die Bellman-Gleichung
$$
V_t(S_t) = \min_{a_t\in\mathcal{A}(S_t)}\Big\{\,C(S_t,a_t) \;+\; \gamma\cdot\mathbb{E}\big[V_{t+1}(S_{t+1})\mid S_t,a_t\big]\Big\}
$$
mit der Endbedingung $V_T(S_T) = g(S_T)$.
> **📐 Formel-Lesehilfe**
> * $V_t(S_t)$ — „Was kostet mich der Rest, wenn ich zum Zeitpunkt $t$ im Zustand $S_t$
> bin und ab jetzt optimal handle?“
> * $C(S_t,a_t)$ — die **sofortigen** Kosten der Aktion $a_t$.
> * $V_{t+1}(S_{t+1})$ — die Restkosten **danach**, wieder optimal.
> * $\gamma$ — Diskontfaktor (bei kurzen Horizonten meist $\gamma = 1$).
> * $\mathbb{E}[\cdot]$ — Erwartungswert, falls der Übergang zufällig ist.
>
> **Ohne Formel gesagt:** „Der Wert von hier ist: die Kosten des nächsten Schritts plus der
> Wert von dort — und zwar für denjenigen nächsten Schritt, bei dem diese Summe am
> kleinsten ist.“
>
> **Warum rückwärts?** Weil $V_{t+1}$ bekannt sein muss, bevor man $V_t$ berechnen kann. Am
> Ende ($t = T$) ist der Wert bekannt — dort fängt man an und arbeitet sich nach vorn.
> **🔤 Formel-Übersetzer: die Bellman-Gleichung Baustein für Baustein**
>
> | Mathematik | Alltagssprache |
> | --- | --- |
> | $S_t$ | „Alles, was ich jetzt über meine Lage wissen muss.“ Restmenge, Lagerbestand, Position — **und sonst nichts**. |
> | $a_t$ | „Was tue ich jetzt?“ Die Entscheidung dieser Stufe. |
> | $\mathcal{A}(S_t)$ | „Was ist von hier aus überhaupt erlaubt?“ Die zulässigen Aktionen hängen vom Zustand ab. |
> | $C(S_t, a_t)$ | „Was kostet mich das sofort?“ |
> | $V_{t+1}(S_{t+1})$ | „Was kostet mich der ganze Rest danach — wenn ich ab dort optimal handle?“ |
> | $\mathbb{E}[\cdot]$ | „… gemittelt über das, was der Zufall dazwischenwirft.“ Entfällt bei sicheren Übergängen. |
> | $\gamma$ | „Wie viel ist mir ein Euro nächstes Jahr heute wert?“ Bei kurzen Horizonten meist 1. |
> | $\min_{a_t}\{\dots\}$ | „Und von allen erlaubten Aktionen nehme ich die, bei der *Sofortkosten plus Rest* am kleinsten ist.“ |
> | $V_T(S_T) = g(S_T)$ | „Am Ende steht fest, was die Endlage wert ist.“ Hier fängt die Rechnung an. |
>
> **Die ganze Gleichung in einem Satz:** *Der Wert von hier ist: was der nächste Schritt
> kostet, plus was es von dort aus noch kostet — für denjenigen nächsten Schritt, bei dem
> diese Summe am kleinsten ist.*
>
> **Und die Zeile, die man sich merken sollte:** Sobald Sie $V_t$ für alle Zustände kennen,
> ist die optimale Strategie eine reine **Nachschlagetabelle** — für jeden Zustand steht
> darin, was zu tun ist. Das ist der praktische Ertrag der ganzen Rechnung: nicht ein Plan,
> sondern eine **Regel**, die auch dann noch gilt, wenn es anders kommt als gedacht.
> **✏️ Handrechnung: Rückwärtsinduktion von Hand**
>
> **Problem:** Sie müssen 3 Einheiten in 2 Perioden verkaufen. Sofortkosten je Periode:
> $C(n) = n^2$ (großer Verkauf drückt den Preis überproportional). In der letzten Periode
> muss alles weg.
>
> **Schritt 1 — Endstufe $t = 2$:** Es sind keine Perioden mehr übrig, also
> $V_2(0) = 0$, alle anderen Zustände sind unzulässig.
>
> **Schritt 2 — Stufe $t = 1$** (letzte Verkaufsperiode, alles muss weg):
>
> | Zustand $X_1$ | erzwungene Aktion | Kosten | $V_1$ |
> | --- | --- | --- | --- |
> | 0 | 0 | 0 | 0 |
> | 1 | 1 | 1 | 1 |
> | 2 | 2 | 4 | 4 |
> | 3 | 3 | 9 | 9 |
>
> **Schritt 3 — Stufe $t = 0$** (Start mit $X_0 = 3$), alle Aktionen prüfen:
>
> | Aktion $n_0$ | Sofortkosten $n_0^2$ | Rest $X_1$ | $V_1(X_1)$ | Summe |
> | --- | --- | --- | --- | --- |
> | 0 | 0 | 3 | 9 | 9 |
> | 1 | 1 | 2 | 4 | 5 |
> | **2** | **4** | **1** | **1** | **5** |
> | 3 | 9 | 0 | 0 | 9 |
>
> **Ergebnis:** $V_0(3) = 5$, erreicht durch $n_0 = 1$ (dann $n_1 = 2$) **oder**
> $n_0 = 2$ (dann $n_1 = 1$). Zwei gleichwertige Optima.
>
> **Die Lehre:** Alles auf einmal zu verkaufen kostet 9, gleichmäßiges Aufteilen nur 5. Das
> ist der Kern des Ausführungsproblems: **Bei überproportionalen Kosten lohnt sich
> Stückelung.** Und beachten Sie, dass wir nur $4 + 4 = 8$ Kombinationen bewertet haben statt
> aller Verkaufspfade — bei größeren Problemen ist dieser Unterschied dramatisch.
```python
#!/usr/bin/env python3
# Bellman_Minimalbeispiel.py
"""
Kapitel Dynamische Programmierung: Die Handrechnung zur Rueckwaertsinduktion als Code.
Zeigt die Wertfunktionstabelle und die optimale Politik Schritt fuer Schritt.
"""
import numpy as np
GESAMT = 3 # zu verkaufende Einheiten
PERIODEN = 2 # Anzahl Verkaufsperioden
def kosten(menge: int) -> float:
"""Ueberproportionale Marktauswirkung: doppelte Menge kostet vierfach."""
return float(menge ** 2)
def loese_rueckwaerts():
# V[t, x] = minimale Restkosten, wenn zu Beginn von Periode t noch x Stueck offen sind
V = np.full((PERIODEN + 1, GESAMT + 1), np.inf)
politik = np.zeros((PERIODEN, GESAMT + 1), dtype=int)
# Endbedingung: nach der letzten Periode darf nichts mehr offen sein
V[PERIODEN, 0] = 0.0
print("=" * 70)
print(" RUECKWAERTSINDUKTION SCHRITT FUER SCHRITT")
print("=" * 70)
for t in range(PERIODEN - 1, -1, -1):
letzte_periode = (t == PERIODEN - 1)
print(f"\nStufe t = {t}" + (" (letzte Periode: alles muss weg)" if letzte_periode
else " (freie Wahl der Menge)"))
print(f" {'Zustand x':>10} | {'beste Aktion':>12} | {'Sofortkosten':>13} | "
f"{'V[t+1]':>9} | {'V[t]':>8}")
print(" " + "-" * 62)
for x in range(GESAMT + 1):
aktionen = [x] if letzte_periode else range(x + 1)
bester_wert, beste_aktion, beste_teile = np.inf, 0, (0.0, 0.0)
for n in aktionen:
rest = x - n
sofort = kosten(n)
zukunft = V[t + 1, rest]
gesamt = sofort + zukunft
if gesamt < bester_wert:
bester_wert, beste_aktion = gesamt, n
beste_teile = (sofort, zukunft)
V[t, x] = bester_wert
politik[t, x] = beste_aktion
print(f" {x:>10} | {beste_aktion:>12} | {beste_teile[0]:>13.1f} | "
f"{beste_teile[1]:>9.1f} | {bester_wert:>8.1f}")
return V, politik
if __name__ == "__main__":
V, politik = loese_rueckwaerts()
# --- Vorwaertspfad: der optimalen Politik folgen ---------------------
print("\n" + "=" * 70)
print(" OPTIMALER PFAD (Vorwaertssimulation)")
print("=" * 70)
bestand = GESAMT
gesamtkosten = 0.0
for t in range(PERIODEN):
aktion = politik[t, bestand]
gesamtkosten += kosten(aktion)
print(f" Periode {t}: Bestand {bestand} -> verkaufe {aktion} "
f"(Kosten {kosten(aktion):.1f}) -> Rest {bestand - aktion}")
bestand -= aktion
print(f"\n Gesamtkosten: {gesamtkosten:.1f} (V[0, {GESAMT}] = {V[0, GESAMT]:.1f})")
assert abs(gesamtkosten - V[0, GESAMT]) < 1e-9, "Pfadkosten != Wertfunktion!"
# --- Vergleich mit naiven Strategien ---------------------------------
alles_sofort = kosten(GESAMT)
print(f"\n Zum Vergleich - alles in Periode 0 verkaufen: {alles_sofort:.1f}")
print(f" Ersparnis durch Stueckelung: {alles_sofort - gesamtkosten:.1f} "
f"({(1 - gesamtkosten/alles_sofort)*100:.0f} %)")
print("=" * 70)
```
---
## Das Almgren-Chriss-Problem: optimale Orderausführung {#sec:dynamische-programmierung-das-almgren-chriss-problem-optimale-orderausfuehrung}
Ein klassisches OR-Problem an Börsen: Ein institutioneller Händler muss $X_0 = 100\,000$
Aktien innerhalb von $T$ Handelsperioden verkaufen.
**Der Zielkonflikt:**
1. **Marktauswirkung (*market impact*, Slippage)**{idx:Marktauswirkung}. Verkauft man zu schnell, bricht das
Orderbuch ein — der Ausführungspreis verschlechtert sich. Diese Kosten wachsen
**überproportional** mit der Ordergröße.
2. **Zeitrisiko (*timing risk*).** Wartet man zu lange, schwankt der Marktpreis. Je größer
der noch offene Restbestand, desto mehr Geld liegt im Risiko.
Das Modell nach Almgren und Chriss (2000){idx:Almgren-Chriss-Modell} minimiert die Summe aus beidem:
$$
C(n_t, X_t) = \underbrace{\eta\,n_t^2}_{\text{Marktauswirkung}} \;+\; \underbrace{\lambda\,\sigma^2 X_t^2}_{\text{Risiko des Restbestands}}
$$
> **📐 Formel-Lesehilfe**
> * $n_t$ — in Periode $t$ verkaufte Stückzahl. Quadriert, weil doppelte Menge mehr als
> doppelten Preisdruck erzeugt.
> * $\eta$ („eta“) — Slippage-Koeffizient. Wie stark reagiert der Markt auf Volumen?
> * $X_t$ — Restbestand **nach** dem Verkauf. Quadriert, weil Varianz quadratisch mit der
> Positionsgröße wächst.
> * $\lambda$ — Risikoaversion: Wie sehr stört mich Schwankung im Vergleich zu Slippage?
> * $\sigma$ — Volatilität je Periode.
>
> **Ohne Formel gesagt:** „Schnell verkaufen kostet Preisabschlag. Langsam verkaufen kostet
> Nervenkitzel. Finde die Mitte.“
>
> **Die zwei Extremfälle:**
> * $\lambda \to 0$ (risikoneutral): gleichmäßiges Aufteilen auf alle Perioden minimiert
> $\sum n_t^2$ bei fester Summe.
> * $\lambda \to \infty$ (extrem risikoscheu): sofort alles verkaufen, um kein Risiko zu
> tragen.
> **⚠️ Zur Kalibrierung des Risikoparameters**
>
> Ein häufiger Fehler ist ein undokumentierter Skalierungsfaktor im Risikoterm, etwa:
> ```python
> holding_risk = risk_aversion * (sigma_period ** 2) * (remaining ** 2) * 1e5
> ```
> Woher eine solche Zahl kommt, bleibt dann unklar. Faktisch wirkt sie als versteckte
> Erhöhung der Risikoaversion um fünf Größenordnungen — ein Parameter wie
> `risk_aversion = 1e-6` würde dann in Wahrheit $0{,}1$ bedeuten. Solche „magischen
> Zahlen“ machen ein Modell unkalibrierbar: Niemand kann sagen, ob 1e-6 viel oder wenig
> ist.
>
> **Der Parameter lässt sich stattdessen herleiten.** Beide Kostenterme werden in
> **Euro** gerechnet:
> * Marktauswirkung: $\eta \cdot n_t^2$ mit $\eta$ in €/Stück².
> * Risiko: $\tfrac{\lambda}{2}\cdot \sigma_{\text{Periode}}^2 \cdot P_0^2 \cdot X_t^2$ —
> die Varianz des Werts der offenen Position, multipliziert mit der Risikoaversion.
>
> Durch die Multiplikation mit $P_0^2$ (dem Quadrat des Aktienkurses) stimmen die Einheiten,
> und $\lambda$ wird interpretierbar: Es ist der Preis, den man je Einheit Wertvarianz zu
> zahlen bereit ist.
```python
#!/usr/bin/env python3
# Mehrperiodige_Order_Execution.py
"""
Kapitel Dynamische Programmierung: Dynamische Programmierung fuer optimale Orderausfuehrung
(Almgren-Chriss-Rahmen, geloest per Rueckwaertsinduktion).
Eigenschaften:
* Risikoterm sauber hergeleitet ueber den Aktienkurs P0 (Einheiten: EUR),
ohne undokumentierte Skalierungsfaktoren
* Vergleich mit der analytischen Almgren-Chriss-Loesung
* Vergleich mit naiven Strategien (alles sofort / gleichmaessig)
* Sensitivitaet gegenueber der Risikoaversion
"""
import os
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
os.makedirs(OUTPUT_DIR, exist_ok=True)
# --- Parameter -------------------------------------------------------------
GESAMTBESTAND = 100_000 # X_0, zu verkaufende Aktien
PERIODEN = 5 # T Handelsperioden
KURS = 50.0 # P_0 in EUR, zur Skalierung des Risikoterms
ETA = 2.5e-6 # EUR je Stueck^2 (Slippage-Koeffizient)
VOLA_JAHR = 0.30 # 30 % p.a.
HANDELSSTUNDEN_JAHR = 252 * 6.5
RISIKOAVERSION = 1e-5 # 1/EUR; kalibriert, siehe Kommentar unten
SCHRITTWEITE = 1000 # Diskretisierung des Zustandsraums
# Kalibrierungshinweis: Die dimensionslose Kennzahl des Modells ist
# kappa_tilde^2 = lambda * sigma_periode^2 * P0^2 / eta
# Sie entscheidet ueber den Charakter der Loesung:
# << 1 -> praktisch gleichmaessige Aufteilung (Risiko spielt keine Rolle)
# ~ 1 -> ausgewogener Kompromiss <- hier: 0.55
# >> 1 -> fast alles sofort verkaufen
# Genau diese Interpretierbarkeit geht mit einem undokumentierten
# Skalierungsfaktor verloren.
VOLA_PERIODE = VOLA_JAHR / np.sqrt(HANDELSSTUNDEN_JAHR)
def periodenkosten(verkauf: float, restbestand: float) -> float:
"""
Sofortkosten einer Periode in EUR:
(1) Marktauswirkung: eta * n^2
(2) Risiko des Restbestands: lambda/2 * sigma^2 * P0^2 * X^2
-> P0^2 macht aus "Stueck^2" einen EUR^2-Wert; lambda hat damit
die Einheit 1/EUR und ist interpretierbar.
"""
marktauswirkung = ETA * verkauf ** 2
wertvarianz = (VOLA_PERIODE ** 2) * (KURS ** 2) * (restbestand ** 2)
risiko = 0.5 * RISIKOAVERSION * wertvarianz
return marktauswirkung + risiko
def loese_dp():
"""Rueckwaertsinduktion ueber den diskretisierten Zustandsraum."""
zustaende = np.arange(0, GESAMTBESTAND + SCHRITTWEITE, SCHRITTWEITE)
anzahl = len(zustaende)
V = np.full((PERIODEN + 1, anzahl), np.inf)
politik = np.zeros((PERIODEN, anzahl), dtype=int)
V[PERIODEN, 0] = 0.0 # am Ende muss alles verkauft sein
for t in range(PERIODEN - 1, -1, -1):
for idx, bestand in enumerate(zustaende):
if t == PERIODEN - 1:
moegliche = [bestand] # letzte Periode: Rest muss weg
else:
moegliche = zustaende[zustaende <= bestand]
bester_wert, beste_aktion = np.inf, 0
for verkauf in moegliche:
rest = bestand - verkauf
rest_idx = int(round(rest / SCHRITTWEITE))
gesamt = periodenkosten(verkauf, rest) + V[t + 1, rest_idx]
if gesamt < bester_wert:
bester_wert, beste_aktion = gesamt, int(verkauf)
V[t, idx] = bester_wert
politik[t, idx] = beste_aktion
return zustaende, V, politik
def analytische_loesung():
"""
Geschlossene Almgren-Chriss-Loesung fuer den kontinuierlichen Fall.
Der optimale Pfad ist X_t = X_0 * sinh(kappa*(T-t)) / sinh(kappa*T)
mit kappa = arccosh(tilde_kappa^2/2 + 1), tilde_kappa^2 = lambda*sigma^2*P0^2/eta.
Dient hier als unabhaengige Kontrolle des DP-Ergebnisses.
"""
kappa_tilde_quadrat = (RISIKOAVERSION * (VOLA_PERIODE ** 2) * (KURS ** 2)) / ETA
kappa = np.arccosh(kappa_tilde_quadrat / 2.0 + 1.0)
if kappa < 1e-12: # Grenzfall: risikoneutral -> linear
return np.linspace(GESAMTBESTAND, 0, PERIODEN + 1)
t = np.arange(PERIODEN + 1)
return GESAMTBESTAND * np.sinh(kappa * (PERIODEN - t)) / np.sinh(kappa * PERIODEN)
def bewerte_pfad(bestaende):
"""Gesamtkosten eines beliebigen Bestandspfades."""
summe = 0.0
for t in range(len(bestaende) - 1):
verkauf = bestaende[t] - bestaende[t + 1]
summe += periodenkosten(verkauf, bestaende[t + 1])
return summe
if __name__ == "__main__":
zustaende, V, politik = loese_dp()
# --- Vorwaertspfad der optimalen Politik -----------------------------
bestand = GESAMTBESTAND
verlauf = [bestand]
verkaeufe = []
for t in range(PERIODEN):
idx = int(round(bestand / SCHRITTWEITE))
verkauf = politik[t, idx]
verkaeufe.append(verkauf)
bestand -= verkauf
verlauf.append(bestand)
print("=" * 84)
print(" OPTIMALE MEHRPERIODIGE ORDER-EXECUTION (BELLMAN DP)")
print("=" * 84)
print(f"Gesamtvolumen: {GESAMTBESTAND:,} Stueck zu {KURS:.2f} EUR "
f"= {GESAMTBESTAND*KURS:,.0f} EUR Positionswert")
print(f"Zeithorizont: {PERIODEN} Handelsperioden")
print(f"Volatilitaet: {VOLA_JAHR*100:.0f} % p.a. "
f"= {VOLA_PERIODE*100:.3f} % je Periode")
print(f"Slippage eta: {ETA:.2e} EUR/Stueck^2")
print(f"Risikoaversion: {RISIKOAVERSION:.2e} 1/EUR")
print(f"Erwartete Gesamtreibung: {V[0, -1]:,.2f} EUR "
f"({V[0, -1]/(GESAMTBESTAND*KURS)*10000:.1f} Basispunkte)\n")
plan = pd.DataFrame([{
"Periode": f"t = {t} -> {t+1}",
"Startbestand": f"{verlauf[t]:,}",
"Verkauf n_t": f"{verkaeufe[t]:,}",
"Restbestand": f"{verlauf[t+1]:,}",
"Anteil": f"{verkaeufe[t]/GESAMTBESTAND*100:5.1f} %",
"Kosten (EUR)": f"{periodenkosten(verkaeufe[t], verlauf[t+1]):,.0f}",
} for t in range(PERIODEN)])
print(plan.to_string(index=False))
# --- Vergleich mit Alternativen und der analytischen Loesung ---------
sofort = [GESAMTBESTAND] + [0] * PERIODEN
gleichmaessig = [GESAMTBESTAND * (1 - t / PERIODEN) for t in range(PERIODEN + 1)]
analytisch = analytische_loesung()
print("\n" + "-" * 84)
print(f"{'Strategie':<34} {'Kosten (EUR)':>15} {'Basispunkte':>13} "
f"{'ggue. Optimum':>16}")
print("-" * 84)
optimum = V[0, -1]
for name, pfad in [("DP-Optimum", verlauf),
("Analytisch (Almgren-Chriss)", list(analytisch)),
("Gleichmaessig (TWAP)", gleichmaessig),
("Alles sofort", sofort)]:
kosten = bewerte_pfad(pfad)
bp = kosten / (GESAMTBESTAND * KURS) * 10000
print(f"{name:<34} {kosten:>15,.0f} {bp:>12.1f} "
f"{kosten - optimum:>+15,.0f}")
print("-" * 84)
abweichung = abs(bewerte_pfad(list(analytisch)) - optimum) / optimum
print(f"Abweichung DP zur analytischen Loesung: {abweichung*100:.3f} % "
f"(Diskretisierung: {SCHRITTWEITE} Stueck)")
# --- Sensitivitaet gegenueber der Risikoaversion ---------------------
print("\n--- Wie wirkt die Risikoaversion? ---")
print(f"{'lambda':>10} | {'Verkauf in Periode 0':>22} | {'Charakter':<28}")
print("-" * 70)
for lam in [1e-7, 1e-6, 1e-5, 1e-4, 1e-3]:
globals()["RISIKOAVERSION"] = lam
_, V_l, pol_l = loese_dp()
erste = pol_l[0, -1]
anteil = erste / GESAMTBESTAND * 100
charakter = ("nahezu gleichmaessig" if anteil < 25 else
"front-loaded" if anteil < 60 else "fast alles sofort")
print(f"{lam:>10.0e} | {erste:>13,} ({anteil:5.1f} %) | {charakter:<28}")
globals()["RISIKOAVERSION"] = 1e-5 # zuruecksetzen
# --- Diagramm ---------------------------------------------------------
plt.figure(figsize=(10, 5.5))
plt.plot(range(PERIODEN + 1), verlauf, "o-", lw=2.5, label="DP-Optimum")
plt.plot(range(PERIODEN + 1), analytisch, "s--", lw=1.8, alpha=0.8,
label="Analytisch (Almgren-Chriss)")
plt.plot(range(PERIODEN + 1), gleichmaessig, "^:", lw=1.8, alpha=0.8,
label="Gleichmaessig (TWAP)")
plt.bar(range(PERIODEN), verkaeufe, alpha=0.25, color="orange", width=0.45,
label="Verkaufstranche $n_t$")
plt.title("Optimaler Liquidationspfad ueber diskrete Perioden", fontsize=12)
plt.xlabel("Handelsperiode $t$")
plt.ylabel("Verbleibender Bestand $X_t$")
plt.xticks(range(PERIODEN + 1))
plt.grid(True, linestyle=":", alpha=0.6)
plt.legend()
plt.tight_layout()
ziel = os.path.join(OUTPUT_DIR, "optimal_execution_dp.png")
plt.savefig(ziel, dpi=150)
print(f"\nDiagramm gespeichert unter '{ziel}'")
print("=" * 84)
```
**Erwartete Ausgabe:**
```
====================================================================================
OPTIMALE MEHRPERIODIGE ORDER-EXECUTION (BELLMAN DP)
====================================================================================
Gesamtvolumen: 100,000 Stueck zu 50.00 EUR = 5,000,000 EUR Positionswert
Zeithorizont: 5 Handelsperioden
Volatilitaet: 30 % p.a. = 0.741 % je Periode
Slippage eta: 2.50e-06 EUR/Stueck^2
Risikoaversion: 1.00e-05 1/EUR
Erwartete Gesamtreibung: 10,266.24 EUR (20.5 Basispunkte)
Periode Startbestand Verkauf n_t Restbestand Anteil Kosten (EUR)
t = 0 -> 1 100,000 41,000 59,000 41.0 % 6,593
t = 1 -> 2 59,000 25,000 34,000 25.0 % 2,356
t = 2 -> 3 34,000 16,000 18,000 16.0 % 863
t = 3 -> 4 18,000 10,000 8,000 10.0 % 294
t = 4 -> 5 8,000 8,000 0 8.0 % 160
------------------------------------------------------------------------------------
Strategie Kosten (EUR) Basispunkte ggue. Optimum
------------------------------------------------------------------------------------
DP-Optimum 10,266 20.5 +0
Analytisch (Almgren-Chriss) 10,860 21.7 +594
Gleichmaessig (TWAP) 13,242 26.5 +2,976
Alles sofort 25,000 50.0 +14,734
------------------------------------------------------------------------------------
Abweichung DP zur analytischen Loesung: 5.788 % (Diskretisierung: 1000 Stueck)
--- Wie wirkt die Risikoaversion? ---
lambda | Verkauf in Periode 0 | Charakter
----------------------------------------------------------------------
1e-07 | 20,000 ( 20.0 %) | nahezu gleichmaessig
1e-06 | 23,000 ( 23.0 %) | nahezu gleichmaessig
1e-05 | 41,000 ( 41.0 %) | front-loaded
1e-04 | 78,000 ( 78.0 %) | fast alles sofort
1e-03 | 97,000 ( 97.0 %) | fast alles sofort
====================================================================================
```
**Die Lösung ist *front-loaded*:** 41 % im ersten Schritt, dann fallend (25 %, 16 %, 10 %,
8 %). Das ist die typische Form — man baut Risiko früh ab, aber nicht abrupt. Die
Sensitivitätstabelle zeigt, wie $\lambda$ zwischen den beiden Extremen steuert: Bei
$\lambda = 10^{-7}$ verkauft das Modell gleichmäßig (Risiko ist egal), bei
$\lambda = 10^{-3}$ praktisch alles sofort (Risiko dominiert).
Der Vergleich beziffert den Nutzen: Gegenüber der naiven TWAP-Strategie{idx:TWAP} (gleiche Tranchen)
spart die optimierte Ausführung **2 976 €** oder 6 Basispunkte — bei einer Position von
5 Mio. €. Gegenüber „alles sofort“ sind es 14 734 €.
> **💻 Code-Durchgang: die analytische Gegenprobe und was ihre Abweichung bedeutet**
>
> Das Almgren-Chriss-Problem hat eine **geschlossene Lösung**:
> $$X_t = X_0\,\frac{\sinh(\kappa(T-t))}{\sinh(\kappa T)}, \qquad
> \kappa = \operatorname{arcosh}\!\Big(\tfrac{\tilde\kappa^2}{2}+1\Big),\qquad
> \tilde\kappa^2 = \frac{\lambda\,\sigma^2 P_0^2}{\eta}$$
>
> Sie liefert hier 10 860 € gegenüber 10 266 € beim DP — **eine Abweichung von 5,8 %, und
> zwar zugunsten des DP.** Das ist kein Fehler, sondern lehrreich, und es hat zwei Ursachen:
>
> 1. **Unterschiedliche Zeitkonvention.** Die geschlossene Formel gilt für die
> zeitkontinuierliche Variante des Modells, in der das Risiko über das gesamte Intervall
> integriert wird. Unser diskretes Modell belastet dagegen den Bestand **nach** dem
> Verkauf. Beide Varianten sind legitim, sie lösen aber leicht verschiedene Probleme.
> 2. **Diskretisierung.** Der Zustandsraum ist in 1000er-Schritten gerastert; die analytische
> Lösung darf beliebige Stückzahlen wählen.
>
> **Die Lehre daraus ist wichtiger als die Zahl:** Eine Vergleichsrechnung, die *ungefähr*
> passt, bestätigt die Größenordnung und die Form der Lösung — beide Pfade sind
> front-loaded, beide liegen bei rund 21 Basispunkten. Sie beweist aber nicht die
> Punktgenauigkeit, solange die Modellkonventionen nicht identisch sind. **Wer zwei Zahlen
> vergleicht, muss zuerst prüfen, ob sie dasselbe messen.**
>
> **Übernehmen Sie dennoch das Prinzip:** Wo immer eine unabhängige zweite Rechnung möglich
> ist — Formel, Simulation, Handrechnung —, bauen Sie sie ein. Hätte das DP hier 500 000 €
> oder 12 € geliefert, wäre der Fehler sofort aufgefallen.
---
## Der Fluch der Dimensionalität {#sec:dynamische-programmierung-der-fluch-der-dimensionalitaet}
DP ist mächtig, aber es hat eine harte Grenze. Der Aufwand der Rückwärtsinduktion ist
$$
\mathcal{O}(T \cdot |\mathcal{S}| \cdot |\mathcal{A}|)
$$
— Perioden mal Zustände mal Aktionen. Im Beispiel oben: $5 \times 101 \times 101 \approx
51\,000$ Auswertungen, in Sekundenbruchteilen erledigt.
Das Problem entsteht, sobald der Zustand **mehrere Dimensionen** hat:
| Zustandsbeschreibung | Zustandsraum | Machbar? |
| --- | --- | --- |
| Restbestand (101 Stufen) | 101 | ✓ trivial |
| + aktueller Kurs (50 Stufen) | 5 050 | ✓ leicht |
| + Orderbuchtiefe (20 Stufen) | 101 000 | ✓ noch gut |
| + 10 weitere Titel mit je 101 Stufen | $101^{11} \approx 10^{22}$ | ✗ hoffnungslos |
**Das ist der Fluch der Dimensionalität**{idx:Fluch der Dimensionalität} (Bellmans eigener Begriff): Jede zusätzliche
Zustandsvariable **multipliziert** den Aufwand.
**Gegenmittel:**
* **Zustandsraum**{idx:Zustandsraum} **verkleinern:** gröber diskretisieren, irrelevante Variablen weglassen.
* **Approximate Dynamic Programming**{idx:Dynamische Programmierung!Approximate Dynamic Programming}**:** $V_t$ durch eine parametrische Funktion annähern
statt tabellarisch zu speichern.
* **Reinforcement Learning**{idx:Reinforcement Learning}**:** dieselbe Bellman-Gleichung{idx:Bellman-Gleichung}, aber $V$ wird aus Erfahrung
gelernt (Q-Learning{idx:Q-Learning}) statt vollständig berechnet.
* **Nach geschlossenen Lösungen suchen** — wie bei Almgren-Chriss. Wo eine Formel existiert,
ist sie unschlagbar.
---
## Übungsaufgaben {#sec:dynamische-programmierung-uebungsaufgaben}
> Lösungen: {ref:sec:loesungen-dynamische-programmierung}.
**Aufgabe ⭐ — Bausteine benennen.**
Ein Wanderer plant eine 5-Tages-Tour und muss täglich entscheiden, wie weit er läuft.
Bestimmen Sie Stufe, Zustand, Aktion und Wertfunktion. Was gehört **nicht** in den Zustand?
**Aufgabe ⭐ — Optimalitätsprinzip anwenden.**
Warum folgt aus dem Optimalitätsprinzip, dass man rückwärts rechnen darf? Was würde
schiefgehen, wenn die Kosten einer Periode auch von **früheren** Aktionen abhängen (und
nicht nur vom aktuellen Zustand)?
**Aufgabe ⭐⭐ — Rückwärtsinduktion von Hand.**
5 Einheiten in 3 Perioden, Kosten $C(n) = n^2 + 2n$. Erstellen Sie die vollständige
Wertfunktionstabelle und bestimmen Sie den optimalen Pfad. Prüfen Sie mit
`Bellman_Minimalbeispiel.py` (angepasst).
**Aufgabe ⭐⭐ — Rucksackproblem als DP.**
Lösen Sie das Rucksackproblem aus {ref:kap:milp} mit dynamischer Programmierung statt MILP.
(Zustand: verbleibende Kapazität; Stufe: betrachteter Gegenstand.) Vergleichen Sie
Laufzeit und Ergebnis mit dem MILP-Solver.
**Aufgabe ⭐⭐⭐ — Risikoaversion kalibrieren.**
Untersuchen Sie mit `Mehrperiodige_Order_Execution.py`:
(a) Bei welchem $\lambda$ verkauft das Modell in der ersten Periode mehr als 50 %?
(b) Stellen Sie den Zusammenhang zwischen $\lambda$ und den erwarteten Gesamtkosten dar.
(c) Ein Händler sagt: „Ich will höchstens 20 % Marktauswirkungskosten und den Rest an
Risiko.“ Welches $\lambda$ setzen Sie?
**Aufgabe ⭐⭐⭐ — Zustandsraum erweitern.**
Erweitern Sie das Ausführungsmodell um einen zweiten Zustand: die aktuelle
**Orderbuchtiefe** (3 Stufen: dünn/normal/tief), die mit gegebenen
Übergangswahrscheinlichkeiten wechselt und $\eta$ um Faktor 2 / 1 / 0,5 skaliert. Wie ändert
sich die Strategie? Wie stark wächst die Rechenzeit?
---
## Finde den Denkfehler {#sec:dynamische-programmierung-denkfehler}
Bei dynamischer Programmierung entscheidet eine einzige Frage über Erfolg oder Misserfolg:
**Was gehört in den Zustand?** Ist er zu klein, rechnet das Verfahren völlig korrekt — nur
eben an einem anderen Problem.
> **🐛 Finde den Denkfehler 10.1: Der Zustand, der zu wenig weiß**
>
> Eine Werkstatt plant vier Perioden. Der Bedarf beträgt 3, 1, 4, 2 Stück; produziert werden
> können höchstens 5 je Periode, gelagert höchstens 6 Stück.
>
> | Kostenart | Höhe |
> | --- | ---: |
> | Stückkosten | 4 € |
> | Lagerkosten je Stück und Periode | 2 € |
> | **Rüstkosten** — fallen an, wenn produziert wird **und in der Vorperiode nicht** | 30 € |
>
> Der Entwickler überlegt: *„Für die Zukunft zählt nur, wie viel ich auf Lager habe. Also
> ist der Lagerbestand mein Zustand.“*
>
> ```python
> import functools
>
> @functools.lru_cache(None)
> def V(t, lager):
> if t == 4:
> return (0.0, ())
> best = (float("inf"), ())
> for p in range(KAP + 1):
> neu = lager + p - BEDARF[t]
> if neu < 0 or neu > LAGER_MAX:
> continue
> kosten = (RUESTKOSTEN if p > 0 else 0) + STUECKKOSTEN * p + LAGERKOSTEN * neu
> rest, plan = V(t + 1, neu)
> if kosten + rest < best[0]:
> best = (kosten + rest, (p,) + plan)
> return best
> ```
>
> **Ergebnis:** Plan $(5, 0, 5, 0)$ für **110 €**. Nachgerechnet stimmt der Betrag sogar —
> dieser Plan kostet tatsächlich 110 €. Das wahre Optimum lautet aber $(3, 1, 4, 2)$ und
> kostet **70 €**.
>
> **Ihre Aufgabe:** (a) Sehen Sie sich die Zeile mit den Rüstkosten an. Welche Information
> bräuchte sie, die im Zustand `(t, lager)` nicht enthalten ist? (b) Welchen Vorteil des
> Plans $(3,1,4,2)$ kann dieses Modell prinzipiell nicht erkennen? (c) Wie lautet der
> korrigierte Zustand, und um welchen Faktor wächst der Zustandsraum dadurch? (d) Warum ist
> es besonders tückisch, dass der ausgegebene Kostenbetrag *richtig* war?
>
> *Auflösung: {ref:sec:loesungen-dynamische-programmierung}.*
> **🎯 Merksatz**
> Der Zustand muss **alles** enthalten, was die Zukunft beeinflusst — und **nichts**
> darüber hinaus. Zu wenig, und das Modell löst ein anderes Problem, ohne es zu merken. Zu
> viel, und es wird unnötig groß ({ref:sec:dynamische-programmierung-der-fluch-der-dimensionalitaet}).
> Die Probe dafür ist ein einziger Satz: *„Wenn ich nur den Zustand kenne und nicht den Weg
> dorthin — kann ich dann noch optimal weiterentscheiden?“* Lautet die Antwort nein, fehlt
> etwas.
---
## Micro-Quiz {#sec:dynamische-programmierung-quiz}
> **❓ Micro-Quiz 10: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-dynamische-programmierung).
>
> **1. Warum rechnet dynamische Programmierung rückwärts?**
> (a) Weil Rekursion in Python rückwärts effizienter ist.
> (b) Weil $V_t$ den Wert $V_{t+1}$ voraussetzt: Man kann eine Entscheidung erst bewerten,
> wenn man weiß, was sie für die Zukunft bedeutet. Am Ende ist dieser Wert bekannt — dort
> beginnt man.
> (c) Weil die Kosten in späteren Perioden höher sind.
>
> **2. Was besagt das Bellmansche Optimalitätsprinzip?**
> (a) Jede optimale Lösung besteht aus lauter einzeln optimalen Schritten — man kann also
> gierig vorgehen.
> (b) Ist ein Weg insgesamt optimal, so ist auch sein **Reststück** ab jedem Zwischenzustand
> optimal. Deshalb genügt es, je Zustand einen einzigen Wert zu speichern.
> (c) Bei genügend Rechenzeit findet man das Optimum immer.
>
> **3. Ihr DP-Modell für eine Lagerplanung hat den Zustand „Lagerbestand“. Nun kommt eine
> Mengenrabattstaffel dazu, die sich nach der **bisher im Jahr bestellten Gesamtmenge**
> richtet. Was folgt?**
> (a) Nichts — der Rabatt betrifft nur die Kosten, nicht den Zustand.
> (b) Die kumulierte Jahresmenge muss in den Zustand, sonst kann das Modell den Rabatt nicht
> korrekt zuordnen. Der Zustandsraum wird dadurch erheblich größer.
> (c) Man muss auf ein MILP wechseln, DP ist hier grundsätzlich ungeeignet.
---
## Selbsttest {#sec:dynamische-programmierung-selbsttest}
> Antworten: [Anhang A](90_Anhang_Loesungen.md#selbsttest-loesung-dynamische-programmierung).
1. Formulieren Sie das Optimalitätsprinzip in eigenen Worten.
2. Warum rechnet man bei DP rückwärts und nicht vorwärts?
3. Was muss ein Zustand enthalten — und woran erkennt man, dass er unvollständig ist?
4. Was besagt der Fluch der Dimensionalität, und welche drei Gegenmittel gibt es?
5. Warum ist eine analytische Vergleichslösung wertvoll, wenn man schon eine numerische hat?
---
## Zusammenfassung {#sec:dynamische-programmierung-zusammenfassung}
* **Das Optimalitätsprinzip** erlaubt es, ein mehrstufiges Problem in ineinandergreifende
Einperiodenprobleme zu zerlegen.
* **Rückwärtsinduktion** startet am bekannten Ende und arbeitet sich nach vorn — dadurch ist
$V_{t+1}$ immer schon bekannt, wenn $V_t$ berechnet wird.
* **Die Zustandsdefinition ist die eigentliche Modellierungsleistung**: vollständig, aber so
knapp wie möglich. Die Probe dafür ist ein Satz: *„Wenn ich nur den Zustand kenne und nicht
den Weg dorthin — kann ich dann noch richtig weiterentscheiden?“* Fehlt etwas, rechnet das
Verfahren korrekt an einem anderen Problem, ohne es zu melden.
* **Die Wertfunktion ist eine Nachschlagetabelle, kein Plan.** Ihr praktischer Ertrag ist
eine Regel für jeden Zustand — die auch dann noch gilt, wenn es anders kommt als gedacht.
* **Gierige Vorwärtsregeln scheitern systematisch**, weil sie die Restkosten nicht kennen:
Im Schnellstart kostet der billigere erste Abschnitt am Ende 50 % mehr.
* **Bei überproportionalen Kosten lohnt sich Stückelung** — das ist der ökonomische Kern des
Ausführungsproblems.
* **Magische Konstanten sind ein Warnsignal.** Jeder Parameter braucht eine Einheit und eine
Interpretation, sonst ist das Modell nicht kalibrierbar.
* **Der Fluch der Dimensionalität** begrenzt DP auf wenige Zustandsdimensionen.
**Ausblick.** {ref:teil:anwendungen} führt alles zusammen: Ab {ref:kap:finanzdaten} arbeiten wir mit echten
Marktdaten — und lernen zuerst, warum diese Daten trügerisch sind.
---
*Weiter mit:* [{ref:kap:finanzdaten} — Finanzdaten und Kovarianz-Shrinkage](40_Finanzdaten.md)

View file

@ -0,0 +1,759 @@
# Kapitel: Mehrere Ziele — Pareto-Fronten statt Gewichte {#kap:mehrziel}
> **📌 Kapitel auf einen Blick**
>
> **Worum geht es?** Um den Normalfall, den die bisherigen Kapitel umgangen haben: Es gibt
> nicht *ein* Ziel, sondern zwei, die gegeneinander stehen. Kosten und CO₂, Termintreue und
> Bestand, Rendite und Risiko.
>
> **Voraussetzungen:** {ref:kap:modellierung} (die Zielgröße als Entscheidung) und
> {ref:kap:milp}. Für den Denkfehler hilft {ref:kap:markowitz}.
>
> **Danach können Sie:** begründen, warum eine gewichtete Summe bei ganzzahligen Modellen
> Kompromisse **grundsätzlich** nicht erreichen kann; eine vollständige Pareto-Front mit dem
> ε-Constraint-Verfahren berechnen; und einer Geschäftsführung eine Entscheidung vorlegen,
> die sie tatsächlich treffen kann.
>
> **Zeitbedarf:** ca. 4 Stunden.
>
> **Programme:**\
> `Mehrziel_Pareto.py`
---
## In 5 Minuten gelöst {#sec:mehrziel-schnellstart}
> **🚀 In 5 Minuten gelöst: Was kostet ein Kilogramm CO₂?**
>
> Eine Spedition vergibt fünf Sendungen an LKW, Bahn oder Kombinierten Verkehr. Der LKW ist
> billig und schmutzig, die Bahn teuer und sauber — und es sind nur zwei Trassen frei.
>
> Statt zu fragen „wie wichtig ist uns CO₂?" fragen wir: **Was bekommen wir für zwei Prozent
> mehr Geld?**
>
> ```python
> import numpy as np
> from scipy.optimize import linprog
>
> # LKW Bahn Kombi LKW billig+schmutzig, Bahn teuer+sauber
> kosten = np.array([[ 900, 1150, 1020], [ 620, 790, 700], [1300, 1660, 1470],
> [ 480, 610, 545], [1050, 1340, 1180]], dtype=float)
> co2 = np.array([[ 1400, 310, 760], [ 980, 220, 530], [2000, 440, 1080],
> [ 760, 170, 410], [1620, 360, 880]], dtype=float)
> eine_pro_sendung = np.repeat(np.eye(5), 3, axis=1)
> bahn_trassen = [[1.0 if j == 1 else 0.0 for _ in range(5) for j in range(3)]]
>
> def plane(ziel, kostenbudget=None):
> A, b = list(bahn_trassen), [2.0] # nur zwei Trassen frei
> if kostenbudget is not None:
> A.append(kosten.reshape(-1)); b.append(kostenbudget)
> x = linprog(ziel.reshape(-1), A_ub=A, b_ub=b, A_eq=eine_pro_sendung,
> b_eq=np.ones(5), bounds=(0, 1), integrality=1, method="highs").x
> return kosten.reshape(-1) @ x, co2.reshape(-1) @ x
>
> k0, c0 = plane(kosten) # nur Kosten minimieren
> k1, c1 = plane(co2, kostenbudget=k0 * 1.02) # 2 % mehr ausgeben
> print(f"guenstigster Plan: {k0:>7,.0f} EUR {c0:>6,.0f} kg CO2")
> print(f"mit 2 % Aufpreis: {k1:>7,.0f} EUR {c1:>6,.0f} kg CO2 "
> f"({(c0-c1)/c0:.0%} weniger, {(k1-k0)/(c0-c1):.2f} EUR je kg)")
> ```
>
> **Ausgabe:**
>
> ```
> guenstigster Plan: 4,350 EUR 6,760 kg CO2
> mit 2 % Aufpreis: 4,430 EUR 6,310 kg CO2 (7% weniger, 0.18 EUR je kg)
> ```
**Und jetzt der Punkt.** 80 Euro mehr sparen 450 Kilogramm CO₂ — **18 Cent je Kilogramm**.
Der europäische Emissionshandel bewegt sich um ein Vielfaches davon. Die Entscheidung ist
damit keine Wertefrage mehr, sondern eine Rechnung.
Beachten Sie, was hier *nicht* passiert ist: Niemand musste ein Gewicht festlegen. Die
zweite Zeile setzt eine **Schranke** („höchstens 2 % teurer") und optimiert dann das andere
Ziel. Das Ergebnis ist eine Aussage, die ein Kaufmann prüfen kann.
> **🎯 Merksatz**
> „Wie wichtig ist Ihnen CO₂?" ist keine beantwortbare Frage. „Wir geben zwei Prozent mehr
> aus — was bringt das?" ist eine.
**Warum funktioniert das?** Weil aus zwei Zielen ein Ziel und eine Nebenbedingung geworden
sind. Das ist die Grundidee des **ε-Constraint-Verfahrens**, um das sich dieses Kapitel
dreht — und es ist der Grund, warum es ohne jedes Gewicht auskommt.
---
## Lernziele {#sec:mehrziel-lernziele}
Nach diesem Kapitel können Sie …
1. … Pareto-Dominanz und Pareto-Front definieren und an einem Diagramm zeigen.
2. … erklären, warum die gewichtete Summe nur **gestützte** Lösungen findet — und was das
geometrisch heißt.
3. … eine vollständige Pareto-Front mit dem ε-Constraint-Verfahren berechnen, mit so vielen
Solverläufen, wie die Front Punkte hat.
4. … lexikografische Optimierung einsetzen, wenn es eine klare Rangfolge der Ziele gibt.
5. … einer Entscheiderin die Front so vorlegen, dass sie eine Wahl treffen kann.
---
## Wann ein Kompromiss gut ist {#sec:mehrziel-dominanz}
Bei einem Ziel ist „besser" eindeutig. Bei zwei Zielen nicht mehr: Ein Plan kann billiger
und schmutziger sein als ein anderer, und dann ist keiner von beiden besser.
Vergleichbar sind nur die Fälle, in denen ein Plan in **keinem** Ziel schlechter ist:
$$
x \text{ dominiert } y
\quad :\Longleftrightarrow \quad
f_i(x) \le f_i(y)\ \text{ für alle } i
\ \text{ und }\
f_j(x) < f_j(y)\ \text{ für mindestens ein } j
$$
> **🔤 Formel-Übersetzer**
>
> | Mathematik | Alltagssprache |
> | --- | --- |
> | $f_i(x) \le f_i(y)$ für alle $i$ | „Plan $x$ ist in **keinem** Ziel schlechter als $y$.“ |
> | $f_j(x) < f_j(y)$ für ein $j$ | „… und in mindestens einem echt besser.“ |
> | $x$ dominiert $y$ | „$y$ kann man wegwerfen. Es gibt keinen Grund, ihn zu wählen.“ |
> | $x$ ist **pareto-optimal** | „Niemand dominiert $x$. Wer $x$ verbessern will, muss anderswo schlechter werden.“ |
> | **Pareto-Front** | „Die Speisekarte: alle Pläne, die man ernsthaft in Betracht ziehen kann.“ |
>
> **Ohne Formel gesagt:** Wegwerfen darf man einen Plan nur, wenn ein anderer ihn in jeder
> Hinsicht schlägt. Alles Übrige ist eine Frage der Präferenz — und die gehört nicht dem
> Modellierer.
> **📖 Definition: Pareto-Front**{idx:Pareto-Front}
>
> Die Menge aller nicht dominierten Lösungen. Sie ist das vollständige Ergebnis eines
> Mehrzielproblems — nicht ein Punkt, sondern eine Liste.
>
> Wer daraus einen einzelnen Punkt macht, trifft eine Entscheidung. Die Frage ist nur, ob er
> es merkt.
---
## Der Reflex: alles in ein Ziel rühren {#sec:mehrziel-skalarisierung}
Der naheliegende Weg, zwei Ziele zu einem zu machen, ist die **gewichtete Summe**{idx:Skalarisierung}:
$$
\min_{x \in X} \; c(x) + w \cdot e(x)
$$
> **🔤 Formel-Übersetzer**
>
> | Mathematik | Alltagssprache |
> | --- | --- |
> | $c(x)$ | Transportkosten des Plans $x$, in Euro. |
> | $e(x)$ | CO₂-Ausstoß desselben Plans, in Kilogramm. |
> | $w$ | „So viele Euro ist mir ein Kilogramm CO₂ wert.“ Ein **Wechselkurs**, kein Wichtigkeitsregler ({ref:sec:cpsat-denkfehler}). |
> | $\min_{x \in X}$ | über alle zulässigen Pläne. |
>
> Der Ausdruck hat eine Einheit: Euro. Damit ist $w$ keine freie Zahl zwischen 0 und 1,
> sondern ein CO₂-Preis in €/kg — und den kann man tatsächlich beziffern.
Das ist bequem, liefert zulässige Lösungen und wird in der Praxis fast immer so gemacht. Es
hat nur einen Fehler, den man erst sieht, wenn man das Ergebnis mit der vollständigen Front
vergleicht.
---
## Das Programm {#sec:mehrziel-programm}
Zwölf Sendungen, drei Verkehrsträger, fünf freie Bahntrassen. Der Zielkonflikt entsteht
nicht zwischen den Trägern — die Bahn ist immer billiger *und* sauberer als der LKW —,
sondern durch die **Knappheit** der Trassen. Genau so sieht es in der Praxis aus.
```python
#!/usr/bin/env python3
# Mehrziel_Pareto.py
"""
Kapitel Mehrziel: Kosten gegen CO2 - und warum Gewichte nicht genuegen.
Eine Spedition vergibt zwoelf Sendungen an drei Verkehrstraeger: LKW (schnell,
teuer, schmutzig), Bahn (billig und sauber, aber nur fuenf Trassen frei) und
Kombinierten Verkehr (dazwischen). Zwei Ziele stehen gegeneinander:
Transportkosten und CO2-Ausstoss.
Der uebliche Reflex ist, beide Ziele zu einem zusammenzuruehren:
minimiere Kosten + w * CO2
Das ist bequem, liefert zulaessige Loesungen - und ist unvollstaendig. Bei
ganzzahligen Entscheidungen gibt es Kompromisse, die auf diesem Weg
GRUNDSAETZLICH nicht erreichbar sind, egal welches w man waehlt. Nicht "schwer
zu finden", sondern beweisbar unerreichbar.
Das Programm zeigt in vier Teilen:
1. Die beiden Extreme - was jedes Ziel allein kostet.
2. Die lineare Skalarisierung ueber ein feines Gewichtsraster.
3. Die vollstaendige Pareto-Front ueber das eps-Constraint-Verfahren.
4. Den Nachweis, dass die Luecke keine Frage des Rasters ist, sondern
Geometrie: Die fehlenden Punkte liegen strikt oberhalb der konvexen
Huelle und koennen deshalb von keiner Geraden gestuetzt werden.
Benoetigt: numpy, scipy
"""
from __future__ import annotations
import numpy as np
from scipy.optimize import linprog
TRAEGER = ["LKW", "Bahn", "Kombiniert"]
BAHN_TRASSEN = 5 # so viele Sendungen passen hoechstens auf die Bahn
SAAT = 5
def erzeuge_sendungen(anzahl: int = 12, saat: int = SAAT):
"""Kosten und CO2 je Sendung und Verkehrstraeger.
Die Bahn ist immer billiger und sauberer als der LKW - der Zielkonflikt
entsteht nicht zwischen den Traegern, sondern durch die KNAPPHEIT der
Trassen. Genau so sieht es in der Praxis aus.
"""
rng = np.random.default_rng(saat)
kosten = np.zeros((anzahl, 3))
co2 = np.zeros((anzahl, 3))
for i in range(anzahl):
grund_kosten = rng.integers(600, 2400)
grund_co2 = rng.integers(400, 1800)
kosten[i] = [grund_kosten,
grund_kosten * rng.uniform(0.55, 0.85),
grund_kosten * rng.uniform(0.70, 1.00)]
co2[i] = [grund_co2,
grund_co2 * rng.uniform(0.15, 0.35),
grund_co2 * rng.uniform(0.40, 0.70)]
return np.round(kosten).astype(int), np.round(co2).astype(int)
KOSTEN, CO2 = erzeuge_sendungen()
N = len(KOSTEN)
def plane(ziel: np.ndarray, co2_grenze: float | None = None,
kosten_grenze: float | None = None):
"""Jede Sendung genau einem Traeger zuordnen; Trassen sind knapp.
'ziel' ist die zu minimierende Matrix (Kosten, CO2 oder eine Mischung).
Die beiden Grenzen sind das Werkzeug fuer eps-Constraint und
lexikografische Optimierung - sie machen aus einem Ziel eine Schranke.
"""
gleichungen = np.zeros((N, N * 3))
for i in range(N):
gleichungen[i, i * 3:(i + 1) * 3] = 1.0 # genau ein Traeger
ungleichungen = [[1.0 if j == 1 else 0.0 for _ in range(N) for j in range(3)]]
grenzen = [float(BAHN_TRASSEN)]
if co2_grenze is not None:
ungleichungen.append(CO2.reshape(-1).astype(float))
grenzen.append(float(co2_grenze))
if kosten_grenze is not None:
ungleichungen.append(KOSTEN.reshape(-1).astype(float))
grenzen.append(float(kosten_grenze))
ergebnis = linprog(ziel.reshape(-1).astype(float),
A_ub=ungleichungen, b_ub=grenzen,
A_eq=gleichungen, b_eq=np.ones(N),
bounds=(0, 1), integrality=1, method="highs")
if not ergebnis.success:
return None
plan = np.round(ergebnis.x).astype(int)
return (int(KOSTEN.reshape(-1) @ plan), int(CO2.reshape(-1) @ plan), plan)
def pareto_front():
"""Vollstaendige Front ueber das eps-Constraint-Verfahren.
Der Ablauf ist der eigentliche Inhalt dieser Funktion: Erst das
Kostenminimum bestimmen (der eine Rand der Front), dann die CO2-Schranke
schrittweise um genau ein Kilogramm unter den zuletzt erreichten Wert
druecken. Jeder Lauf liefert den naechsten Punkt - und wenn keiner mehr
zulaessig ist, ist die Front vollstaendig.
Das braucht so viele Solveraufrufe, wie die Front Punkte hat. Ein Raster
ueber alle moeglichen CO2-Werte braeuchte hier fast tausend.
"""
front = []
start = plane(KOSTEN)
grenze = start[1]
while True:
ergebnis = plane(KOSTEN, co2_grenze=grenze)
if ergebnis is None:
break
front.append((ergebnis[0], ergebnis[1]))
grenze = ergebnis[1] - 1
return front
def skalarisierung(gewichte):
"""Was 'Kosten + w * CO2' fuer viele w hergibt - als Menge von Punkten."""
gefunden = {}
for w in gewichte:
ergebnis = plane(KOSTEN + w * CO2)
if ergebnis:
gefunden.setdefault((ergebnis[0], ergebnis[1]), []).append(w)
return gefunden
def untere_huelle(punkte):
"""Untere linke konvexe Huelle - genau die Punkte, die eine Gerade stuetzt.
Der Zusammenhang, um den es geht: 'Kosten + w*CO2 minimieren' heisst
geometrisch, eine Gerade der Steigung -1/w von links unten an die
Punktwolke zu schieben. Sie beruehrt immer einen Eckpunkt der konvexen
Huelle. Punkte, die oberhalb liegen, werden nie beruehrt - fuer kein w.
"""
huelle = []
for punkt in sorted(punkte):
while len(huelle) >= 2:
(x1, y1), (x2, y2) = huelle[-2], huelle[-1]
if (x2 - x1) * (punkt[1] - y1) - (y2 - y1) * (punkt[0] - x1) <= 0:
huelle.pop()
else:
break
huelle.append(punkt)
return huelle
if __name__ == "__main__":
print("=" * 80)
print(" KOSTEN GEGEN CO2 - UND WARUM GEWICHTE NICHT GENUEGEN")
print("=" * 80)
print(f"{N} Sendungen, {len(TRAEGER)} Verkehrstraeger, "
f"{BAHN_TRASSEN} freie Bahntrassen.\n")
# --- 1. Die beiden Extreme -------------------------------------------
guenstigst = plane(KOSTEN)
saubersten = plane(CO2)
print("1. Was jedes Ziel allein ergibt\n")
print(f" {'':<22} {'Kosten':>10} {'CO2':>10}")
print(" " + "-" * 44)
print(f" {'nur Kosten minimal':<22} {guenstigst[0]:>10,} {guenstigst[1]:>9,} kg")
print(f" {'nur CO2 minimal':<22} {saubersten[0]:>10,} {saubersten[1]:>9,} kg")
print(f"\n Der Zielkonflikt ist echt, aber klein: "
f"{saubersten[0] - guenstigst[0]:,} EUR mehr")
print(f" ({(saubersten[0] - guenstigst[0]) / guenstigst[0] * 100:.1f} %) sparen "
f"{guenstigst[1] - saubersten[1]:,} kg CO2 "
f"({(guenstigst[1] - saubersten[1]) / guenstigst[1] * 100:.1f} %).")
print(" Genau solche Zahlen will die Geschaeftsfuehrung sehen - nicht ein")
print(" Gewicht, das niemand interpretieren kann.")
# --- 2. Die lineare Skalarisierung -----------------------------------
gewichte = np.concatenate([np.linspace(0.0, 3.0, 1201),
np.geomspace(3.0, 1000.0, 200)])
gefunden = skalarisierung(gewichte)
print("\n" + "-" * 80)
print(f"2. Lineare Skalarisierung: 'Kosten + w * CO2' fuer "
f"{len(gewichte):,} Gewichte\n")
print(f" {'Kosten':>10} {'CO2':>10} {'gefunden bei w':>16}")
print(" " + "-" * 46)
for (k, c), ws in sorted(gefunden.items()):
print(f" {k:>10,} {c:>9,} kg {min(ws):>7.3f} bis {max(ws):>7.3f}")
print(f"\n {len(gefunden)} verschiedene Plaene - fuer {len(gewichte):,} Gewichte.")
print(" Das Gewicht ist also gar keine Feineinstellung: Weite Bereiche")
print(" liefern dasselbe Ergebnis, und dazwischen springt es.")
# --- 3. Die vollstaendige Front --------------------------------------
front = pareto_front()
print("\n" + "-" * 80)
print(f"3. Die vollstaendige Pareto-Front ueber eps-Constraint "
f"({len(front)} Solverlaeufe)\n")
print(f" {'Kosten':>10} {'CO2':>10} {'Aufpreis':>9} {'CO2-Ersparnis':>14} "
f"{'EUR je kg':>10}")
print(" " + "-" * 60)
for k, c in front:
auf = k - guenstigst[0]
ersparnis = guenstigst[1] - c
preis = auf / ersparnis if ersparnis else 0.0
print(f" {k:>10,} {c:>9,} kg {auf:>8,} {ersparnis:>13,} "
f"{preis:>10.2f}")
# --- 4. Der Nachweis --------------------------------------------------
huelle = untere_huelle(front)
unerreichbar = [p for p in front if p not in huelle]
print("\n" + "-" * 80)
print("4. Was die Skalarisierung nicht findet\n")
erreicht = [p for p in front if p in gefunden]
print(f" Pareto-Punkte insgesamt: {len(front)}")
print(f" davon von der Skalarisierung gefunden: {len(erreicht)}")
print(f" nie gefunden: {len(front) - len(erreicht)}")
print()
print(" Diese Kompromisse sind fuer KEIN Gewicht erreichbar:\n")
print(f" {'Kosten':>10} {'CO2':>10} {'Aufpreis':>9} {'CO2-Ersparnis':>14}")
print(" " + "-" * 50)
for k, c in unerreichbar:
print(f" {k:>10,} {c:>9,} kg {k - guenstigst[0]:>8,} "
f"{guenstigst[1] - c:>13,}")
stimmt = sorted(unerreichbar) == sorted(p for p in front if p not in gefunden)
print(f"\n Gegenprobe ueber die Geometrie: {'bestanden' if stimmt else 'ABWEICHUNG'}")
print(" Genau die Punkte, die das Gewichtsraster verfehlt, liegen strikt")
print(" oberhalb der unteren konvexen Huelle. Das ist kein Rasterproblem -")
print(" eine Gerade, die von links unten an die Wolke geschoben wird,")
print(" beruehrt immer einen Eckpunkt der Huelle und nie einen Punkt")
print(" darueber. Ein feineres Raster aendert daran nichts.")
# --- 5. Lexikografisch -------------------------------------------------
print("\n" + "-" * 80)
print("5. Lexikografisch: erst Kosten, dann CO2 im Rahmen eines Budgets\n")
print(f" {'Kostenbudget':>14} {'Kosten':>10} {'CO2':>10} {'gegenueber Minimum':>20}")
print(" " + "-" * 58)
for aufschlag in (0.00, 0.01, 0.02, 0.05, 0.10):
budget = guenstigst[0] * (1 + aufschlag)
ergebnis = plane(CO2, kosten_grenze=budget)
if ergebnis is None:
print(f" {aufschlag:>13.0%} unzulaessig")
continue
print(f" {aufschlag:>13.0%} {ergebnis[0]:>10,} {ergebnis[1]:>9,} kg "
f"{guenstigst[1] - ergebnis[1]:>15,} kg weniger")
print("\n Das ist die Form, die im Betrieb am ehesten trifft: Nicht 'wie")
print(" wichtig ist CO2?', sondern 'wir geben zwei Prozent mehr aus - was")
print(" bringt das?'. Die Frage kann ein Kaufmann beantworten.")
print("\n" + "=" * 80)
print(" WAS MAN DARAUS MITNIMMT")
print("=" * 80)
print("Ein Gewicht zu setzen heisst, die Entscheidung heimlich zu treffen -")
print("und dabei einen Teil der Moeglichkeiten gar nicht erst zu sehen.")
print()
print("Die Pareto-Front ist die ehrlichere Antwort: Sie legt dem Betrieb")
print("alle sinnvollen Kompromisse vor und ueberlaesst ihm die Wahl. Die")
print("Spalte 'EUR je kg' macht sie entscheidbar - man vergleicht sie mit")
print("dem CO2-Preis, den das Unternehmen ohnehin ansetzt.")
print("=" * 80)
```
**Erwartete Ausgabe:**
```
================================================================================
KOSTEN GEGEN CO2 - UND WARUM GEWICHTE NICHT GENUEGEN
================================================================================
12 Sendungen, 3 Verkehrstraeger, 5 freie Bahntrassen.
1. Was jedes Ziel allein ergibt
Kosten CO2
--------------------------------------------
nur Kosten minimal 13,543 6,475 kg
nur CO2 minimal 14,821 5,506 kg
Der Zielkonflikt ist echt, aber klein: 1,278 EUR mehr
(9.4 %) sparen 969 kg CO2 (15.0 %).
Genau solche Zahlen will die Geschaeftsfuehrung sehen - nicht ein
Gewicht, das niemand interpretieren kann.
--------------------------------------------------------------------------------
2. Lineare Skalarisierung: 'Kosten + w * CO2' fuer 1,401 Gewichte
Kosten CO2 gefunden bei w
----------------------------------------------
13,543 6,475 kg 0.000 bis 0.398
13,724 6,022 kg 0.400 bis 1.002
14,036 5,711 kg 1.005 bis 1.935
14,189 5,632 kg 1.938 bis 3.471
14,592 5,517 kg 3.574 bis 20.599
14,821 5,506 kg 21.210 bis 1000.000
6 verschiedene Plaene - fuer 1,401 Gewichte.
Das Gewicht ist also gar keine Feineinstellung: Weite Bereiche
liefern dasselbe Ergebnis, und dazwischen springt es.
--------------------------------------------------------------------------------
3. Die vollstaendige Pareto-Front ueber eps-Constraint (10 Solverlaeufe)
Kosten CO2 Aufpreis CO2-Ersparnis EUR je kg
------------------------------------------------------------
13,543 6,475 kg 0 0 0.00
13,635 6,317 kg 92 158 0.58
13,718 6,286 kg 175 189 0.93
13,724 6,022 kg 181 453 0.40
13,868 5,927 kg 325 548 0.59
13,978 5,912 kg 435 563 0.77
14,036 5,711 kg 493 764 0.65
14,189 5,632 kg 646 843 0.77
14,592 5,517 kg 1,049 958 1.09
14,821 5,506 kg 1,278 969 1.32
--------------------------------------------------------------------------------
4. Was die Skalarisierung nicht findet
Pareto-Punkte insgesamt: 10
davon von der Skalarisierung gefunden: 6
nie gefunden: 4
Diese Kompromisse sind fuer KEIN Gewicht erreichbar:
Kosten CO2 Aufpreis CO2-Ersparnis
--------------------------------------------------
13,635 6,317 kg 92 158
13,718 6,286 kg 175 189
13,868 5,927 kg 325 548
13,978 5,912 kg 435 563
Gegenprobe ueber die Geometrie: bestanden
Genau die Punkte, die das Gewichtsraster verfehlt, liegen strikt
oberhalb der unteren konvexen Huelle. Das ist kein Rasterproblem -
eine Gerade, die von links unten an die Wolke geschoben wird,
beruehrt immer einen Eckpunkt der Huelle und nie einen Punkt
darueber. Ein feineres Raster aendert daran nichts.
--------------------------------------------------------------------------------
5. Lexikografisch: erst Kosten, dann CO2 im Rahmen eines Budgets
Kostenbudget Kosten CO2 gegenueber Minimum
----------------------------------------------------------
0% 13,543 6,475 kg 0 kg weniger
1% 13,635 6,317 kg 158 kg weniger
2% 13,724 6,022 kg 453 kg weniger
5% 14,189 5,632 kg 843 kg weniger
10% 14,821 5,506 kg 969 kg weniger
Das ist die Form, die im Betrieb am ehesten trifft: Nicht 'wie
wichtig ist CO2?', sondern 'wir geben zwei Prozent mehr aus - was
bringt das?'. Die Frage kann ein Kaufmann beantworten.
================================================================================
WAS MAN DARAUS MITNIMMT
================================================================================
Ein Gewicht zu setzen heisst, die Entscheidung heimlich zu treffen -
und dabei einen Teil der Moeglichkeiten gar nicht erst zu sehen.
Die Pareto-Front ist die ehrlichere Antwort: Sie legt dem Betrieb
alle sinnvollen Kompromisse vor und ueberlaesst ihm die Wahl. Die
Spalte 'EUR je kg' macht sie entscheidbar - man vergleicht sie mit
dem CO2-Preis, den das Unternehmen ohnehin ansetzt.
================================================================================
```
---
## Was die gewichtete Summe nicht sieht {#sec:mehrziel-luecke}
Die Front hat **zehn** Punkte. Ein Raster aus 1 401 Gewichten findet **sechs** davon. Vier
Kompromisse sind für kein Gewicht erreichbar — und das ist keine Frage der Rasterweite.
![Pareto-Front: Kosten gegen CO₂. Die gefüllten Punkte liegen auf der unteren konvexen
Hülle und sind über Gewichte erreichbar; die vier offenen Kreise sind pareto-optimal,
werden aber von keiner Geraden gestützt.](bilder_04/kap_mehrziel_pareto.svg)
{plotly:kap_mehrziel_pareto}
**Warum es keine Frage des Rasters ist.** „$c(x) + w\,e(x)$ minimieren" heißt geometrisch:
eine Gerade der Steigung $-1/w$ von links unten an die Punktwolke schieben und schauen,
welchen Punkt sie zuerst berührt. Eine Gerade berührt immer einen **Eckpunkt der unteren
konvexen Hülle**. Punkte, die oberhalb der Hülle liegen, werden von keiner Geraden zuerst
getroffen — für kein $w$, bei beliebig feiner Abstufung.
Das Programm rechnet diese Hülle in `untere_huelle()` unabhängig aus und vergleicht: Genau
die vier Punkte, die das Gewichtsraster verfehlt, liegen strikt oberhalb. Die Gegenprobe
steht in der Ausgabe.
> **⚠️ Der Grund liegt in der Ganzzahligkeit**
>
> Bei einem reinen LP ist der zulässige Bereich konvex, und die Pareto-Front liegt vollständig
> auf ihrer eigenen konvexen Hülle — dort findet die gewichtete Summe alles. Sobald
> Entscheidungen **ganzzahlig** werden (welcher Träger, welches Lager, welche Schicht),
> zerfällt der Bereich in einzelne Punkte, und zwischen ihnen entstehen Einbuchtungen.
>
> Deshalb betrifft dieses Problem praktisch jedes betriebliche Mehrzielmodell. Wer mit
> Gewichten arbeitet, verliert nicht ein paar Nachkommastellen, sondern **ganze Alternativen**
> im Beispiel 40 % der Front.
### Das Gewicht ist keine Feineinstellung
Die zweite Beobachtung aus derselben Tabelle: 1 401 Gewichte erzeugen **sechs** verschiedene
Pläne. Weite Gewichtsbereiche liefern dasselbe Ergebnis, und dazwischen springt es.
Das macht die verbreitete Vorgehensweise — „wir probieren ein paar Gewichte und schauen,
was herauskommt" — zu einem Glücksspiel. Man weiß nie, ob man einen neuen Kompromiss
gefunden hat oder nur denselben noch einmal.
---
## Das ε-Constraint-Verfahren {#sec:mehrziel-epsilon}
Der Ausweg ist unspektakulär: **Man macht aus dem zweiten Ziel eine Nebenbedingung.**
$$
\min_{x \in X} \; c(x)
\qquad \text{unter} \qquad
e(x) \le \varepsilon
$$
Für jedes $\varepsilon$ liefert das einen Punkt der Front. Die Kunst besteht nur darin,
die $\varepsilon$ geschickt zu wählen — und dafür gibt es ein Verfahren, das genau so viele
Solverläufe braucht, wie die Front Punkte hat:
1. Kostenminimum bestimmen. Das ist der eine Rand der Front; nebenbei fällt sein CO₂-Wert an.
2. $\varepsilon$ auf **ein Kilogramm unter diesen Wert** setzen und erneut lösen.
3. Schritt 2 wiederholen, bis kein zulässiger Plan mehr existiert.
Im Beispiel sind das zehn Läufe. Ein Raster über alle möglichen CO₂-Werte bräuchte fast
tausend, ein Gewichtsraster findet auch mit 1 401 Läufen nur sechs Punkte.
> **🎯 Merksatz**
> ε-Constraint ist das Arbeitspferd der Mehrzieloptimierung: vollständig, mit minimal vielen
> Solverläufen, und ohne dass jemand ein Gewicht erfinden muss.
### Wenn es eine klare Rangfolge gibt: lexikografisch
Manchmal ist ein Ziel wirklich vorrangig. Dann optimiert man **lexikografisch**{idx:Lexikografische Optimierung}: erst das
erste Ziel, dann das zweite unter der Bedingung, dass das erste (fast) erhalten bleibt.
| Kostenbudget | Kosten | CO₂ | gegenüber dem Minimum |
| ---: | ---: | ---: | ---: |
| +0 % | 13 543 € | 6 475 kg | — |
| +1 % | 13 635 € | 6 317 kg | 158 kg weniger |
| +2 % | 13 724 € | 6 022 kg | 453 kg weniger |
| +5 % | 14 189 € | 5 632 kg | 843 kg weniger |
| +10 % | 14 821 € | 5 506 kg | 969 kg weniger |
Der Sprung von +1 % auf +2 % ist der interessante: Das zweite Prozent bringt fast dreimal so
viel wie das erste. Solche Unregelmäßigkeiten sind bei ganzzahligen Modellen die Regel — und
ein weiteres Argument gegen Gewichte, die eine gleichmäßige Abwägung suggerieren.
Beachten Sie außerdem: Die Zeile „+0 %" ist ein Sonderfall, der sich auszahlt. Sie sucht
unter allen **kostenminimalen** Plänen den saubersten. Gibt es mehrere optimale Lösungen —
und das ist bei ganzzahligen Modellen fast immer so —, bekommt man die CO₂-Ersparnis
geschenkt. Ein Solver, der nur ein Ziel kennt, gibt einfach die erstbeste zurück.
---
## Wie man die Front vorlegt {#sec:mehrziel-vorlegen}
Eine Front mit zehn Punkten ist kein Ergebnis, sondern eine Speisekarte. Damit sie
entscheidbar wird, gehört eine Spalte dazu, die im Programm `EUR je kg` heißt:
$$
\text{Schattenpreis} = \frac{\text{Aufpreis gegenüber dem Kostenminimum}}
{\text{eingesparte Kilogramm}}
$$
Diese Zahl ist mit etwas vergleichbar, das das Unternehmen ohnehin kennt: dem internen
CO₂-Preis oder dem Zertifikatspreis. Liegt der Schattenpreis darunter, ist die Entscheidung
kaufmännisch schon getroffen.
> **💻 Code-Durchgang: die drei Bausteine**
>
> | Stelle | Was sie tut |
> | --- | --- |
> | `plane(ziel, co2_grenze, kosten_grenze)` | **eine** Funktion für alle Varianten. Welches Ziel und welche Schranke — mehr Unterschied gibt es zwischen den fünf Teilen des Programms nicht |
> | `pareto_front()` | das ε-Verfahren: Grenze jeweils um 1 kg unter den zuletzt erreichten Wert drücken, bis nichts mehr geht |
> | `untere_huelle()` | rechnet die Hülle **unabhängig** aus, damit die Aussage „unerreichbar“ nicht auf dem Gewichtsraster beruht, sondern auf Geometrie |
>
> Der Vergleich am Ende von Teil 4 ist bewusst ein `assert`-artiger Abgleich: Wenn
> Gewichtsraster und Hüllenrechnung verschiedene Punktmengen liefern, stimmt eines von
> beidem nicht — und das steht dann in der Ausgabe.
---
## Übungsaufgaben {#sec:mehrziel-uebungsaufgaben}
> Lösungen: {ref:sec:loesungen-mehrziel}.
**Aufgabe ⭐ — Dominanz prüfen.**
Plan P kostet 14 000 € und stößt 6 000 kg aus, Plan Q kostet 13 800 € und stößt 6 100 kg
aus. Dominiert einer den anderen? Und wie steht es mit R (14 000 €, 6 100 kg)?
**Aufgabe ⭐ — Der Schattenpreis in der Praxis.**
Der interne CO₂-Preis des Unternehmens beträgt 0,90 € je Kilogramm. Welchen Punkt der Front
würden Sie empfehlen — und wie begründen Sie ihn in einem Satz?
**Aufgabe ⭐⭐ — Mehr Trassen.**
Erhöhen Sie `BAHN_TRASSEN` von 5 auf 8. Was passiert mit der Länge der Front und mit der
Zahl der unerreichbaren Punkte? Erklären Sie den Zusammenhang.
**Aufgabe ⭐⭐ — Die Front der Relaxation.**
Lassen Sie `integrality` weg, sodass Anteile erlaubt sind. Berechnen Sie die Front erneut
(mit einem ε-Raster, da es nun unendlich viele Punkte gibt). Wie viele Punkte liegen jetzt
oberhalb der konvexen Hülle — und warum?
**Aufgabe ⭐⭐⭐ — Drei Ziele.**
Ergänzen Sie die Laufzeit als drittes Ziel (LKW schnell, Bahn langsam). Die Front wird zu
einer Fläche. Wie ändert sich das ε-Verfahren, und warum wächst der Aufwand so schnell?
---
## Finde den Denkfehler {#sec:mehrziel-denkfehler}
> **🐛 „Wir haben die Gewichte sauber kalibriert"**
>
> Ein Team baut ein Mehrzielmodell für die Tourenplanung: Kosten gegen CO₂. Um das Gewicht
> nicht willkürlich zu setzen, geht es methodisch vor:
>
> > *„Wir haben $w$ in 500 Schritten von 0 bis 5 durchgerechnet und für jeden Wert den
> > optimalen Plan bestimmt. Dann haben wir der Geschäftsführung alle so gefundenen Pläne
> > vorgelegt und sie hat einen ausgewählt. Damit ist die Entscheidung nicht von uns
> > getroffen worden, sondern vom Fachbereich — und wir haben den Lösungsraum vollständig
> > abgetastet."*
>
> Das Vorgehen ist sorgfältig, die Rechnung stimmt, und die Beteiligung des Fachbereichs ist
> vorbildlich.
>
> **Trotzdem stimmt die letzte Behauptung nicht. Warum — und was hätte das Team stattdessen
> tun müssen?**
>
> Und ein zweiter Teil, der schwerer wiegt: Angenommen, unter den nicht gefundenen Plänen
> wäre einer gewesen, der die Geschäftsführung überzeugt hätte. **Hätte irgendjemand das
> bemerkt?**
---
## Micro-Quiz {#sec:mehrziel-quiz}
> **❓ Drei Fragen**
>
> **1. Warum findet die gewichtete Summe nicht alle Pareto-Punkte?**
> a) Weil das Gewichtsraster zwangsläufig zu grob ist.
> b) Weil sie nur Punkte auf der unteren konvexen Hülle erreichen kann — Punkte darüber
> werden von keiner Geraden gestützt.
> c) Weil Solver bei gemischten Zielfunktionen numerisch ungenau werden.
>
> **2. Wie viele Solverläufe braucht das ε-Constraint-Verfahren in der gezeigten Form?**
> a) So viele, wie das ε-Raster Schritte hat.
> b) So viele, wie die Pareto-Front Punkte hat, plus einen.
> c) Genau zwei — einen je Ziel.
>
> **3. Die Zeile „+0 % Kostenbudget" in der lexikografischen Tabelle liefert weniger CO₂ als
> das reine Kostenminimum. Wie kann das sein?**
> a) Ein Rundungsfehler im Solver.
> b) Es gibt mehrere kostenminimale Pläne; unter ihnen wird der sauberste gewählt.
> c) Das Kostenbudget ist durch die Prozentrechnung minimal größer.
---
## Selbsttest {#sec:mehrziel-selbsttest}
1. Erklären Sie Pareto-Dominanz an einem Beispiel aus Ihrem Arbeitsumfeld.
2. Warum ist „gewichtete Summe" bei einem reinen LP unproblematisch und bei einem MILP nicht?
3. Ein Kollege sagt: „Ich nehme $w = 0{,}5$, das ist neutral." Was ist daran falsch?
4. Beschreiben Sie das ε-Constraint-Verfahren in drei Sätzen, ohne Formeln.
5. Sie legen einer Geschäftsführung eine Front mit zwölf Punkten vor. Welche Spalte brauchen
Sie, damit die Entscheidung fallen kann?
---
## Zusammenfassung {#sec:mehrziel-zusammenfassung}
* Bei mehreren Zielen ist „besser" nur noch über **Dominanz** definiert. Das vollständige
Ergebnis ist keine Lösung, sondern die **Pareto-Front**.
* Die **gewichtete Summe** findet nur Punkte auf der unteren konvexen Hülle. Bei
ganzzahligen Modellen liegen regelmäßig Pareto-Punkte darüber — im Beispiel 4 von 10, also
40 % der Alternativen. Das ist kein Rasterproblem, sondern Geometrie.
* Das Gewicht ist außerdem **keine Feineinstellung**: 1 401 Gewichte erzeugten sechs
verschiedene Pläne, mit Sprüngen dazwischen.
* Das **ε-Constraint-Verfahren** macht aus dem zweiten Ziel eine Schranke und liefert die
vollständige Front mit so vielen Solverläufen, wie sie Punkte hat.
* **Lexikografisch** optimiert man, wenn es eine echte Rangfolge gibt — „höchstens 2 % teurer,
dafür so sauber wie möglich" ist eine Frage, die ein Kaufmann beantworten kann.
* Die Front wird erst entscheidbar durch die Spalte **€ je kg**: Sie ist mit dem internen
CO₂-Preis vergleichbar und verwandelt eine Wertefrage in eine Rechnung.

View file

@ -0,0 +1,702 @@
# Kapitel: Predict-then-Optimize — die bessere Prognose, die schlechtere Entscheidung {#kap:prognose}
> **📌 Kapitel auf einen Blick**
>
> **Worum geht es?** Um die Naht zwischen zwei Welten, die in getrennten Abteilungen sitzen:
> Ein Modell **prognostiziert**, ein anderes **entscheidet**. Beide arbeiten sauber — und
> genau an der Naht entsteht ein Fehler, den keine der beiden Seiten sieht.
>
> **Voraussetzungen:** {ref:kap:unsicherheit}, insbesondere das Newsvendor-Problem und das
> kritische Verhältnis. Etwas Regression hilft, ist aber nicht nötig.
>
> **Danach können Sie:** begründen, warum ein Prognosemodell mit kleinerem MSE teurere
> Entscheidungen erzeugen kann; die richtige **Zielgröße** einer Prognose bestimmen; und
> Prognosemodelle an Entscheidungskosten statt an Fehlermaßen bewerten.
>
> **Zeitbedarf:** ca. 3,5 Stunden.
>
> **Programme:**\
> `Predict_then_Optimize.py`
---
## In 5 Minuten gelöst {#sec:prognose-schnellstart}
> **🚀 In 5 Minuten gelöst: Der Durchschnitt ist die falsche Zahl**
>
> Eine Bäckerei kennt die Nachfrage der letzten 20 Tage. Ein Brot bringt 6 € Marge, ein
> übriges kostet 3 € Einkauf. Wie viele soll sie ansetzen?
>
> Die naheliegende Antwort — den Durchschnitt — ist nachweislich falsch:
>
> ```python
> import numpy as np
>
> nachfrage = np.array([104, 138, 96, 151, 118, 127, 143, 109, 162, 121, # 20 Tage
> 133, 115, 148, 102, 129, 156, 111, 140, 124, 135])
> preis, einkauf = 9.0, 3.0
> fehl, ueber = preis - einkauf, einkauf # 6 EUR zu wenig, 3 EUR zu viel
>
> kosten = lambda menge: (fehl * np.maximum(0, nachfrage - menge)
> + ueber * np.maximum(0, menge - nachfrage)).mean()
>
> erwartungswert = round(nachfrage.mean())
> kritisch = round(np.quantile(nachfrage, fehl / (fehl + ueber)))
> print(f"Erwartungswert bestellen: {erwartungswert} Stueck -> {kosten(erwartungswert):5.2f} EUR/Tag")
> print(f"kritisches Quantil : {kritisch} Stueck -> {kosten(kritisch):5.2f} EUR/Tag")
> print(f"bestmoegliche feste Menge: {int(np.argmin([kosten(m) for m in range(200)]))} Stueck"
> f" -> {min(kosten(m) for m in range(200)):5.2f} EUR/Tag")
> ```
>
> **Ausgabe:**
>
> ```
> Erwartungswert bestellen: 128 Stueck -> 69.45 EUR/Tag
> kritisches Quantil : 137 Stueck -> 62.25 EUR/Tag
> bestmoegliche feste Menge: 138 Stueck -> 62.10 EUR/Tag
> ```
**Und jetzt der Punkt.** Der Durchschnitt ist perfekt bestimmt — es ist der Mittelwert
derselben Daten, aus denen auch das Quantil kommt. Trotzdem kostet er **7,20 € je Tag mehr**,
gut 10 %.
Der Grund steht in den Preisen: Ein fehlendes Brot kostet doppelt so viel wie ein übriges.
Bei asymmetrischen Kosten liegt die optimale Menge nicht in der Mitte der Verteilung,
sondern beim **kritischen Verhältnis** $c_-/(c_- + c_+) = 6/9 = 66{,}7\,\%$ — das kennen Sie
aus {ref:kap:unsicherheit}.
Bemerkenswert ist die dritte Zeile: Die Quantilregel trifft mit 137 fast genau die beste
überhaupt mögliche feste Menge (138). Sie ist nicht ungefähr richtig, sie ist richtig.
> **🎯 Merksatz**
> Eine Prognose ist kein Selbstzweck. Sie liefert eine Zahl, die in eine Entscheidung
> eingeht — und **welche** Zahl das sein muss, bestimmt die Entscheidung, nicht die
> Statistik.
**Warum funktioniert das?** Weil die Kostenfunktion des Newsvendors geknickt und
unsymmetrisch ist. Der Mittelwert minimiert den *quadratischen* Fehler; die Kosten sind aber
stückweise linear mit verschiedenen Steigungen nach oben und unten. Zwei verschiedene
Zielfunktionen haben zwei verschiedene Optima — das ist kein Paradox, sondern Arithmetik.
---
## Lernziele {#sec:prognose-lernziele}
Nach diesem Kapitel können Sie …
1. … die Zweiteilung *predict* / *optimize* benennen und sagen, wo dabei Information
verloren geht.
2. … begründen, warum das MSE-beste Modell nicht das kostenbeste sein muss.
3. … die richtige Prognosegröße aus der Entscheidung ableiten statt aus Gewohnheit.
4. … erklären, warum ein pauschaler Sicherheitszuschlag schwächer ist als ein Modell, das
die Unsicherheit aus den Merkmalen liest.
5. … Prognosemodelle an **Entscheidungskosten** messen — und wissen, wie viele Testdaten ein
solcher Vergleich braucht.
---
## Die Naht zwischen zwei Abteilungen {#sec:prognose-naht}
In fast jedem Unternehmen sieht der Ablauf so aus:
```
Verkaufsdaten -> [ PROGNOSE ] -> Nachfrageschätzung -> [ PLANUNG ] -> Bestellung
Data Science Disposition
```
Beide Seiten arbeiten sorgfältig. Die Prognoseabteilung optimiert ihr Modell auf ein
**Fehlermaß** — meist den mittleren quadratischen Fehler (MSE) oder MAPE — und berichtet
stolz eine Verbesserung von 12 %. Die Disposition nimmt die Zahl entgegen und rechnet ihre
Bestellmenge aus.
Der Fehler steckt nicht in einer der beiden Hälften, sondern in der Naht:
> **📖 Definition: Predict-then-Optimize**{idx:Predict-then-Optimize}
>
> Das übliche zweistufige Vorgehen: erst eine unbekannte Größe schätzen, dann mit der
> Schätzung optimieren, als wäre sie die Wahrheit.
>
> Das Problem: Die erste Stufe wird auf ein **statistisches** Maß trainiert, die zweite
> erzeugt **ökonomische** Kosten. Niemand garantiert, dass ein besseres statistisches Maß zu
> geringeren Kosten führt — und in diesem Kapitel ist es nachweislich umgekehrt.
> **⚠️ Warum das nicht auffällt**
>
> Beide Abteilungen erfüllen ihre Kennzahl. Die Prognose wird besser (MSE sinkt), die
> Disposition arbeitet korrekt (sie wendet die Formel richtig an). Es gibt keinen Ort, an
> dem der Verlust sichtbar würde — außer man misst die Entscheidungskosten, und das ist
> niemandes Kennzahl.
>
> Das ist dieselbe Struktur wie in {ref:sec:testing-denkfehler}: Der Fehler entsteht
> zwischen zwei Zuständigkeiten und wird deshalb von keiner Prüfung gefunden, die innerhalb
> einer der beiden liegt.
---
## Das Programm {#sec:prognose-programm}
Dieselbe Bäckerei, aber mit Merkmalen: Wochentag, Temperatur, Aktionstage. Die Nachfrage
muss jetzt **modelliert** werden.
Eine Eigenschaft der Daten ist dabei entscheidend und in der Praxis der Normalfall: An
Aktionstagen ist die Nachfrage nicht nur höher, sondern auch **viel unsicherer**
(heteroskedastisch).
```python
#!/usr/bin/env python3
# Predict_then_Optimize.py
"""
Kapitel Prognose: Die bessere Prognose trifft die schlechtere Entscheidung.
Eine Baeckerei muss jeden Abend entscheiden, wie viel sie fuer den naechsten Tag
ansetzt. Zu wenig kostet die Marge des entgangenen Verkaufs, zu viel kostet den
Einkaufspreis der Retoure. Das ist das Newsvendor-Problem aus dem Kapitel
Unsicherheit - nur dass die Nachfrage diesmal nicht aus einer Verteilung kommt,
sondern PROGNOSTIZIERT werden muss: aus Wochentag, Temperatur und Aktionstagen.
Damit zerfaellt die Aufgabe in zwei Schritte, und genau an der Naht entsteht der
Fehler, um den es hier geht:
PREDICT ein Modell schaetzt die Nachfrage
OPTIMIZE daraus wird eine Bestellmenge
Der Prognostiker optimiert seinen Modellfehler, meist den MSE. Der Planer traegt
die Kosten. Beide messen etwas anderes - und die beiden Masse widersprechen
einander. Das Programm zeigt:
1. Vier Verfahren, verglichen nach MSE UND nach Entscheidungskosten. Das
Verfahren mit dem BESTEN MSE hat die HOECHSTEN Kosten.
2. Warum ein pauschaler Sicherheitszuschlag zu kurz greift - die Streuung der
Nachfrage haengt selbst von den Merkmalen ab.
3. Eine Messfalle, in die der Autor dieses Programms zuerst selbst getappt
ist: Bei kurzen Testzeitraeumen ist der MSE-Vergleich nicht stabil.
Benoetigt: numpy, scipy, scikit-learn
"""
from __future__ import annotations
import numpy as np
from scipy.stats import norm
from sklearn.linear_model import LinearRegression, QuantileRegressor
VERKAUFSPREIS = 9.0
EINKAUFSPREIS = 3.0
KOSTEN_FEHLMENGE = VERKAUFSPREIS - EINKAUFSPREIS # entgangene Marge: 6 EUR
KOSTEN_UEBERHANG = EINKAUFSPREIS # Retoure: 3 EUR
KRITISCHES_VERHAELTNIS = KOSTEN_FEHLMENGE / (KOSTEN_FEHLMENGE + KOSTEN_UEBERHANG)
TAGE = 5000 # Simulation, siehe Hinweis unten
TRAINING = 1000
SAAT = 11
def erzeuge_daten(tage: int = TAGE, saat: int = SAAT):
"""Taegliche Nachfrage mit Wochentag, Temperatur und Aktionstagen.
Die entscheidende Eigenschaft steckt in 'streuung': An Aktionstagen ist die
Nachfrage nicht nur hoeher, sondern auch viel UNSICHERER. Solche
heteroskedastischen Daten sind der Normalfall - und der Grund, warum ein
pauschaler Sicherheitszuschlag nicht genuegt.
"""
rng = np.random.default_rng(saat)
wochentag = np.arange(tage) % 7
temperatur = (12 + 10 * np.sin(2 * np.pi * np.arange(tage) / 365)
+ rng.normal(0, 3, tage))
aktion = (rng.random(tage) < 0.15).astype(float)
merkmale = np.column_stack([np.eye(7)[wochentag][:, 1:], temperatur, aktion])
erwartung = (120
+ np.eye(7)[wochentag] @ np.array([0, 10, 12, 14, 18, 35, -40])
+ 1.8 * temperatur + 45 * aktion)
streuung = 8 + 22 * aktion
nachfrage = np.maximum(0.0, erwartung + rng.normal(0, 1, tage) * streuung)
return merkmale, nachfrage, aktion
def tageskosten(bestellung: np.ndarray, nachfrage: np.ndarray) -> float:
"""Die Zahl, auf die es ankommt - und die kein Prognosemass kennt."""
fehlmenge = np.maximum(0.0, nachfrage - bestellung)
ueberhang = np.maximum(0.0, bestellung - nachfrage)
return float((KOSTEN_FEHLMENGE * fehlmenge
+ KOSTEN_UEBERHANG * ueberhang).mean())
if __name__ == "__main__":
merkmale, nachfrage, aktion = erzeuge_daten()
lernen = slice(0, TRAINING)
pruefen = slice(TRAINING, TAGE)
print("=" * 84)
print(" DIE BESSERE PROGNOSE TRIFFT DIE SCHLECHTERE ENTSCHEIDUNG")
print("=" * 84)
print(f"Verkaufspreis {VERKAUFSPREIS:.0f} EUR, Einkauf {EINKAUFSPREIS:.0f} EUR.")
print(f"Fehlmenge kostet {KOSTEN_FEHLMENGE:.0f} EUR, Ueberhang "
f"{KOSTEN_UEBERHANG:.0f} EUR je Stueck.")
print(f"Kritisches Verhaeltnis: {KRITISCHES_VERHAELTNIS:.3f} - der Planer sollte "
f"also das")
print(f"{KRITISCHES_VERHAELTNIS:.1%}-Quantil der Nachfrage bestellen, nicht ihren "
f"Erwartungswert.")
print(f"\nTraining: Tag 1 bis {TRAINING}. Bewertung: die restlichen "
f"{TAGE - TRAINING} Tage.\n")
# --- Die vier Verfahren ----------------------------------------------
kleinste_quadrate = LinearRegression().fit(merkmale[lernen], nachfrage[lernen])
punktprognose = kleinste_quadrate.predict(merkmale[pruefen])
restfehler = nachfrage[lernen] - kleinste_quadrate.predict(merkmale[lernen])
pauschalzuschlag = norm.ppf(KRITISCHES_VERHAELTNIS) * restfehler.std()
# Ein Zuschlag, der nicht aus der Normalverteilung kommt, sondern direkt
# auf den Trainingsdaten die Kosten minimiert.
kandidaten = np.linspace(-10.0, 30.0, 401)
trainingsprognose = kleinste_quadrate.predict(merkmale[lernen])
kostenzuschlag = float(kandidaten[np.argmin(
[tageskosten(trainingsprognose + z, nachfrage[lernen]) for z in kandidaten])])
# Und das Verfahren, das von vornherein das richtige Quantil schaetzt.
quantilmodell = QuantileRegressor(quantile=KRITISCHES_VERHAELTNIS,
alpha=0.0, solver="highs")
quantilmodell.fit(merkmale[lernen], nachfrage[lernen])
quantilprognose = quantilmodell.predict(merkmale[pruefen])
verfahren = [
("bestelle die Punktprognose", punktprognose, punktprognose),
("+ Zuschlag aus der Normalverteilung",
punktprognose, punktprognose + pauschalzuschlag),
("+ Zuschlag auf Kosten trainiert",
punktprognose, punktprognose + kostenzuschlag),
("Quantilregression aufs kritische Quantil",
quantilprognose, quantilprognose),
]
print(f" {'Verfahren':<42} {'MSE':>9} {'Kosten/Tag':>12} {'gegen Zeile 1':>14}")
print(" " + "-" * 80)
ergebnisse = {}
for name, prognose, bestellung in verfahren:
mse = float(((prognose - nachfrage[pruefen]) ** 2).mean())
kosten = tageskosten(bestellung, nachfrage[pruefen])
ergebnisse[name] = (mse, kosten)
basis = ergebnisse[verfahren[0][0]][1]
vergleich = "" if name == verfahren[0][0] else f"{(kosten - basis) / basis:+13.1%}"
print(f" {name:<42} {mse:>9.1f} {kosten:>10.2f} EUR {vergleich:>14}")
bester_mse = min(ergebnisse, key=lambda k: ergebnisse[k][0])
beste_kosten = min(ergebnisse, key=lambda k: ergebnisse[k][1])
print(f"\n bester MSE: {bester_mse}")
print(f" beste Kosten: {beste_kosten}")
print(f"\n Das Verfahren mit dem besten MSE hat die HOECHSTEN Kosten, und das")
print(f" Verfahren mit den besten Kosten hat einen um "
f"{(ergebnisse[beste_kosten][0] / ergebnisse[bester_mse][0] - 1):.0%} SCHLECHTEREN MSE.")
print(" Wer Prognosemodelle nach MSE auswaehlt, waehlt hier das falsche.")
# --- Warum der pauschale Zuschlag zu kurz greift ---------------------
print("\n" + "-" * 84)
print("Warum ein pauschaler Zuschlag nicht genuegt\n")
ist_aktion = aktion[pruefen] > 0.5
print(f" {'Verfahren':<42} {'normale Tage':>14} {'Aktionstage':>14}")
print(" " + "-" * 74)
for name, _, bestellung in verfahren:
normal = tageskosten(bestellung[~ist_aktion], nachfrage[pruefen][~ist_aktion])
aktionstag = tageskosten(bestellung[ist_aktion], nachfrage[pruefen][ist_aktion])
print(f" {name:<42} {normal:>10.2f} EUR {aktionstag:>10.2f} EUR")
# Nachgerechnet statt behauptet: Welcher Zuschlag waere je Tagesart richtig?
aktion_training = aktion[lernen] > 0.5
z = norm.ppf(KRITISCHES_VERHAELTNIS)
richtig_normal = z * restfehler[~aktion_training].std()
richtig_aktion = z * restfehler[aktion_training].std()
print(f"\n Der pauschale Zuschlag betraegt {pauschalzuschlag:.1f} Stueck. Aus den")
print(f" Trainingsresten getrennt nach Tagesart waere richtig:")
print(f" normale Tage : {richtig_normal:5.1f} Stueck")
print(f" Aktionstage : {richtig_aktion:5.1f} Stueck")
print(f" Ein Zuschlag fuer alle Tage kann nur einen Mittelweg treffen - hier")
print(f" ist er an normalen Tagen {pauschalzuschlag / richtig_normal:.1f}-mal zu gross und an")
print(f" Aktionstagen nur {pauschalzuschlag / richtig_aktion:.0%} dessen, was noetig waere.")
print(f"\n Die Quantilregression schaetzt das {KRITISCHES_VERHAELTNIS:.1%}-Quantil "
f"direkt aus den")
print(f" Merkmalen und darf deshalb an verschiedenen Tagen verschieden weit")
print(f" ueber dem Erwartungswert liegen. Genau das ist der Unterschied")
print(f" zwischen 'ein Modell und danach eine Formel' und 'ein Modell, das")
print(f" weiss, wofuer es gebraucht wird'.")
# --- Die Messfalle ---------------------------------------------------
print("\n" + "-" * 84)
print("Eine Messfalle, in die der Autor zuerst selbst getappt ist\n")
print(" Der erste Entwurf dieses Programms bewertete auf 230 Testtagen - ein")
print(" realistischer Zeitraum. Dort hatte die Quantilregression den BESSEREN")
print(" MSE, und die ganze Aussage des Kapitels stand auf dem Kopf.")
print("\n Wie oft das passiert, laesst sich ausmessen:\n")
rng = np.random.default_rng(0)
print(f" {'Testfenster':>14} {'QR sieht MSE-besser aus':>26}")
print(" " + "-" * 42)
for fenster in (180, 365, 730, 2000):
treffer = 0
versuche = 400
for _ in range(versuche):
start = int(rng.integers(TRAINING, TAGE - fenster))
ausschnitt = slice(start, start + fenster)
mse_punkt = ((kleinste_quadrate.predict(merkmale[ausschnitt])
- nachfrage[ausschnitt]) ** 2).mean()
mse_quantil = ((quantilmodell.predict(merkmale[ausschnitt])
- nachfrage[ausschnitt]) ** 2).mean()
treffer += mse_quantil < mse_punkt
print(f" {fenster:>10} Tage {treffer / versuche:>24.1%}")
print("\n Bei einem halben Jahr Testdaten sieht das schlechtere Modell in gut")
print(" jedem zehnten Fall besser aus. Das ist keine grosse Zahl - aber wer")
print(" EINMAL misst, hat genau eine Ziehung aus dieser Verteilung.")
print("\n Die Lehre ist nicht 'nimm 4.000 Testtage' - die hat niemand. Sie")
print(" lautet: Ein Kennzahlenvergleich ohne Angabe seiner Streuung ist keine")
print(" Aussage. Bei kurzen Zeitraeumen gehoert eine Kreuzvalidierung dazu.")
print("\n" + "=" * 84)
print(" WAS MAN DARAUS MITNIMMT")
print("=" * 84)
print("Der Prognostiker optimiert den MSE, der Planer traegt die Kosten - und")
print("die beiden Masse zeigen hier in verschiedene Richtungen. Drei Saetze:")
print()
print(" 1. Sagen Sie nicht den Erwartungswert vorher, sondern die Groesse, die")
print(" in die Entscheidung eingeht. Beim Newsvendor ist das das kritische")
print(" Quantil - und das kann man direkt schaetzen.")
print(" 2. Bewerten Sie Prognosemodelle an den ENTSCHEIDUNGSKOSTEN. Die sind")
print(" in Euro und damit vergleichbar; ein MSE ist es nicht.")
print(" 3. Ein pauschaler Sicherheitszuschlag ist besser als nichts und")
print(" schlechter als ein Modell, das die Unsicherheit selbst aus den")
print(" Merkmalen liest.")
print()
print("Der naechste Schritt - Prognosemodelle so zu trainieren, dass sie die")
print("Entscheidungskosten direkt minimieren (Smart Predict-then-Optimize,")
print("differenzierbare Optimierungsschichten) - ist Forschungsstand und")
print("erfordert Bibliotheken wie cvxpylayers. Die dritte Zeile der Tabelle")
print("oben ist seine einfachste denkbare Form: ein einziger Parameter, auf")
print("Kosten statt auf Fehler trainiert.")
print("=" * 84)
```
**Erwartete Ausgabe:**
```
====================================================================================
DIE BESSERE PROGNOSE TRIFFT DIE SCHLECHTERE ENTSCHEIDUNG
====================================================================================
Verkaufspreis 9 EUR, Einkauf 3 EUR.
Fehlmenge kostet 6 EUR, Ueberhang 3 EUR je Stueck.
Kritisches Verhaeltnis: 0.667 - der Planer sollte also das
66.7%-Quantil der Nachfrage bestellen, nicht ihren Erwartungswert.
Training: Tag 1 bis 1000. Bewertung: die restlichen 4000 Tage.
Verfahren MSE Kosten/Tag gegen Zeile 1
--------------------------------------------------------------------------------
bestelle die Punktprognose 201.0 42.63 EUR
+ Zuschlag aus der Normalverteilung 201.0 39.51 EUR -7.3%
+ Zuschlag auf Kosten trainiert 201.0 39.14 EUR -8.2%
Quantilregression aufs kritische Quantil 227.6 38.08 EUR -10.7%
bester MSE: bestelle die Punktprognose
beste Kosten: Quantilregression aufs kritische Quantil
Das Verfahren mit dem besten MSE hat die HOECHSTEN Kosten, und das
Verfahren mit den besten Kosten hat einen um 13% SCHLECHTEREN MSE.
Wer Prognosemodelle nach MSE auswaehlt, waehlt hier das falsche.
------------------------------------------------------------------------------------
Warum ein pauschaler Zuschlag nicht genuegt
Verfahren normale Tage Aktionstage
--------------------------------------------------------------------------
bestelle die Punktprognose 29.67 EUR 108.59 EUR
+ Zuschlag aus der Normalverteilung 27.53 EUR 100.49 EUR
+ Zuschlag auf Kosten trainiert 26.67 EUR 102.58 EUR
Quantilregression aufs kritische Quantil 26.49 EUR 97.07 EUR
Der pauschale Zuschlag betraegt 5.9 Stueck. Aus den
Trainingsresten getrennt nach Tagesart waere richtig:
normale Tage : 3.5 Stueck
Aktionstage : 12.3 Stueck
Ein Zuschlag fuer alle Tage kann nur einen Mittelweg treffen - hier
ist er an normalen Tagen 1.7-mal zu gross und an
Aktionstagen nur 48% dessen, was noetig waere.
Die Quantilregression schaetzt das 66.7%-Quantil direkt aus den
Merkmalen und darf deshalb an verschiedenen Tagen verschieden weit
ueber dem Erwartungswert liegen. Genau das ist der Unterschied
zwischen 'ein Modell und danach eine Formel' und 'ein Modell, das
weiss, wofuer es gebraucht wird'.
------------------------------------------------------------------------------------
Eine Messfalle, in die der Autor zuerst selbst getappt ist
Der erste Entwurf dieses Programms bewertete auf 230 Testtagen - ein
realistischer Zeitraum. Dort hatte die Quantilregression den BESSEREN
MSE, und die ganze Aussage des Kapitels stand auf dem Kopf.
Wie oft das passiert, laesst sich ausmessen:
Testfenster QR sieht MSE-besser aus
------------------------------------------
180 Tage 11.8%
365 Tage 5.5%
730 Tage 0.0%
2000 Tage 0.0%
Bei einem halben Jahr Testdaten sieht das schlechtere Modell in gut
jedem zehnten Fall besser aus. Das ist keine grosse Zahl - aber wer
EINMAL misst, hat genau eine Ziehung aus dieser Verteilung.
Die Lehre ist nicht 'nimm 4.000 Testtage' - die hat niemand. Sie
lautet: Ein Kennzahlenvergleich ohne Angabe seiner Streuung ist keine
Aussage. Bei kurzen Zeitraeumen gehoert eine Kreuzvalidierung dazu.
====================================================================================
WAS MAN DARAUS MITNIMMT
====================================================================================
Der Prognostiker optimiert den MSE, der Planer traegt die Kosten - und
die beiden Masse zeigen hier in verschiedene Richtungen. Drei Saetze:
1. Sagen Sie nicht den Erwartungswert vorher, sondern die Groesse, die
in die Entscheidung eingeht. Beim Newsvendor ist das das kritische
Quantil - und das kann man direkt schaetzen.
2. Bewerten Sie Prognosemodelle an den ENTSCHEIDUNGSKOSTEN. Die sind
in Euro und damit vergleichbar; ein MSE ist es nicht.
3. Ein pauschaler Sicherheitszuschlag ist besser als nichts und
schlechter als ein Modell, das die Unsicherheit selbst aus den
Merkmalen liest.
Der naechste Schritt - Prognosemodelle so zu trainieren, dass sie die
Entscheidungskosten direkt minimieren (Smart Predict-then-Optimize,
differenzierbare Optimierungsschichten) - ist Forschungsstand und
erfordert Bibliotheken wie cvxpylayers. Die dritte Zeile der Tabelle
oben ist seine einfachste denkbare Form: ein einziger Parameter, auf
Kosten statt auf Fehler trainiert.
====================================================================================
```
---
## Der Befund {#sec:prognose-befund}
| Verfahren | MSE | Kosten je Tag |
| --- | ---: | ---: |
| bestelle die Punktprognose | **201,0** (bester) | 42,63 € (schlechteste) |
| + Zuschlag aus der Normalverteilung | 201,0 | 39,51 € |
| + Zuschlag auf Kosten trainiert | 201,0 | 39,14 € |
| Quantilregression aufs kritische Quantil | 227,6 (schlechtester) | **38,08 €** (beste) |
**Das Modell mit dem besten MSE hat die höchsten Kosten.** Das kostenbeste Modell hat einen
um 13 % schlechteren MSE — und spart 10,7 % der Kosten.
Wer Prognosemodelle nach MSE auswählt (und das ist die Voreinstellung jeder
Modellauswahl-Bibliothek), wählt hier das falsche.
> **🔤 Formel-Übersetzer: zwei Zielfunktionen**
>
> | Mathematik | Was sie belohnt |
> | --- | --- |
> | $\min \sum_t (\hat{d}_t - d_t)^2$ | „Liege im Mittel richtig." Abweichungen nach oben und unten zählen gleich, große Abweichungen überproportional. |
> | $\min \sum_t \big[c_-(d_t - q_t)^+ + c_+(q_t - d_t)^+\big]$ | „Sei lieber etwas zu großzügig als etwas zu knapp." Beide Richtungen zählen **verschieden** und beide nur linear. |
> | $(x)^+$ | „nur der positive Teil" — also $\max(0, x)$. |
> | $c_- \ne c_+$ | Der ganze Grund, warum die beiden Optima auseinanderfallen. |
>
> Wären $c_-$ und $c_+$ gleich groß, läge das Optimum beim **Median** — immer noch nicht beim
> Mittelwert, aber wenigstens in der Mitte. Erst die Asymmetrie schiebt es ans Quantil.
### Warum ein pauschaler Zuschlag zu kurz greift
Die zweite Zeile der Tabelle ist die verbreitete Praxis: Punktprognose plus
Sicherheitsbestand. Sie hilft (7,3 %), bleibt aber hinter der Quantilregression zurück.
Das Programm rechnet nach, warum:
| | benötigter Zuschlag |
| --- | ---: |
| normale Tage | 3,5 Stück |
| Aktionstage | 12,3 Stück |
| **pauschal verwendet** | **5,9 Stück** |
Ein einziger Wert kann nur einen Mittelweg treffen: An normalen Tagen ist er 1,7-mal zu
groß, an Aktionstagen erreicht er nur 48 % dessen, was nötig wäre.
Die Quantilregression schätzt das 66,7-%-Quantil **direkt aus den Merkmalen** und darf
deshalb an verschiedenen Tagen verschieden weit über dem Erwartungswert liegen. Das ist der
Unterschied zwischen *„ein Modell und danach eine Formel"* und *„ein Modell, das weiß, wofür
es gebraucht wird"*.
> **🎯 Merksatz**
> Sagen Sie nicht den Erwartungswert vorher, sondern **die Größe, die in die Entscheidung
> eingeht**. Beim Newsvendor ist das das kritische Quantil — und das lässt sich direkt
> schätzen, statt es nachträglich aus einer Punktprognose zu basteln.
---
## Eine Messfalle, in die der Autor selbst getappt ist {#sec:prognose-messfalle}
Der erste Entwurf dieses Programms bewertete auf **230 Testtagen** — ein realistischer
Zeitraum, gut sieben Monate. Dort hatte die Quantilregression den *besseren* MSE, und die
ganze Aussage dieses Kapitels stand auf dem Kopf.
Das war kein Denkfehler, sondern Zufall. Wie viel Zufall, lässt sich ausmessen:
| Testfenster | Quantilregression sieht MSE-besser aus |
| ---: | ---: |
| 180 Tage | 11,8 % |
| 365 Tage | 5,5 % |
| 730 Tage | 0,0 % |
| 2 000 Tage | 0,0 % |
Bei einem halben Jahr Testdaten sieht das (bezogen auf den MSE) schlechtere Modell in gut
jedem zehnten Fall besser aus. Das ist keine große Zahl — aber **wer einmal misst, hat genau
eine Ziehung aus dieser Verteilung.**
> **⚠️ Was daraus folgt — und was nicht**
>
> Die Lehre ist **nicht** „nimm 4 000 Testtage". Die hat niemand; das Programm arbeitet
> deshalb mit simulierten Daten und sagt das auch.
>
> Die Lehre ist: **Ein Kennzahlenvergleich ohne Angabe seiner Streuung ist keine Aussage.**
> Bei kurzen Zeiträumen gehört eine Kreuzvalidierung dazu, und der Unterschied zwischen zwei
> Modellen muss größer sein als die Schwankung zwischen zwei Zeitfenstern.
>
> Verwandt, aber nicht dasselbe: {ref:kap:handelsmaschine}, `Data_Snooping.py`. Dort geht es
> um die Zahl der *Versuche*, hier um die Zahl der *Beobachtungen*. Beide Male führt eine
> weggelassene Angabe zu einer Aussage, die nicht trägt.
---
## Wie weit das Verfahren reicht {#sec:prognose-ausblick}
Die dritte Zeile der Ergebnistabelle — „Zuschlag auf Kosten trainiert" — ist die einfachste
denkbare Form einer Idee, die derzeit erforscht wird: **das Prognosemodell direkt auf die
Entscheidungskosten zu trainieren** statt auf ein Fehlermaß.
Hier war es ein einziger Parameter, mit einer Rasterschleife bestimmt. Der allgemeine Fall
ist schwieriger, weil die Optimierung zwischen Modell und Kosten steht: Um den Gradienten
der Kosten nach den Modellparametern zu bilden, muss man **durch das Optimierungsproblem
hindurch ableiten**.
| Ansatz | Idee | Bibliothek |
| --- | --- | --- |
| **Richtige Zielgröße** (dieses Kapitel) | Schätze das Quantil, das die Entscheidung braucht | `sklearn` |
| **SPO+** (Elmachtoub/Grigas) | Ein Ersatzverlust, der die Entscheidungskosten nach oben abschätzt und konvex ist | — |
| **Differenzierbare Optimierung** | Das Optimierungsproblem wird eine Schicht im neuronalen Netz | `cvxpylayers` |
> **💡 Womit man anfängt**
>
> Nicht mit `cvxpylayers`. Der Ertrag der ersten Zeile ist in der Praxis meist der größte
> und kostet einen Nachmittag: Fragen Sie, welche Größe die Entscheidung wirklich braucht,
> und schätzen Sie diese. In diesem Kapitel bringt das 10,7 % — die aufwendigen Verfahren
> ringen danach um die letzten Prozentpunkte.
---
## Übungsaufgaben {#sec:prognose-uebungsaufgaben}
> Lösungen: {ref:sec:loesungen-prognose}.
**Aufgabe ⭐ — Andere Preise.**
Die Bäckerei kann übrige Ware am Folgetag zum halben Preis abgeben; der Überhang kostet
dann nur noch 1,50 € statt 3 €. Wie ändert sich das kritische Verhältnis, und in welche
Richtung verschiebt sich die Bestellmenge?
**Aufgabe ⭐ — Wann ist der Mittelwert richtig?**
Nennen Sie die Bedingung, unter der Erwartungswert und optimale Bestellmenge zusammenfallen.
Wie realistisch ist sie?
**Aufgabe ⭐⭐ — Die Kennzahl der Prognoseabteilung.**
Ersetzen Sie im Programm den MSE durch den MAPE (mittlerer absoluter prozentualer Fehler).
Ändert sich die Rangfolge der vier Verfahren? Begründen Sie, warum das kein Zufall ist.
**Aufgabe ⭐⭐ — Zwei Quantile.**
Schätzen Sie zusätzlich das 5-%- und das 95-%-Quantil und zeichnen Sie für 30 Testtage ein
Band um die Punktprognose. An welchen Tagen ist es breit — und passt das zu Ihrer Erwartung?
**Aufgabe ⭐⭐⭐ — Der Wert der Merkmale.**
Entfernen Sie das Merkmal „Aktion" aus dem Modell. Wie verschlechtern sich MSE und Kosten —
und zwar **in unterschiedlichem Ausmaß**? Was sagt das über den Wert eines Merkmals aus?
---
## Finde den Denkfehler {#sec:prognose-denkfehler}
> **🐛 „Wir haben die Prognose um 18 % verbessert"**
>
> Ein Data-Science-Team stellt sein Quartalsergebnis vor:
>
> > *„Wir haben das alte lineare Modell durch ein Gradient-Boosting-Modell ersetzt. Der MSE
> > auf den Testdaten ist um 18 % gesunken, der MAPE um 14 %. Das ist die größte
> > Prognoseverbesserung, die wir je erreicht haben. Die Disposition bekommt ab nächstem
> > Monat die neuen Werte."*
>
> Sechs Wochen später meldet die Disposition, die Retouren seien gestiegen.
>
> Das Team prüft alles nach: Die Modellgüte stimmt, die Testdaten waren sauber getrennt, es
> gibt kein Leck. Das neue Modell prognostiziert die Nachfrage tatsächlich deutlich besser
> als das alte.
>
> **Wie kann eine bessere Prognose zu schlechteren Ergebnissen führen — und was hätte das
> Team messen müssen?**
>
> Ein zweiter Hinweis für den zweiten Teil der Antwort: Die Disposition rechnet mit
> Punktprognose plus einem Sicherheitszuschlag, der aus den **Residuen des alten Modells**
> stammt und seit Jahren unverändert ist.
---
## Micro-Quiz {#sec:prognose-quiz}
> **❓ Drei Fragen**
>
> **1. Warum ist der Erwartungswert beim Newsvendor die falsche Bestellmenge?**
> a) Weil er statistisch unzuverlässig geschätzt wird.
> b) Weil die Kosten für Fehlmenge und Überhang verschieden hoch sind und das Optimum
> deshalb am kritischen Quantil liegt.
> c) Weil die Nachfrage nicht normalverteilt ist.
>
> **2. Ein Kollege schlägt vor, den Sicherheitszuschlag jährlich aus den Residuen neu zu
> berechnen. Was löst das nicht?**
> a) Nichts — das ist die richtige Lösung.
> b) Der Zuschlag bleibt für alle Tage gleich, obwohl die Unsicherheit von den Merkmalen
> abhängt.
> c) Residuen sind grundsätzlich ungeeignet, um Unsicherheit zu schätzen.
>
> **3. Bei 180 Testtagen sieht das MSE-schlechtere Modell in 11,8 % der Fälle besser aus.
> Was folgt daraus für die Praxis?**
> a) Man braucht mindestens 730 Testtage, sonst ist jeder Vergleich wertlos.
> b) Ein einzelner Kennzahlenvergleich ohne Streuungsangabe ist keine belastbare Aussage;
> bei kurzen Zeiträumen gehört eine Kreuzvalidierung dazu.
> c) Der MSE ist grundsätzlich unbrauchbar.
---
## Selbsttest {#sec:prognose-selbsttest}
1. Erklären Sie in zwei Sätzen, warum „predict then optimize" eine Naht hat, an der
Information verlorengeht.
2. Welche Größe muss ein Prognosemodell liefern, wenn die Entscheidung ein Newsvendor ist —
und woher kennen Sie den Wert?
3. Warum ist ein pauschaler Sicherheitszuschlag schwächer als eine Quantilregression? Nennen
Sie die Dateneigenschaft, um die es geht.
4. Ihre Prognoseabteilung meldet 18 % besseren MSE. Welche eine Frage stellen Sie?
5. Wie viele Testdaten braucht ein Modellvergleich? Formulieren Sie die Antwort ohne eine
konkrete Zahl.
---
## Zusammenfassung {#sec:prognose-zusammenfassung}
* **Predict-then-Optimize** trennt Schätzen und Entscheiden. Die erste Stufe wird auf ein
statistisches Maß trainiert, die zweite erzeugt ökonomische Kosten — und die beiden Maße
können in verschiedene Richtungen zeigen.
* Im Beispiel hat das Verfahren mit dem **besten MSE die höchsten Kosten**; das kostenbeste
Verfahren hat einen um 13 % schlechteren MSE und spart 10,7 %.
* Der Grund ist die **Asymmetrie** der Kosten: Fehlmenge und Überhang kosten verschieden
viel, deshalb liegt das Optimum am kritischen Quantil und nicht am Erwartungswert.
* Ein **pauschaler Sicherheitszuschlag** hilft, greift aber zu kurz, wenn die Unsicherheit
selbst von den Merkmalen abhängt: Hier wäre er an normalen Tagen 1,7-mal zu groß und an
Aktionstagen halb so groß wie nötig.
* **Bewerten Sie Prognosemodelle an den Entscheidungskosten.** Sie sind in Euro und damit
vergleichbar; ein MSE ist es nicht.
* Ein Kennzahlenvergleich braucht eine **Streuungsangabe**. Bei 180 Testtagen sah das
schlechtere Modell in gut jedem zehnten Fall besser aus — und wer einmal misst, hat genau
eine Ziehung.

View file

@ -0,0 +1,737 @@
# Teil: Anwendungen — Energiewirtschaft und Finanzmärkte {#teil:anwendungen}
Dieser Teil wechselt die Domäne — aber **nicht** die Methoden. Was hier folgt, ist die
konsequente Anwendung dessen, was Sie in den Teilen II und III gelernt haben, auf zwei
Gebiete, in denen viel Unsicherheit auf ein unmittelbar messbares Ergebnis trifft: die
Energiewirtschaft und die Finanzmärkte.
Er hat drei Abschnitte, und Sie können nach jedem aufhören:
1. **Die Brücke** ({ref:kap:bruecke}) macht die Behauptung nachprüfbar, dass es dieselben
Methoden sind. Sie lässt denselben Code einmal über eine Werkstatt und einmal über ein
Depot laufen — und benennt die drei Stellen, an denen die Analogie endet.
2. **Die Industrieanwendung** ({ref:kap:supplychain}) führt die Werkzeuge an einem
zusammenhängenden Fall aus der Energiewirtschaft zusammen: Kraftwerkseinsatzplanung unter
Windunsicherheit. Wer mit Produktion, Logistik oder Energie zu tun hat, findet hier die
Vertiefung, die für die Finanzkapitel keinen Ersatz braucht.
3. **Die Finanzanwendung** (ab {ref:kap:finanzdaten}) ist die spezialisierte
Vertiefungsdomäne.
> **📌 Für wen dieser Teil gedacht ist**
>
> Die Finanzkapitel ab {ref:kap:finanzdaten} sind eine **Vertiefungsdomäne**, kein
> Pflichtstoff. Wer beruflich mit Logistik, Produktion oder Energie zu tun hat, liest die
> ersten beiden Kapitel dieses Teils und kann den Rest überspringen, ohne dass etwas
> fehlt — die Methoden sind dieselben.
>
> **Lesen Sie ihn trotzdem, wenn Sie eines der folgenden Themen brauchen:** Schätzfehler in
> Kovarianzmatrizen (das Problem tritt bei jeder Korrelationsschätzung auf, auch bei
> Lieferzeiten), Risikomaße jenseits der Standardabweichung, oder rollierende Auswertung
> mit sauberer Trennung von Trainings- und Testzeitraum.
**Ein Hinweis vorweg:** Die Kapitel ab {ref:kap:finanzdaten} laden aktuelle Kursdaten über
`yfinance` und brauchen dafür einen **Internetzugang**. Weil die Daten sich täglich ändern,
sind die abgedruckten Zahlen dort *Beispielläufe*, keine reproduzierbaren Werte — anders als
im übrigen Buch. Das Brückenkapitel kommt ohne Internet aus.
---
# Kapitel: Die Strukturbrücke — dieselbe Mathematik, zwei Welten {#kap:bruecke}
> **📌 Kapitel auf einen Blick**
>
> **Worum geht es?** Um den Nachweis, dass die Verfahren aus Teil II und III unverändert in
> der Finanzwelt funktionieren — und um die drei Stellen, an denen die Analogie endet.
>
> **Voraussetzungen:** {ref:kap:einfuehrung} (Allokation als LP), {ref:kap:unsicherheit}
> (Szenarien) und {ref:sec:praxisfallen-or-kern} (`or_kern.py`).
>
> **Danach können Sie:** ein betriebswirtschaftliches Modell in die andere Domäne
> übersetzen, ohne den Modellcode anzufassen; Schattenpreise in beiden Welten lesen; und
> benennen, welche Annahmen dabei **nicht** mitwandern.
>
> **Zeitbedarf:** ca. 2,5 Stunden.
>
> **Programme:**\
> `Strukturbruecke.py`
---
## In 5 Minuten gelöst {#sec:bruecke-schnellstart}
> **🚀 In 5 Minuten gelöst: Was ist ein Risikobudget?**
>
> Ein Anleger hat 150 000 € und will sie auf drei Anlageklassen verteilen. Neben dem Kapital
> gibt es eine zweite knappe Größe: das **Risikobudget** — eine Kennzahl, die die
> Bankaufsicht oder das eigene Regelwerk vorgibt und die jede Anlage unterschiedlich stark
> beansprucht.
>
> | Anlage | Ertrag je 1 000 € | Kapital | Risikobudget |
> | --- | ---: | ---: | ---: |
> | Aktien Welt | 75 € | 1,0 | 1,00 |
> | Anleihen | 28 € | 1,0 | 0,22 |
> | Immobilienfonds | 46 € | 1,0 | 0,55 |
> | **verfügbar** | | **150** | **90** |
>
> Lesen Sie die Tabelle noch einmal — und ersetzen Sie „Kapital" durch „Montagestunden" und
> „Risikobudget" durch „Plattenmaterial". Es ist die Schreinerei aus {ref:kap:einfuehrung},
> Wort für Wort.
>
> Deshalb genügt der vorhandene Code:
>
> ```python
> from or_kern import Produkt, Produktionsproblem, loese_mit_scipy
>
> depot = Produktionsproblem(
> produkte=[
> Produkt(name="Aktien Welt", deckungsbeitrag=75.0,
> verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 1.00}),
> Produkt(name="Anleihen", deckungsbeitrag=28.0,
> verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 0.22}),
> Produkt(name="Immobilienfonds", deckungsbeitrag=46.0,
> verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 0.55}),
> ],
> kapazitaeten={"Kapital (Tsd. EUR)": 150.0, "Risikobudget": 90.0})
>
> loesung = loese_mit_scipy(depot)
> print(loesung.als_bericht())
> print(loesung.schattenpreise)
> ```
>
> **Ausgabe:**
>
> ```
> Status: optimal | Zielwert: 7,634.62 | Gap: 0.00% | Zeit: 0.00s
> {'Kapital (Tsd. EUR)': 14.74, 'Risikobudget': 60.26}
> ```
**Und jetzt der Punkt.** Die Klasse heißt `Produktionsproblem`, und das Depot passt hinein,
ohne dass eine Zeile geändert wurde. Kein Adapter, keine Unterklasse, keine
Fallunterscheidung — die Struktur ist dieselbe, weil das Problem dasselbe ist: *knappe
Größen auf konkurrierende Verwendungen verteilen.*
Der interessanteste Teil steht in der zweiten Zeile der Ausgabe. **60,26 € je Einheit
Risikobudget** — das ist der Dualwert derselben Nebenbedingung, die in der Werkstatt sagt,
was eine zusätzliche Montagestunde wert wäre. In der Finanzwelt hat diese Zahl einen eigenen
Namen: der **Preis des Risikos**. Es ist nicht bloß eine Analogie, es ist derselbe
Schattenpreis aus {ref:kap:lp}.
> **🎯 Merksatz**
> Wenn zwei Probleme dieselbe Struktur haben, brauchen sie nicht zwei Modelle, sondern zwei
> Datensätze. Ob das der Fall ist, erkennt man daran, dass derselbe Code ohne Änderung läuft
> — nicht daran, dass eine Tabelle es behauptet.
**Warum funktioniert das?** Weil ein LP nichts über die Bedeutung seiner Zahlen weiß. Es
sieht eine Matrix, einen Kapazitätsvektor und einen Zielvektor. Ob in der Matrix Stunden,
Kilogramm oder Risikobeiträge stehen, kommt darin nicht vor.
---
## Lernziele {#sec:bruecke-lernziele}
Nach diesem Kapitel können Sie …
1. … ein Allokationsproblem aus der einen Domäne in die andere übersetzen, indem Sie die
Daten austauschen statt des Codes.
2. … den Schattenpreis in beiden Welten deuten — als Wert einer Ressource und als Preis des
Risikos.
3. … erklären, warum die CVaR-Formulierung aus {ref:kap:cvar} auf Lieferverzüge genauso
passt wie auf Kursverluste.
4. … die drei Stellen benennen, an denen die Analogie **nicht** trägt — und begründen, warum
das Übertragen von Methoden dort gefährlich wird.
---
## Das Programm {#sec:bruecke-programm}
```python
#!/usr/bin/env python3
# Strukturbruecke.py
"""
Kapitel Bruecke: Derselbe Code, zwei Welten - der Beweis statt der Behauptung.
Der Teil-Auftakt stellt eine Tabelle auf: "Ressourcen auf Produkte verteilen"
entspreche "Kapital auf Anlagen verteilen", "gegen den Worst Case absichern"
entspreche "Absicherung gegen Kursabstuerze". Solche Tabellen stehen in vielen
Buechern. Sie sind billig - und man kann sie pruefen.
Dieses Programm prueft sie. Es fuettert zweimal DENSELBEN Code mit Daten aus
zwei Welten und zeigt die Ergebnisse nebeneinander:
1. Die Allokation als LP. Das Domaenenmodell aus or_kern.py, einmal mit einer
Schreinerei (Montagestunden, Plattenmaterial) und einmal mit einem Depot
(Kapital, Risikobudget). Gleiche Klasse, gleicher Modellbauer, gleiche
Abnahmepruefung - und der Schattenpreis heisst in der einen Welt
"Wert einer zusaetzlichen Montagestunde" und in der anderen "Preis des
Risikos".
2. Die Absicherung gegen den schlechtesten Fall als CVaR. EINE Funktion,
einmal mit Kursrenditen und einmal mit Lieferverzuegen.
3. Was NICHT hinueberreicht. Der ehrliche Teil: Drei Unterschiede, die die
Analogie begrenzen - und die man kennen muss, bevor man Methoden aus
der einen Welt in die andere traegt.
ZUR SOLVERWAHL: Teil 1 benutzt 'loese_mit_scipy' und nicht 'loese_mit_glop'.
Das ist kein Zufall - CVXPY laedt fuer Teil 2 highspy, und ortools vertraegt
sich damit nicht im selben Prozess (Kapitel Oekosystem). Wer hier GLOP nimmt,
bekommt beim cvxpy-Import eine Fehlermeldung ueber ein 'undefined symbol'.
Genau deshalb laedt or_kern.py seine Solver erst beim Aufruf.
Benoetigt: numpy, cvxpy, scipy und pydantic (ueber or_kern)
"""
from __future__ import annotations
import numpy as np
from or_kern import (Produkt, Produktionsproblem, loese_mit_scipy,
pruefe_loesung)
SAAT = 7
SZENARIEN = 500
ALPHA = 0.95 # die schlechtesten 5 % der Faelle
MAX_ANTEIL = 0.40 # Streuungsgebot in beiden Welten
# --- Teil 1: Dieselbe Klasse, zwei Welten ----------------------------------
def schreinerei() -> Produktionsproblem:
"""Montagestunden und Plattenmaterial auf Tische und Stuehle verteilen."""
return Produktionsproblem(
produkte=[
Produkt(name="Tisch", deckungsbeitrag=240.0,
verbrauch={"Montagestunden": 3.0, "Plattenmaterial": 6.0}),
Produkt(name="Stuhl", deckungsbeitrag=60.0,
verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0}),
Produkt(name="Regal", deckungsbeitrag=130.0,
verbrauch={"Montagestunden": 2.0, "Plattenmaterial": 4.0}),
],
kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0})
def depot() -> Produktionsproblem:
"""Kapital und Risikobudget auf Anlageklassen verteilen.
Eine "Einheit" ist hier 1.000 EUR Anlagesumme. Der Deckungsbeitrag ist der
erwartete Jahresertrag dieser Einheit, der Verbrauch die beanspruchte
Kapital- und Risikomenge. Das ist keine Analogie, sondern buchstaeblich
dasselbe Modell - deshalb passt es in dieselbe Klasse.
"""
return Produktionsproblem(
produkte=[
Produkt(name="Aktien Welt", deckungsbeitrag=75.0,
verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 1.00}),
Produkt(name="Anleihen", deckungsbeitrag=28.0,
verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 0.22}),
Produkt(name="Immobilienfonds", deckungsbeitrag=46.0,
verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 0.55}),
],
kapazitaeten={"Kapital (Tsd. EUR)": 150.0, "Risikobudget": 90.0})
def berichte_allokation(titel: str, problem: Produktionsproblem,
einheit: str, ertragsname: str) -> None:
"""Ein Bericht fuer beide Welten - nur die Beschriftung wechselt."""
loesung = loese_mit_scipy(problem)
beanstandungen = pruefe_loesung(problem, loesung)
print(f" {titel}")
print(f" {loesung.als_bericht()}")
for produkt in problem.produkte:
print(f" {produkt.name:<18} {loesung.werte[produkt.name]:8.2f} {einheit}")
print(f" {ertragsname:<18} {loesung.zielwert:8.2f} EUR")
for ressource, preis in loesung.schattenpreise.items():
print(f" Schattenpreis {ressource:<24} {preis:7.2f} EUR")
print(f" Abnahmepruefung: "
f"{'bestanden' if not beanstandungen else beanstandungen}")
# --- Teil 2: Eine CVaR-Funktion, zwei Welten -------------------------------
def optimiere_cvar(verluste: np.ndarray, ertrag: np.ndarray,
mindestertrag: float, alpha: float = ALPHA,
max_anteil: float = MAX_ANTEIL):
"""Minimiert den CVaR der Verluste unter einer Mindestertragsbedingung.
'verluste' hat die Form (Szenarien x Optionen) und enthaelt, was in
Szenario s passiert, wenn eine Einheit in Option j steckt. Was ein
"Verlust" ist, entscheidet allein die Einheit der Matrix: Prozentpunkte
Kursverlust oder Tage Lieferverzug - die Formel sieht keinen Unterschied.
Das ist die Rockafellar-Uryasev-Formulierung aus dem Kapitel CVaR, hier
ohne jede Aenderung wiederverwendet.
"""
import cvxpy as cp
anzahl_szenarien, anzahl_optionen = verluste.shape
anteil = cp.Variable(anzahl_optionen, nonneg=True)
schwelle = cp.Variable() # wird im Optimum zum VaR
ueberschuss = cp.Variable(anzahl_szenarien, nonneg=True)
cvar = schwelle + (1.0 / (anzahl_szenarien * (1 - alpha))) * cp.sum(ueberschuss)
problem = cp.Problem(
cp.Minimize(cvar),
[ueberschuss >= verluste @ anteil - schwelle,
cp.sum(anteil) == 1,
anteil <= max_anteil,
ertrag @ anteil >= mindestertrag])
problem.solve()
if problem.status not in ("optimal", "optimal_inaccurate"):
raise SystemExit(f"CVaR-Problem nicht loesbar: {problem.status}")
return anteil.value, float(cvar.value), float(schwelle.value)
def kursszenarien(rng) -> tuple[np.ndarray, np.ndarray, list[str]]:
"""Taegliche Verluste (negative Renditen) von sechs Anlageklassen."""
namen = ["Aktien Welt", "Aktien EU", "Schwellenlaender",
"Staatsanleihen", "Unternehmensanl.", "Rohstoffe"]
rendite_pa = np.array([0.080, 0.065, 0.110, 0.025, 0.045, 0.070])
schwankung = np.array([0.180, 0.160, 0.260, 0.040, 0.075, 0.210])
taeglich = rng.normal(rendite_pa / 252, schwankung / np.sqrt(252),
(SZENARIEN, len(namen)))
return -taeglich * 100.0, rendite_pa * 100.0, namen
def lieferszenarien(rng) -> tuple[np.ndarray, np.ndarray, list[str]]:
"""Lieferverzug in Tagen bei sechs Lieferanten.
Der Aufbau ist bewusst anders als bei den Kursen: Hier gibt es einen
normalen Verzug UND seltene Totalausfaelle, die 14 Tage kosten. Das ist
genau die Art fetter Raender, wegen der man in beiden Welten CVaR statt
Standardabweichung benutzt.
"""
namen = ["Nordwerk", "Sued-Metall", "Fernost A", "Lokalzulieferer",
"Fernost B", "Osteuropa"]
zuverlaessigkeit = np.array([0.94, 0.97, 0.90, 0.99, 0.92, 0.95])
verzugsstreuung = np.array([3.5, 1.8, 5.0, 0.9, 4.2, 2.8])
normaler_verzug = np.maximum(0.0, rng.normal(0.5, 1.0, (SZENARIEN, len(namen)))
* verzugsstreuung)
totalausfall = (rng.random((SZENARIEN, len(namen))) > zuverlaessigkeit) * 14.0
return normaler_verzug + totalausfall, zuverlaessigkeit, namen
def berichte_cvar(titel: str, verluste, ertrag, namen, mindestertrag,
einheit: str, ertragsname: str):
anteile, cvar, var = optimiere_cvar(verluste, ertrag, mindestertrag)
mittel = float((verluste @ anteile).mean())
print(f" {titel}")
print(f" VaR {ALPHA:.0%}: {var:8.3f} {einheit}")
print(f" CVaR {ALPHA:.0%}: {cvar:8.3f} {einheit} "
f"(Mittelwert ueber alle Szenarien: {mittel:.3f})")
print(f" {ertragsname}: {float(ertrag @ anteile):.3f}")
print(" Aufteilung:")
for name, anteil in zip(namen, anteile):
balken = "#" * int(round(anteil * 40))
grenze = " <- an der Streuungsgrenze" if anteil > MAX_ANTEIL - 1e-4 else ""
print(f" {name:<18} {anteil:6.1%} {balken}{grenze}")
return anteile
if __name__ == "__main__":
rng = np.random.default_rng(SAAT)
print("=" * 80)
print(" DIESELBE STRUKTUR, ZWEI WELTEN")
print("=" * 80)
# --- Teil 1 ----------------------------------------------------------
print("\n1. Allokation als LP - EINE Klasse, EIN Modellbauer\n")
berichte_allokation("Werkstatt: Produktionsprogramm", schreinerei(),
"Stueck", "Deckungsbeitrag")
print()
berichte_allokation("Depot: Anlageaufteilung", depot(),
"Tsd. ", "Erwarteter Ertrag")
print("\n Beide Ausgaben stammen aus derselben Funktion "
"'berichte_allokation'.")
print(" Ausgetauscht wurden nur die Daten und die Beschriftungen -")
print(" keine Zeile Modellcode.")
print()
print(" Lesen Sie die Schattenpreise nebeneinander: In der Werkstatt sagt")
print(" er, was eine zusaetzliche Montagestunde wert waere. Im Depot sagt")
print(" dieselbe Zahl, was eine zusaetzliche Einheit Risikobudget wert")
print(" waere - der PREIS DES RISIKOS. Das ist kein Sprachbild, sondern")
print(" derselbe Dualwert derselben Nebenbedingung.")
# --- Teil 2 ----------------------------------------------------------
print("\n" + "-" * 80)
print("2. Absicherung gegen den schlechtesten Fall - EINE CVaR-Funktion\n")
kurse, rendite, anlagen = kursszenarien(rng)
anteile_depot = berichte_cvar(
"Depot: die schlechtesten 5 % der Handelstage",
kurse, rendite, anlagen, 5.5,
"% je Tag ", "Erwartete Jahresrendite (%)")
print()
lieferung, zuverlaessig, lieferanten = lieferszenarien(rng)
anteile_einkauf = berichte_cvar("Einkauf: die schlechtesten 5 % der Bestellungen",
lieferung, zuverlaessig, lieferanten, 0.945,
"Tage Verzug ", "Mittlere Zuverlaessigkeit")
print("\n Auch hier: eine Funktion, zwei Aufrufe. Die Zielfunktion")
print(" interessiert sich nicht dafuer, ob in der Matrix Prozentpunkte")
print(" oder Tage stehen.")
print()
print(f" Interessant ist, WO die Streuungsgrenze von {MAX_ANTEIL:.0%} bindet:")
print(f" Depot : {(anteile_depot > MAX_ANTEIL - 1e-4).sum()} von "
f"{len(anteile_depot)} Posten am Anschlag")
print(f" Einkauf : {(anteile_einkauf > MAX_ANTEIL - 1e-4).sum()} von "
f"{len(anteile_einkauf)} Posten am Anschlag")
print()
print(" Im Einkauf zieht es die Loesung an den sicheren Lokalzulieferer,")
print(" bis die Grenze sie stoppt. Im Depot nicht - dort verhindert die")
print(" Mindestrendite, dass alles in Anleihen wandert. Zwei verschiedene")
print(" Bremsen also, und sie stehen an verschiedenen Stellen des Modells:")
print(" einmal in einer Nebenbedingung ueber die Anteile, einmal in einer")
print(" ueber den Ertrag. Wer eine Struktur uebertraegt, uebertraegt eben")
print(" nicht automatisch mit, WELCHE Bedingung am Ende bindet.")
# --- Teil 3: Der ehrliche Teil ---------------------------------------
print("\n" + "=" * 80)
print(" WAS NICHT HINUEBERREICHT")
print("=" * 80)
print("Die Struktur traegt. Drei Unterschiede tragen NICHT mit, und wer sie")
print("uebersieht, macht aus einer nuetzlichen Analogie einen Fehler:")
print()
print("1. WOHER DIE ZAHLEN KOMMEN.")
print(" In der Werkstatt ist der Verbrauch je Tisch gemessen - drei")
print(" Montagestunden sind drei Montagestunden. Im Depot ist die")
print(" erwartete Rendite GESCHAETZT, und zwar mit einem Fehler, der")
print(" groesser sein kann als die Unterschiede zwischen den Anlagen")
print(" (Kapitel Markowitz, Renditeschaetzung_Falle.py). Dieselbe")
print(" Optimierung ist im einen Fall Planung und im anderen")
print(" Fehlerverstaerkung.")
print()
print("2. OB DIE VERGANGENHEIT ETWAS UEBER DIE ZUKUNFT SAGT.")
print(" Lieferzeiten haben physikalische Ursachen: Entfernung, Zoll,")
print(" Kapazitaet. Sie aendern sich langsam und nachvollziehbar.")
print(" Kursrenditen entstehen aus dem Verhalten von Marktteilnehmern,")
print(" die selbst auf Modelle reagieren - dort verschwindet ein")
print(" erkanntes Muster oft genau deshalb, weil es erkannt wurde")
print(" (Kapitel Handelsmaschine, Data_Snooping.py).")
print()
print("3. OB TEILBARKEIT ERLAUBT IST.")
print(" 37,4 % eines Aktienfonds sind ein normaler Auftrag. 37,4 % eines")
print(" Lieferanten sind es nicht - Vertraege, Mindestabnahmen und")
print(" Ruestzeiten machen Einkaufsentscheidungen ganzzahlig. Genau")
print(" deshalb ist Teil II voller MILP und Teil V fast frei davon.")
print()
print("Die Bruecke traegt also die MODELLE, nicht die Annahmen. Wer sie")
print("benutzt, spart sich das Lernen der Methoden - nicht das Nachdenken")
print("ueber die Daten.")
print("=" * 80)
```
**Erwartete Ausgabe:**
```
================================================================================
DIESELBE STRUKTUR, ZWEI WELTEN
================================================================================
1. Allokation als LP - EINE Klasse, EIN Modellbauer
Werkstatt: Produktionsprogramm
Status: optimal | Zielwert: 10,800.00 | Gap: 0.00% | Zeit: 0.00s
Tisch 30.00 Stueck
Stuhl 60.00 Stueck
Regal 0.00 Stueck
Deckungsbeitrag 10800.00 EUR
Schattenpreis Montagestunden 40.00 EUR
Schattenpreis Plattenmaterial 20.00 EUR
Abnahmepruefung: bestanden
Depot: Anlageaufteilung
Status: optimal | Zielwert: 7,634.62 | Gap: 0.00% | Zeit: 0.00s
Aktien Welt 73.08 Tsd.
Anleihen 76.92 Tsd.
Immobilienfonds 0.00 Tsd.
Erwarteter Ertrag 7634.62 EUR
Schattenpreis Kapital (Tsd. EUR) 14.74 EUR
Schattenpreis Risikobudget 60.26 EUR
Abnahmepruefung: bestanden
Beide Ausgaben stammen aus derselben Funktion 'berichte_allokation'.
Ausgetauscht wurden nur die Daten und die Beschriftungen -
keine Zeile Modellcode.
Lesen Sie die Schattenpreise nebeneinander: In der Werkstatt sagt
er, was eine zusaetzliche Montagestunde wert waere. Im Depot sagt
dieselbe Zahl, was eine zusaetzliche Einheit Risikobudget wert
waere - der PREIS DES RISIKOS. Das ist kein Sprachbild, sondern
derselbe Dualwert derselben Nebenbedingung.
--------------------------------------------------------------------------------
2. Absicherung gegen den schlechtesten Fall - EINE CVaR-Funktion
Depot: die schlechtesten 5 % der Handelstage
VaR 95%: 0.521 % je Tag
CVaR 95%: 0.644 % je Tag (Mittelwert ueber alle Szenarien: -0.011)
Erwartete Jahresrendite (%): 5.500
Aufteilung:
Aktien Welt 14.9% ######
Aktien EU 12.2% #####
Schwellenlaender 9.6% ####
Staatsanleihen 26.8% ###########
Unternehmensanl. 30.7% ############
Rohstoffe 5.7% ##
Einkauf: die schlechtesten 5 % der Bestellungen
VaR 95%: 4.351 Tage Verzug
CVaR 95%: 5.716 Tage Verzug (Mittelwert ueber alle Szenarien: 2.072)
Mittlere Zuverlaessigkeit: 0.963
Aufteilung:
Nordwerk 12.6% #####
Sued-Metall 22.1% #########
Fernost A 6.2% ##
Lokalzulieferer 40.0% ################ <- an der Streuungsgrenze
Fernost B 8.7% ###
Osteuropa 10.4% ####
Auch hier: eine Funktion, zwei Aufrufe. Die Zielfunktion
interessiert sich nicht dafuer, ob in der Matrix Prozentpunkte
oder Tage stehen.
Interessant ist, WO die Streuungsgrenze von 40% bindet:
Depot : 0 von 6 Posten am Anschlag
Einkauf : 1 von 6 Posten am Anschlag
Im Einkauf zieht es die Loesung an den sicheren Lokalzulieferer,
bis die Grenze sie stoppt. Im Depot nicht - dort verhindert die
Mindestrendite, dass alles in Anleihen wandert. Zwei verschiedene
Bremsen also, und sie stehen an verschiedenen Stellen des Modells:
einmal in einer Nebenbedingung ueber die Anteile, einmal in einer
ueber den Ertrag. Wer eine Struktur uebertraegt, uebertraegt eben
nicht automatisch mit, WELCHE Bedingung am Ende bindet.
================================================================================
WAS NICHT HINUEBERREICHT
================================================================================
Die Struktur traegt. Drei Unterschiede tragen NICHT mit, und wer sie
uebersieht, macht aus einer nuetzlichen Analogie einen Fehler:
1. WOHER DIE ZAHLEN KOMMEN.
In der Werkstatt ist der Verbrauch je Tisch gemessen - drei
Montagestunden sind drei Montagestunden. Im Depot ist die
erwartete Rendite GESCHAETZT, und zwar mit einem Fehler, der
groesser sein kann als die Unterschiede zwischen den Anlagen
(Kapitel Markowitz, Renditeschaetzung_Falle.py). Dieselbe
Optimierung ist im einen Fall Planung und im anderen
Fehlerverstaerkung.
2. OB DIE VERGANGENHEIT ETWAS UEBER DIE ZUKUNFT SAGT.
Lieferzeiten haben physikalische Ursachen: Entfernung, Zoll,
Kapazitaet. Sie aendern sich langsam und nachvollziehbar.
Kursrenditen entstehen aus dem Verhalten von Marktteilnehmern,
die selbst auf Modelle reagieren - dort verschwindet ein
erkanntes Muster oft genau deshalb, weil es erkannt wurde
(Kapitel Handelsmaschine, Data_Snooping.py).
3. OB TEILBARKEIT ERLAUBT IST.
37,4 % eines Aktienfonds sind ein normaler Auftrag. 37,4 % eines
Lieferanten sind es nicht - Vertraege, Mindestabnahmen und
Ruestzeiten machen Einkaufsentscheidungen ganzzahlig. Genau
deshalb ist Teil II voller MILP und Teil V fast frei davon.
Die Bruecke traegt also die MODELLE, nicht die Annahmen. Wer sie
benutzt, spart sich das Lernen der Methoden - nicht das Nachdenken
ueber die Daten.
================================================================================
```
---
## Die zwei Brücken im Einzelnen {#sec:bruecke-einzelnen}
### Erste Brücke: Allokation
| Werkstatt | Depot | im Modell |
| --- | --- | --- |
| Montagestunden, Plattenmaterial | Kapital, Risikobudget | `kapazitaeten` |
| Tisch, Stuhl, Regal | Aktien, Anleihen, Immobilien | `produkte` |
| Verbrauch je Stück | Beanspruchung je 1 000 € | `verbrauch` |
| Deckungsbeitrag je Stück | Erwarteter Ertrag je 1 000 € | `deckungsbeitrag` |
| Wert einer Montagestunde | **Preis des Risikos** | `schattenpreise` |
Die letzte Zeile ist die, die sich zu merken lohnt. Ein Portfoliomanager, der sagt „unser
Risikobudget ist zu knapp bemessen", trifft dieselbe Aussage wie ein Fertigungsleiter, der
eine zweite Schicht fordert — und beide können sie mit derselben Zahl belegen.
### Zweite Brücke: Absicherung gegen den schlechtesten Fall
Hier ist die Übereinstimmung noch enger, weil sie nicht nur die Struktur betrifft, sondern
die *Formel*. Die Rockafellar-Uryasev-Formulierung aus {ref:kap:cvar}
$$
\min_{w,\;\gamma} \;\; \gamma + \frac{1}{S(1-\alpha)} \sum_{s=1}^{S} u_s
\qquad \text{mit} \qquad
u_s \ge \ell_s^\top w - \gamma, \quad u_s \ge 0
$$
steht im Programm genau einmal und wird zweimal aufgerufen.
> **🔤 Formel-Übersetzer**
>
> | Mathematik | im Depot | im Einkauf |
> | --- | --- | --- |
> | $w$ | Anteil je Anlageklasse | Bestellanteil je Lieferant |
> | $\ell_s$ | Verlust in Szenario $s$, in Prozentpunkten | Verzug in Szenario $s$, in Tagen |
> | $\gamma$ | der VaR: „so schlimm wird es an 95 % der Tage höchstens" | „so viele Tage Verzug höchstens bei 95 % der Bestellungen" |
> | $u_s$ | Überschuss über diese Schwelle | dasselbe |
> | Zielwert | CVaR: Mittelwert der schlechtesten 5 % | dasselbe |
>
> **Ohne Formel gesagt:** „Sorge dafür, dass die schlimmsten fünf Prozent der Fälle im
> Mittel so glimpflich wie möglich ausgehen." Dieser Satz enthält kein Wort, das nur an der
> Börse Sinn ergibt.
Beide Anwendungen haben denselben Grund, warum sie CVaR und nicht die Standardabweichung
benutzen: **fette Ränder.** Ein Lieferant fällt selten aus, aber wenn, dann kostet es
vierzehn Tage — genau die Verteilungsform, bei der die Standardabweichung in die Irre führt
({ref:kap:cvar}).
### Wo die Lösungen sich unterscheiden
Der Vergleich fördert nebenbei einen Unterschied zutage, den man nicht erwartet: Die
Streuungsgrenze von 40 % **bindet nur im Einkauf**, nicht im Depot.
Im Einkauf zieht es die Lösung an den sicheren Lokalzulieferer, bis die Grenze sie stoppt.
Im Depot verhindert stattdessen die Mindestrendite, dass alles in Anleihen wandert. Zwei
verschiedene Bremsen also — und sie sitzen an verschiedenen Stellen des Modells.
Das ist die feine, wichtige Lehre dieses Abschnitts: **Wer eine Struktur überträgt, überträgt
nicht automatisch mit, welche Nebenbedingung am Ende bindet.** Und das ist die Information,
die im Gespräch mit dem Fachbereich zählt.
---
## Wo die Brücke endet {#sec:bruecke-grenzen}
Eine Analogie ist nur so nützlich wie die Grenzen, die man ihr zieht. Drei Dinge wandern
**nicht** mit:
| | Werkstatt / Einkauf | Depot |
| --- | --- | --- |
| **Woher die Zahlen kommen** | gemessen: drei Montagestunden sind drei Montagestunden | geschätzt: die erwartete Rendite hat einen Fehler, der größer sein kann als die Unterschiede zwischen den Anlagen ({ref:kap:markowitz}) |
| **Ob die Vergangenheit trägt** | Lieferzeiten haben physikalische Ursachen und ändern sich langsam | Kurse entstehen aus dem Verhalten von Marktteilnehmern, die auf Modelle reagieren — ein erkanntes Muster verschwindet oft deshalb, weil es erkannt wurde ({ref:kap:handelsmaschine}) |
| **Ob Teilbarkeit erlaubt ist** | 37,4 % eines Lieferanten gibt es nicht: Verträge, Mindestabnahmen, Rüstzeiten → MILP | 37,4 % eines Fonds sind ein normaler Auftrag → LP/QP genügt |
> **⚠️ Der Fehler, vor dem diese Tabelle schützt**
>
> Dieselbe Optimierung ist in der Werkstatt **Planung** und im Depot **Fehlerverstärkung**.
> Ein LP sucht die Ecke des zulässigen Bereichs mit dem besten Zielwert — und wenn die
> Zielkoeffizienten Schätzungen mit großem Fehler sind, sucht es genau die Ecke, an der der
> Schätzfehler am günstigsten aussieht.
>
> In der Werkstatt gibt es dieses Problem nicht, weil der Deckungsbeitrag je Tisch aus der
> Kalkulation kommt und nicht aus einer Zeitreihe. Deshalb ist Shrinkage
> ({ref:kap:finanzdaten}) im Depot Pflicht und in der Fertigung unbekannt.
>
> Die dritte Zeile erklärt außerdem eine Beobachtung, die aufmerksamen Lesern längst
> aufgefallen ist: **Teil II ist voller MILP, Teil IV fast frei davon.** Das liegt nicht an
> der Methode, sondern daran, was in der jeweiligen Welt teilbar ist.
---
## Übungsaufgaben {#sec:bruecke-uebungsaufgaben}
> Lösungen: {ref:sec:loesungen-bruecke}.
**Aufgabe ⭐ — Die dritte Ressource.**
Ergänzen Sie im Depot eine dritte knappe Größe: „Liquidität" (wie schnell sich eine Anlage
verkaufen lässt). Was entspricht ihr in der Werkstatt?
**Aufgabe ⭐ — Den Schattenpreis lesen.**
Das Risikobudget hat einen Schattenpreis von 60,26 €. Die Aufsicht bietet an, es gegen eine
Gebühr um 10 Einheiten zu erhöhen. Bis zu welcher Gebühr lohnt sich das — und welche
Einschränkung dieser Aussage kennen Sie aus {ref:sec:lp-entartung}?
**Aufgabe ⭐⭐ — Die Brücke rückwärts.**
Übertragen Sie die Ledoit-Wolf-Shrinkage aus {ref:kap:finanzdaten} auf Lieferzeiten:
Wo genau träte dort dasselbe Problem auf, und welche Daten bräuchten Sie?
**Aufgabe ⭐⭐ — CVaR mit Ganzzahligkeit.**
Der Einkauf darf höchstens **drei** Lieferanten beauftragen. Ergänzen Sie die entsprechende
Kardinalitätsbedingung ({ref:kap:milp}, Muster 5) und vergleichen Sie den CVaR mit dem
teilbaren Fall. Was kostet die Ganzzahligkeit?
**Aufgabe ⭐⭐⭐ — Die eigene Brücke.**
Nehmen Sie ein Modell aus Ihrem Arbeitsumfeld und suchen Sie die Entsprechung in der jeweils
anderen Welt. Prüfen Sie mit der Tabelle aus {ref:sec:bruecke-grenzen}, ob die Übertragung
trägt — und schreiben Sie auf, welche der drei Zeilen im Weg steht.
---
## Finde den Denkfehler {#sec:bruecke-denkfehler}
> **🐛 „Das ist doch dasselbe Problem"**
>
> Eine Logistikerin liest dieses Kapitel und überträgt es zurück. Ihre Aufgabe: Aus 40
> möglichen Lieferanten sollen die besten ausgewählt und Bestellmengen zugeteilt werden. Sie
> geht vor wie im Portfoliomanagement:
>
> > *„Ich habe für jeden Lieferanten aus den letzten drei Jahren die mittlere Lieferzeit und
> > die Kovarianzmatrix der Lieferzeiten geschätzt. Dann habe ich das Markowitz-Modell
> > angewendet: minimiere die Varianz der Gesamtlieferzeit bei vorgegebener mittlerer
> > Lieferzeit. Das Ergebnis ist ein sauber diversifiziertes Lieferantenportfolio."*
>
> Das Vorgehen ist methodisch sauber, die Rechnung stimmt, und das Ergebnis sieht plausibel
> aus.
>
> **Zwei Dinge daran sind falsch — und das eine ist genau der Fehler, vor dem dieses Kapitel
> warnt, das andere ein zusätzlicher, den es nicht behandelt. Welche?**
---
## Micro-Quiz {#sec:bruecke-quiz}
> **❓ Drei Fragen**
>
> **1. Warum passt das Depot in die Klasse `Produktionsproblem`, ohne dass etwas geändert
> werden muss?**
> a) Weil `Produktionsproblem` mit `Any`-Typen arbeitet und alles akzeptiert.
> b) Weil beide Probleme dieselbe Struktur haben: knappe Größen auf konkurrierende
> Verwendungen verteilen.
> c) Weil Pydantic die Feldnamen automatisch übersetzt.
>
> **2. Der Schattenpreis des Risikobudgets beträgt 60,26 €. Was bedeutet das?**
> a) Das Risikobudget ist zu 60,26 % ausgelastet.
> b) Eine zusätzliche Einheit Risikobudget würde den erwarteten Ertrag um rund 60,26 €
> erhöhen — solange die Basis sich nicht ändert.
> c) Jede Einheit Risikobudget kostet die Bank 60,26 €.
>
> **3. Welche der drei Grenzen der Analogie ist der Grund dafür, dass Teil II voller MILP ist
> und Teil IV fast frei davon?**
> a) Woher die Zahlen kommen.
> b) Ob die Vergangenheit etwas über die Zukunft sagt.
> c) Ob Teilbarkeit erlaubt ist.
---
## Selbsttest {#sec:bruecke-selbsttest}
1. Nennen Sie drei Größenpaare, die in Werkstatt und Depot einander entsprechen.
2. Warum weiß ein LP nichts über die Bedeutung seiner Zahlen — und warum ist das hier ein
Vorteil?
3. Erklären Sie den „Preis des Risikos" jemandem, der nur Fertigungsplanung kennt.
4. Warum benutzen beide Welten CVaR statt der Standardabweichung? Nennen Sie die
Verteilungseigenschaft, um die es geht.
5. Sie wollen ein Verfahren aus der Finanzwelt in die Produktion übertragen. Welche drei
Fragen stellen Sie vorher?
---
## Zusammenfassung {#sec:bruecke-zusammenfassung}
* Die Behauptung „dieselben Methoden, andere Domäne" ist prüfbar — und hier geprüft: Ein
Depot passt unverändert in die Klasse `Produktionsproblem`, und **eine** CVaR-Funktion
bearbeitet Kursverluste wie Lieferverzüge.
* Der **Schattenpreis** einer Ressource heißt in der Finanzwelt *Preis des Risikos*. Es ist
derselbe Dualwert derselben Nebenbedingung.
* Die **CVaR-Formulierung** enthält kein Wort, das nur an der Börse Sinn ergibt. Beide Welten
benutzen sie aus demselben Grund: fette Ränder, bei denen die Standardabweichung in die
Irre führt.
* Übertragen wird die **Struktur**, nicht die **Annahmen**. Drei Dinge wandern nicht mit:
woher die Zahlen kommen (gemessen gegen geschätzt), ob die Vergangenheit trägt (Physik
gegen reagierende Marktteilnehmer) und ob Teilbarkeit erlaubt ist (MILP gegen LP).
* Die erste dieser Grenzen ist die gefährlichste: Dieselbe Optimierung ist in der Werkstatt
Planung und im Depot Fehlerverstärkung.

View file

@ -0,0 +1,767 @@
# Kapitel: Supply-Chain und Energieeinsatz unter Unsicherheit {#kap:supplychain}
> **📌 Kapitel auf einen Blick**
>
> **Worum geht es?** Um einen zusammenhängenden Industriefall, der die Werkzeuge aus Teil II
> und III zusammenführt: gemischt-ganzzahlige Entscheidungen, Szenarien, Risikomaße — an der
> Kraftwerkseinsatzplanung, der klassischen Aufgabe der Energiewirtschaft.
>
> **Voraussetzungen:** {ref:kap:milp}, {ref:kap:unsicherheit} (zweistufige stochastische
> Programmierung) und {ref:kap:cvar}.
>
> **Danach können Sie:** ein Modell aufsetzen, in dem die **erste Stufe ganzzahlig** ist und
> vor dem Zufall feststeht; erklären, warum eine Planung auf den Erwartungswert
> systematisch zu knapp ausfällt; und den Preis einer Versorgungssicherheitsvorgabe in Euro
> je vermiedener Megawattstunde ausweisen.
>
> **Zeitbedarf:** ca. 4 Stunden.
>
> **Programme:**\
> `Kraftwerkseinsatz.py`
---
## In 5 Minuten gelöst {#sec:supplychain-schnellstart}
> **🚀 In 5 Minuten gelöst: Warum „billigstes Kraftwerk zuerst" falsch ist**
>
> Zwei Kraftwerke können 120 MW liefern. Der Kernblock produziert für 22 €/MWh, die
> Gasturbine für 105 € — fünfmal so teuer. Die Merit-Order-Regel sagt: Kernblock.
>
> Sie übersieht die Anfahrkosten: 40 000 € gegen 1 500 €.
>
> ```python
> # Zwei Kraftwerke, die 120 MW decken sollen - aber wie lange?
> # Grenzkosten Anfahrkosten
> kernblock = dict(name="Kernblock", grenz=22, anfahrt=40_000)
> gasturbine = dict(name="Gasturbine", grenz=105, anfahrt=1_500)
> LEISTUNG = 120 # MW, die gebraucht werden
>
> kosten = lambda kw, stunden: kw["anfahrt"] + kw["grenz"] * LEISTUNG * stunden
>
> print(f"{'Dauer':>7} {'Kernblock':>12} {'Gasturbine':>12} guenstiger")
> for stunden in (1, 2, 3, 4, 5, 6, 8):
> k, g = kosten(kernblock, stunden), kosten(gasturbine, stunden)
> print(f"{stunden:>5} h {k:>12,.0f} {g:>12,.0f} "
> f"{kernblock['name'] if k < g else gasturbine['name']}")
>
> wechsel = (kernblock["anfahrt"] - gasturbine["anfahrt"]) / (
> (gasturbine["grenz"] - kernblock["grenz"]) * LEISTUNG)
> print(f"\nUmschlagpunkt: {wechsel:.1f} Stunden")
> ```
>
> **Ausgabe:**
>
> ```
> Dauer Kernblock Gasturbine guenstiger
> 1 h 42,640 14,100 Gasturbine
> 2 h 45,280 26,700 Gasturbine
> 3 h 47,920 39,300 Gasturbine
> 4 h 50,560 51,900 Kernblock
> 5 h 53,200 64,500 Kernblock
> 6 h 55,840 77,100 Kernblock
> 8 h 61,120 102,300 Kernblock
>
> Umschlagpunkt: 3.9 Stunden
> ```
**Und jetzt der Punkt.** Bis knapp vier Stunden ist die *fünfmal teurere* Gasturbine die
günstigere Wahl. Erst danach hat der Kernblock seine Anfahrkosten wieder eingespielt.
Die Merit-Order — Kraftwerke nach Grenzkosten sortieren und von unten auffüllen — ist damit
nur für einen **einzelnen** Zeitpunkt richtig. Sobald ein Tag geplant wird, hängt jede
Stunde an allen anderen: Ein Block, der um 8 Uhr angefahren wird, muss bis mindestens 16 Uhr
laufen, und diese Entscheidung fällt am Vorabend.
> **🎯 Merksatz**
> Anfahrkosten und Mindestlaufzeiten machen aus 24 unabhängigen Stundenentscheidungen **ein**
> Problem. Genau deshalb ist die Kraftwerkseinsatzplanung ein MILP und keine Sortierung.
**Warum funktioniert das?** Der Umschlagpunkt ist eine einfache Rechnung: Die
Anfahrkostendifferenz (38 500 €) geteilt durch die Grenzkostendifferenz je Stunde
(83 €/MWh × 120 MW = 9 960 €/h) ergibt 3,9 Stunden. Bei zwei Kraftwerken kann man das im
Kopf ausrechnen — bei fünf Blöcken und 24 Stunden nicht mehr.
---
## Lernziele {#sec:supplychain-lernziele}
Nach diesem Kapitel können Sie …
1. … ein Unit-Commitment-Modell mit Anfahrkosten und Mindestlaufzeiten aufstellen.
2. … erklären, warum eine **binäre erste Stufe** das zweistufige Modell aus
{ref:kap:unsicherheit} verschärft.
3. … zeigen, dass eine Planung auf den Erwartungswert nicht nur ungenau, sondern
**systematisch zu knapp** ist.
4. … eine CVaR-Schranke auf eine physikalische Größe (nicht gedeckte Energie) legen.
5. … begründen, wann eine Risikoschranke etwas ändert — und wann sie überflüssig ist.
---
## Die Aufgabe {#sec:supplychain-aufgabe}
Fünf Blöcke, 24 Stunden, eine Last zwischen 270 und 804 MW. Dazu Windeinspeisung, die
niemand am Vorabend kennt.
| Block | $P_{\min}$ | $P_{\max}$ | Grenzkosten | Anfahrkosten | Mindestlaufzeit |
| --- | ---: | ---: | ---: | ---: | ---: |
| Kernblock | 200 | 600 | 22 €/MWh | 40 000 € | 8 h |
| Braunkohle | 100 | 400 | 35 €/MWh | 18 000 € | 6 h |
| Steinkohle | 80 | 300 | 48 €/MWh | 12 000 € | 4 h |
| Gas GuD | 50 | 250 | 72 €/MWh | 6 000 € | 2 h |
| Gasturbine | 20 | 150 | 105 €/MWh | 1 500 € | 1 h |
Die Reihenfolge ist typisch: **je billiger im Betrieb, desto träger und teurer im
Anfahren.** Das ist keine Willkür, sondern Physik — ein großer Dampfprozess braucht Stunden,
bis er auf Temperatur ist.
### Die Zeitstruktur der Entscheidung
Hier geht das Kapitel über {ref:sec:unsicherheit-zweistufige-stochastische-programmierung}
hinaus:
| Stufe | Entscheidung | Typ | wann |
| --- | --- | --- | --- |
| **1** | Welcher Block läuft in welcher Stunde? | **binär**, für alle Szenarien gleich | am Vorabend, bevor der Wind bekannt ist |
| **2** | Wie viel liefert jeder laufende Block? | kontinuierlich, je Szenario verschieden | am Tag selbst, laufend anpassbar |
Im Kapitel Unsicherheit war die erste Stufe kontinuierlich (Kapazität kaufen, Menge
festlegen). Hier ist sie **ganzzahlig** — und das ändert die Sache grundlegend: Eine
Kapazität lässt sich anteilig erhöhen, ein Kraftwerk nicht zu 30 % anfahren.
> **🔤 Formel-Übersetzer: die Kopplung der beiden Stufen**
>
> | Mathematik | Alltagssprache |
> | --- | --- |
> | $u_{k,t} \in \{0,1\}$ | „Läuft Block $k$ in Stunde $t$?" — **eine** Antwort für alle Szenarien |
> | $p^s_{k,t} \ge P^{\min}_k \, u_{k,t}$ | „Ein laufender Block liefert mindestens seine Mindestleistung." |
> | $p^s_{k,t} \le P^{\max}_k \, u_{k,t}$ | „Ein stehender Block liefert gar nichts." — dieselbe Big-M-Kopplung wie in {ref:kap:milp} |
> | $a_{k,t} \ge u_{k,t} - u_{k,t-1}$ | „Wer eben noch aus war und jetzt an ist, ist angefahren." |
> | $u_{k,\tau} \ge a_{k,t}$ für $\tau = t \dots t{+}L_k$ | Mindestlaufzeit: „Wer anfährt, bleibt $L_k$ Stunden am Netz." |
> | $\sum_k p^s_{k,t} + w^s_t + y^s_t \ge D_t$ | „Erzeugung plus Wind plus Nichtdeckung deckt die Last." |
>
> Die beiden mittleren Zeilen sind der ganze Trick: Sie verbinden eine **binäre** Größe
> ($u$) mit einer **kontinuierlichen** ($p$) — und weil $u$ szenarioübergreifend gleich ist,
> die Szenarien untereinander.
---
## Das Programm {#sec:supplychain-programm}
```python
#!/usr/bin/env python3
# Kraftwerkseinsatz.py
"""
Kapitel Supply-Chain: Welche Bloecke laufen morgen? Und was, wenn kein Wind weht?
Die Kraftwerkseinsatzplanung (englisch Unit Commitment) ist die Aufgabe, an der
sich in der Energiewirtschaft alles entscheidet: Fuer jede Stunde des naechsten
Tages muss feststehen, welche Bloecke am Netz sind. Ein Kernblock braucht acht
Stunden Mindestlaufzeit und 40.000 Euro Anfahrkosten - wer ihn abschaltet, hat
ihn fuer den Rest des Tages verloren.
Das Besondere liegt in der Zeitstruktur, und es geht ueber das zweistufige
Modell aus dem Kapitel Unsicherheit hinaus:
STUFE 1 Das AN/AUS je Block und Stunde. Binaer, und es steht am Vorabend
fest - bevor irgendjemand weiss, wie viel Wind morgen weht.
STUFE 2 Die Fahrweise: wie viel jeder laufende Block liefert. Das darf
sich stundenweise an die Wirklichkeit anpassen.
Die erste Stufe ist also GANZZAHLIG und szenariouebergreifend gleich, die
zweite kontinuierlich und je Szenario verschieden. Genau diese Kombination
macht das Problem interessant - und sie ist der Grund, warum ein Plan, der auf
den Wind-Erwartungswert gerechnet wurde, in der Wirklichkeit teuer wird.
Das Programm zeigt drei Plaene auf denselben Daten:
1. Deterministisch: gerechnet mit dem Wind-ERWARTUNGSWERT, danach gegen 40
Szenarien ausgewertet.
2. Zweistufig: der Commitment-Plan sieht alle 40 Szenarien.
3. Mit Versorgungssicherheit: die Nichtdeckung in den schlechtesten Faellen
wird begrenzt - und der Preis dafuer in Euro je vermiedener MWh
ausgewiesen.
Benoetigt: numpy, ortools
"""
from __future__ import annotations
import time
import numpy as np
from ortools.linear_solver import pywraplp
# (Name, Mindestleistung, Nennleistung, Grenzkosten, Anfahrkosten, Mindestlaufzeit)
KRAFTWERKE = [
("Kernblock", 200, 600, 22, 40_000, 8),
("Braunkohle", 100, 400, 35, 18_000, 6),
("Steinkohle", 80, 300, 48, 12_000, 4),
("Gas GuD", 50, 250, 72, 6_000, 2),
("Gasturbine", 20, 150, 105, 1_500, 1),
]
STUNDEN = 24
SZENARIEN = 40
FLAUTENANTEIL = 0.15
ALPHA = 0.90 # die schlechtesten 10 % der Szenarien
LASTABWURF = 3_000.0 # EUR je nicht gedeckter MWh ("value of lost load")
NIEDRIGER_ABWURFPREIS = 300.0 # zum Vergleich: ein Marktpreisdeckel
SAAT = 4
_t = np.arange(STUNDEN)
LAST = 620 + 260 * np.sin((_t - 7) / 24 * 2 * np.pi) + 90 * np.sin((_t - 4) / 12 * 2 * np.pi)
WIND_ERWARTUNG = np.maximum(0.0, 160 + 130 * np.sin((_t - 14) / 24 * 2 * np.pi))
def erzeuge_windszenarien(anzahl: int = SZENARIEN, saat: int = SAAT):
"""Windeinspeisung je Szenario und Stunde.
Zwei Zutaten, die zusammen den Unterschied machen: eine breite
lognormale Streuung des Tagesniveaus - und in 15 % der Faelle eine
DUNKELFLAUTE, in der praktisch gar kein Wind weht. Solche seltenen,
extremen Faelle sind der Grund, warum der Erwartungswert als
Planungsgrundlage nicht genuegt.
"""
rng = np.random.default_rng(saat)
tagesniveau = rng.lognormal(0, 0.40, (anzahl, 1))
wind = np.clip(WIND_ERWARTUNG * tagesniveau
+ rng.normal(0, 25, (anzahl, STUNDEN)), 0, 420)
ist_flaute = rng.random(anzahl) < FLAUTENANTEIL
wind[ist_flaute] *= 0.05
return wind, ist_flaute
def plane(wind: np.ndarray, gewichte, cvar_grenze: float | None = None,
abwurfpreis: float = None, zeitlimit: float = 300.0):
"""Commitment (Stufe 1) und Fahrweise je Szenario (Stufe 2) in einem Modell.
'wind' hat die Form (Szenarien, Stunden). Mit einer einzigen Zeile
Windprognose wird daraus die deterministische Planung.
'cvar_grenze' begrenzt den CVaR der Nichtdeckung ueber die Szenarien -
dieselbe Rockafellar-Uryasev-Konstruktion wie im Kapitel CVaR, nur dass
hier keine Verluste in Euro, sondern Megawattstunden begrenzt werden.
"""
abwurfpreis = LASTABWURF if abwurfpreis is None else abwurfpreis
anzahl_szenarien = wind.shape[0]
anzahl_bloecke = len(KRAFTWERKE)
solver = pywraplp.Solver.CreateSolver("SCIP")
solver.SetTimeLimit(int(zeitlimit * 1000))
# --- Stufe 1: binaer, szenariouebergreifend gleich --------------------
laeuft = [[solver.BoolVar(f"laeuft_{k}_{t}") for t in range(STUNDEN)]
for k in range(anzahl_bloecke)]
faehrt_an = [[solver.BoolVar(f"start_{k}_{t}") for t in range(STUNDEN)]
for k in range(anzahl_bloecke)]
# --- Stufe 2: kontinuierlich, je Szenario -----------------------------
leistung = [[[solver.NumVar(0, KRAFTWERKE[k][2], f"p_{j}_{k}_{t}")
for t in range(STUNDEN)] for k in range(anzahl_bloecke)]
for j in range(anzahl_szenarien)]
nichtdeckung = [[solver.NumVar(0, solver.infinity(), f"y_{j}_{t}")
for t in range(STUNDEN)] for j in range(anzahl_szenarien)]
for k, (_, pmin, pmax, _, _, mindestlaufzeit) in enumerate(KRAFTWERKE):
for t in range(STUNDEN):
# Anfahren erkennen: aus im Vortakt, an im aktuellen
vorher = laeuft[k][t - 1] if t > 0 else 0
solver.Add(faehrt_an[k][t] >= laeuft[k][t] - vorher)
# Mindestlaufzeit: wer anfaehrt, laeuft die naechsten Stunden weiter
for spaeter in range(t, min(STUNDEN, t + mindestlaufzeit)):
solver.Add(laeuft[k][spaeter] >= faehrt_an[k][t])
# Ein laufender Block liefert zwischen Mindest- und Nennleistung,
# ein stehender gar nichts. Das koppelt beide Stufen.
for j in range(anzahl_szenarien):
solver.Add(leistung[j][k][t] >= pmin * laeuft[k][t])
solver.Add(leistung[j][k][t] <= pmax * laeuft[k][t])
for j in range(anzahl_szenarien):
for t in range(STUNDEN):
solver.Add(sum(leistung[j][k][t] for k in range(anzahl_bloecke))
+ float(wind[j, t]) + nichtdeckung[j][t] >= LAST[t])
# --- Versorgungssicherheit als CVaR-Schranke --------------------------
if cvar_grenze is not None:
schwelle = solver.NumVar(-solver.infinity(), solver.infinity(), "schwelle")
ueberschuss = [solver.NumVar(0, solver.infinity(), f"u_{j}")
for j in range(anzahl_szenarien)]
for j in range(anzahl_szenarien):
solver.Add(ueberschuss[j] >= sum(nichtdeckung[j][t]
for t in range(STUNDEN)) - schwelle)
solver.Add(schwelle + (1.0 / (anzahl_szenarien * (1 - ALPHA)))
* sum(ueberschuss) <= cvar_grenze)
anfahrkosten = sum(KRAFTWERKE[k][4] * faehrt_an[k][t]
for k in range(anzahl_bloecke) for t in range(STUNDEN))
betriebskosten = sum(
gewichte[j] * sum(KRAFTWERKE[k][3] * leistung[j][k][t]
for k in range(anzahl_bloecke) for t in range(STUNDEN))
for j in range(anzahl_szenarien))
abwurfkosten = sum(gewichte[j] * abwurfpreis
* sum(nichtdeckung[j][t] for t in range(STUNDEN))
for j in range(anzahl_szenarien))
solver.Minimize(anfahrkosten + betriebskosten + abwurfkosten)
beginn = time.perf_counter()
status = solver.Solve()
dauer = time.perf_counter() - beginn
if status not in (pywraplp.Solver.OPTIMAL, pywraplp.Solver.FEASIBLE):
return None, dauer, False
plan = np.array([[laeuft[k][t].solution_value() for t in range(STUNDEN)]
for k in range(anzahl_bloecke)]).round()
return plan, dauer, status == pywraplp.Solver.OPTIMAL
def bewerte(plan: np.ndarray, wind: np.ndarray, abwurfpreis: float = None):
"""Commitment steht fest - nur noch die Fahrweise je Szenario optimieren.
Das ist die ehrliche Bewertung eines Plans: Er wird der Wirklichkeit
ausgesetzt und darf nur noch das anpassen, was sich am Tag selbst
anpassen laesst.
"""
abwurfpreis = LASTABWURF if abwurfpreis is None else abwurfpreis
anzahl_bloecke = len(KRAFTWERKE)
kosten, fehlmengen = [], []
for j in range(wind.shape[0]):
solver = pywraplp.Solver.CreateSolver("GLOP")
leistung = [[solver.NumVar(0, KRAFTWERKE[k][2], f"p_{k}_{t}")
for t in range(STUNDEN)] for k in range(anzahl_bloecke)]
fehlt = [solver.NumVar(0, solver.infinity(), f"y_{t}")
for t in range(STUNDEN)]
for k, (_, pmin, pmax, _, _, _) in enumerate(KRAFTWERKE):
for t in range(STUNDEN):
solver.Add(leistung[k][t] >= pmin * plan[k, t])
solver.Add(leistung[k][t] <= pmax * plan[k, t])
for t in range(STUNDEN):
solver.Add(sum(leistung[k][t] for k in range(anzahl_bloecke))
+ float(wind[j, t]) + fehlt[t] >= LAST[t])
solver.Minimize(
sum(KRAFTWERKE[k][3] * leistung[k][t]
for k in range(anzahl_bloecke) for t in range(STUNDEN))
+ sum(abwurfpreis * fehlt[t] for t in range(STUNDEN)))
solver.Solve()
kosten.append(solver.Objective().Value())
fehlmengen.append(sum(f.solution_value() for f in fehlt))
anfahrten = sum(KRAFTWERKE[k][4]
* max(0.0, plan[k, t] - (plan[k, t - 1] if t > 0 else 0.0))
for k in range(anzahl_bloecke) for t in range(STUNDEN))
return np.array(kosten) + anfahrten, np.array(fehlmengen)
def cvar(werte: np.ndarray, alpha: float = ALPHA) -> float:
"""Mittelwert der schlechtesten (1-alpha) Faelle."""
schwelle = np.quantile(werte, alpha)
schlechteste = werte[werte >= schwelle]
return float(schlechteste.mean()) if len(schlechteste) else 0.0
def zeige(name: str, plan, wind, abwurfpreis: float = None) -> dict:
kosten, fehl = bewerte(plan, wind, abwurfpreis)
kennzahlen = {"mittel": kosten.mean(), "max": kosten.max(),
"fehl_mittel": fehl.mean(), "fehl_cvar": cvar(fehl),
"betroffen": int((fehl > 0.01).sum()),
"blockstunden": int(plan.sum())}
print(f" {name:<32} {kennzahlen['mittel']:>10,.0f} {kennzahlen['max']:>11,.0f} "
f"{kennzahlen['fehl_mittel']:>9.1f} {kennzahlen['fehl_cvar']:>10.1f} "
f"{kennzahlen['betroffen']:>6}/{len(fehl)}")
return kennzahlen
if __name__ == "__main__":
wind, ist_flaute = erzeuge_windszenarien()
print("=" * 88)
print(" KRAFTWERKSEINSATZ: DER PLAN STEHT, BEVOR DER WIND WEHT")
print("=" * 88)
print(f"{len(KRAFTWERKE)} Bloecke, {STUNDEN} Stunden, {SZENARIEN} Windszenarien "
f"(davon {int(ist_flaute.sum())} Dunkelflauten).")
print(f"Last {LAST.min():.0f} bis {LAST.max():.0f} MW, "
f"Wind im Erwartungswert {WIND_ERWARTUNG.min():.0f} bis "
f"{WIND_ERWARTUNG.max():.0f} MW.")
print(f"Nicht gedeckte Last kostet {LASTABWURF:,.0f} EUR je MWh.\n")
print(f" {'Planungsgrundlage':<32} {'Kosten':>10} {'Kosten':>11} "
f"{'Fehlmenge':>9} {'Fehlmenge':>10} {'Szen. mit':>12}")
print(f" {'':<32} {'im Mittel':>10} {'schlimmst.':>11} "
f"{'Mittel':>9} {'CVaR 90%':>10} {'Abwurf':>12}")
print(" " + "-" * 86)
# --- 1. Deterministisch ----------------------------------------------
plan_det, dauer_det, _ = plane(WIND_ERWARTUNG.reshape(1, -1), [1.0])
det = zeige("nur Wind-Erwartungswert", plan_det, wind)
# --- 2. Zweistufig, risikoneutral ------------------------------------
gleich = [1.0 / SZENARIEN] * SZENARIEN
plan_sto, dauer_sto, optimal_sto = plane(wind, gleich)
sto = zeige("alle 40 Szenarien", plan_sto, wind)
# --- 3. Mit Versorgungssicherheit ------------------------------------
plan_sicher, dauer_sicher, optimal_sicher = plane(wind, gleich, cvar_grenze=0.0)
sicher = zeige("+ Nichtdeckung CVaR 90 % = 0", plan_sicher, wind)
print(f"\n Rechenzeiten: {dauer_det:.1f}s / {dauer_sto:.1f}s / {dauer_sicher:.1f}s "
f"(alle beweisbar optimal: {optimal_sto and optimal_sicher})")
# --- Was die Zeilen bedeuten -----------------------------------------
print("\n" + "-" * 88)
print("Was der Erwartungswert-Plan anrichtet\n")
print(f" Er ist auf dem Papier der billigste - gerechnet auf den mittleren")
print(f" Wind kostet er weniger als jeder andere. In der Wirklichkeit liegt")
print(f" er {det['mittel'] / sto['mittel'] - 1:+.0%} ueber dem zweistufigen Plan, und sein "
f"schlimmster Tag")
print(f" kostet {det['max'] / sto['max']:.1f}-mal so viel.")
print(f"\n Der Grund steht in der letzten Spalte: In {det['betroffen']} von {SZENARIEN} "
f"Szenarien muss")
print(f" Last abgeworfen werden. Was fehlt, sind nicht Kraftwerke, sondern")
print(f" ANGEFAHRENE Kraftwerke - und ein Block mit acht Stunden")
print(f" Mindestlaufzeit laesst sich um 18 Uhr nicht mehr herbeirufen.")
print(f"\n Der Unterschied im Plan ist klein:")
for name, plan in [("Erwartungswert", plan_det), ("zweistufig", plan_sto),
("mit Sicherheit", plan_sicher)]:
laufend = plan.sum(axis=0).astype(int)
print(f" {name:<16} {''.join(str(x) for x in laufend)} "
f"({int(plan.sum())} Blockstunden)")
print(f"\n Der zweistufige Plan haelt {sto['blockstunden'] - det['blockstunden']} "
f"Blockstunden mehr vor - das genuegt,")
print(f" um alle {det['betroffen']} Lastabwuerfe zu vermeiden.")
# --- Der Preis der Sicherheit ----------------------------------------
print("\n" + "-" * 88)
print("Was Versorgungssicherheit kostet\n")
print(f" Bei {LASTABWURF:,.0f} EUR/MWh kostet die CVaR-Schranke NICHTS: Der")
print(" risikoneutrale Plan haelt sie schon ein. Das ist kein Zufall - bei")
print(" diesem Preis lohnt sich Vorhaltung bereits im Erwartungswert.")
print("\n Interessant wird die Schranke dort, wo der Schaden ZU NIEDRIG")
print(" bepreist ist. Dieselbe Rechnung mit einem Marktpreisdeckel von")
print(f" {NIEDRIGER_ABWURFPREIS:,.0f} EUR/MWh statt {LASTABWURF:,.0f}:\n")
print(f" {'Planungsgrundlage':<32} {'Kosten':>10} {'Kosten':>11} "
f"{'Fehlmenge':>9} {'Fehlmenge':>10} {'Szen. mit':>12}")
print(f" {'':<32} {'im Mittel':>10} {'schlimmst.':>11} "
f"{'Mittel':>9} {'CVaR 90%':>10} {'Abwurf':>12}")
print(" " + "-" * 86)
plan_billig, _, _ = plane(wind, gleich, abwurfpreis=NIEDRIGER_ABWURFPREIS)
billig = zeige("risikoneutral, billiger Abwurf", plan_billig, wind,
NIEDRIGER_ABWURFPREIS)
plan_billig_sicher, _, _ = plane(wind, gleich, cvar_grenze=0.0,
abwurfpreis=NIEDRIGER_ABWURFPREIS)
billig_sicher = zeige("+ CVaR 90 % der Fehlmenge = 0", plan_billig_sicher, wind,
NIEDRIGER_ABWURFPREIS)
aufpreis = billig_sicher["mittel"] - billig["mittel"]
vermieden = billig["fehl_cvar"] - billig_sicher["fehl_cvar"]
print(f"\n Jetzt greift die Schranke: Der risikoneutrale Plan nimmt in")
print(f" {billig['betroffen']} von {SZENARIEN} Szenarien einen Lastabwurf in Kauf, weil er bei")
print(f" {NIEDRIGER_ABWURFPREIS:,.0f} EUR/MWh billiger ist als das Vorhalten eines Blocks.")
print(f"\n Aufpreis fuer die Sicherheit: {aufpreis:,.0f} EUR je Tag")
if vermieden > 0.01:
print(f" Vermiedene Fehlmenge (CVaR 90 %): {vermieden:.1f} MWh")
print(f" -> {aufpreis / vermieden:,.0f} EUR je vermiedener MWh")
print(f"\n Diese Zahl ist die Entscheidungsgrundlage - genau wie die Spalte")
print(f" 'EUR je kg' im Kapitel Mehrziel. Sie sagt der Aufsicht, was ihre")
print(f" Versorgungssicherheitsvorgabe tatsaechlich kostet, statt darueber")
print(f" zu streiten, wie wichtig sie ist.")
print("\n" + "=" * 88)
print(" WAS MAN DARAUS MITNIMMT")
print("=" * 88)
print("1. Die erste Stufe ist binaer und traege. Was am Vorabend nicht")
print(" angefahren wurde, steht am naechsten Tag nicht zur Verfuegung -")
print(" egal, wie hoch der Preis dann steigt.")
print("2. Deshalb ist der Erwartungswert als Planungsgrundlage nicht nur")
print(" ungenau, sondern SYSTEMATISCH zu knapp: Er plant fuer einen Tag,")
print(" der so nie eintritt, und laesst keine Reserve fuer die Haelfte")
print(" der Faelle, in denen es schlechter kommt.")
print("3. Der Ausweg ist keine bessere Windprognose, sondern ein Modell,")
print(" das die Szenarien SIEHT. Die Rechenzeit dafuer liegt hier im")
print(" Sekundenbereich - der Aufwand ist die Modellierung, nicht der")
print(" Solver.")
print("4. Wo der Lastabwurf teuer genug bepreist ist, deckt schon die")
print(" Minimierung der ERWARTETEN Kosten die Extremfaelle mit ab. Die")
print(" Risikoschranke wird genau dann gebraucht, wenn der Schaden ZU")
print(" NIEDRIG bepreist ist - was bei Versorgungssicherheit die Regel")
print(" ist, weil ein Marktpreisdeckel nicht den volkswirtschaftlichen")
print(" Schaden abbildet. Dann liefert sie den Preis der Vorgabe in Euro")
print(" je vermiedener MWh, und darueber laesst sich verhandeln.")
print("=" * 88)
```
**Erwartete Ausgabe** (Laufzeiten hardwareabhängig, die Werte nicht):
```
========================================================================================
KRAFTWERKSEINSATZ: DER PLAN STEHT, BEVOR DER WIND WEHT
========================================================================================
5 Bloecke, 24 Stunden, 40 Windszenarien (davon 8 Dunkelflauten).
Last 270 bis 804 MW, Wind im Erwartungswert 30 bis 290 MW.
Nicht gedeckte Last kostet 3,000 EUR je MWh.
Planungsgrundlage Kosten Kosten Fehlmenge Fehlmenge Szen. mit
im Mittel schlimmst. Mittel CVaR 90% Abwurf
--------------------------------------------------------------------------------------
nur Wind-Erwartungswert 874,870 2,504,154 178.7 679.2 28/40
alle 40 Szenarien 351,356 415,924 0.0 0.0 0/40
+ Nichtdeckung CVaR 90 % = 0 351,356 415,924 0.0 0.0 0/40
Rechenzeiten: 0.0s / 1.3s / 1.1s (alle beweisbar optimal: True)
----------------------------------------------------------------------------------------
Was der Erwartungswert-Plan anrichtet
Er ist auf dem Papier der billigste - gerechnet auf den mittleren
Wind kostet er weniger als jeder andere. In der Wirklichkeit liegt
er +149% ueber dem zweistufigen Plan, und sein schlimmster Tag
kostet 6.0-mal so viel.
Der Grund steht in der letzten Spalte: In 28 von 40 Szenarien muss
Last abgeworfen werden. Was fehlt, sind nicht Kraftwerke, sondern
ANGEFAHRENE Kraftwerke - und ein Block mit acht Stunden
Mindestlaufzeit laesst sich um 18 Uhr nicht mehr herbeirufen.
Der Unterschied im Plan ist klein:
Erwartungswert 111111122222222211111111 (33 Blockstunden)
zweistufig 111111222222222222222111 (39 Blockstunden)
mit Sicherheit 111111222222222222222111 (39 Blockstunden)
Der zweistufige Plan haelt 6 Blockstunden mehr vor - das genuegt,
um alle 28 Lastabwuerfe zu vermeiden.
----------------------------------------------------------------------------------------
Was Versorgungssicherheit kostet
Bei 3,000 EUR/MWh kostet die CVaR-Schranke NICHTS: Der
risikoneutrale Plan haelt sie schon ein. Das ist kein Zufall - bei
diesem Preis lohnt sich Vorhaltung bereits im Erwartungswert.
Interessant wird die Schranke dort, wo der Schaden ZU NIEDRIG
bepreist ist. Dieselbe Rechnung mit einem Marktpreisdeckel von
300 EUR/MWh statt 3,000:
Planungsgrundlage Kosten Kosten Fehlmenge Fehlmenge Szen. mit
im Mittel schlimmst. Mittel CVaR 90% Abwurf
--------------------------------------------------------------------------------------
risikoneutral, billiger Abwurf 350,512 421,336 3.0 18.4 8/40
+ CVaR 90 % der Fehlmenge = 0 351,356 415,924 0.0 0.0 0/40
Jetzt greift die Schranke: Der risikoneutrale Plan nimmt in
8 von 40 Szenarien einen Lastabwurf in Kauf, weil er bei
300 EUR/MWh billiger ist als das Vorhalten eines Blocks.
Aufpreis fuer die Sicherheit: 844 EUR je Tag
Vermiedene Fehlmenge (CVaR 90 %): 18.4 MWh
-> 46 EUR je vermiedener MWh
Diese Zahl ist die Entscheidungsgrundlage - genau wie die Spalte
'EUR je kg' im Kapitel Mehrziel. Sie sagt der Aufsicht, was ihre
Versorgungssicherheitsvorgabe tatsaechlich kostet, statt darueber
zu streiten, wie wichtig sie ist.
========================================================================================
WAS MAN DARAUS MITNIMMT
========================================================================================
1. Die erste Stufe ist binaer und traege. Was am Vorabend nicht
angefahren wurde, steht am naechsten Tag nicht zur Verfuegung -
egal, wie hoch der Preis dann steigt.
2. Deshalb ist der Erwartungswert als Planungsgrundlage nicht nur
ungenau, sondern SYSTEMATISCH zu knapp: Er plant fuer einen Tag,
der so nie eintritt, und laesst keine Reserve fuer die Haelfte
der Faelle, in denen es schlechter kommt.
3. Der Ausweg ist keine bessere Windprognose, sondern ein Modell,
das die Szenarien SIEHT. Die Rechenzeit dafuer liegt hier im
Sekundenbereich - der Aufwand ist die Modellierung, nicht der
Solver.
4. Wo der Lastabwurf teuer genug bepreist ist, deckt schon die
Minimierung der ERWARTETEN Kosten die Extremfaelle mit ab. Die
Risikoschranke wird genau dann gebraucht, wenn der Schaden ZU
NIEDRIG bepreist ist - was bei Versorgungssicherheit die Regel
ist, weil ein Marktpreisdeckel nicht den volkswirtschaftlichen
Schaden abbildet. Dann liefert sie den Preis der Vorgabe in Euro
je vermiedener MWh, und darueber laesst sich verhandeln.
========================================================================================
```
---
## Der Befund {#sec:supplychain-befund}
| Planungsgrundlage | Kosten im Mittel | schlimmster Tag | Szenarien mit Lastabwurf |
| --- | ---: | ---: | ---: |
| nur Wind-Erwartungswert | 874 870 € | 2 504 154 € | **28 von 40** |
| alle 40 Szenarien | **351 356 €** | **415 924 €** | **0 von 40** |
Der Erwartungswert-Plan kostet in der Wirklichkeit **149 % mehr**, und sein schlimmster Tag
ist sechsmal so teuer.
Der Unterschied zwischen den beiden Plänen ist dabei erstaunlich klein:
```
Erwartungswert 111111122222222211111111 (33 Blockstunden)
zweistufig 111111222222222222222111 (39 Blockstunden)
```
**Sechs zusätzliche Blockstunden verhindern 28 Lastabwürfe.** Der zweistufige Plan fährt den
zweiten Block eine Stunde früher an und lässt ihn bis 21 Uhr statt bis 16 Uhr laufen.
> **⚠️ Warum der Erwartungswert hier systematisch versagt**
>
> Der Fehler ist nicht, dass die Windprognose ungenau wäre. Er liegt in der **Trägheit der
> ersten Stufe**: Was am Vorabend nicht angefahren wurde, steht am nächsten Tag nicht zur
> Verfügung — egal, wie hoch der Preis dann steigt.
>
> Eine Planung auf den Mittelwert lässt in der Hälfte der Fälle zu wenig Reserve. Bei einer
> *anpassbaren* Größe wäre das halb so schlimm, weil man nachsteuern könnte. Bei einem
> Kernblock mit acht Stunden Mindestlaufzeit gibt es kein Nachsteuern.
>
> Das ist eine Verschärfung des „Fluchs des Durchschnitts" aus
> {ref:sec:unsicherheit-der-fluch-des-durchschnitts}: Dort war der Mittelwert eine schlechte
> Näherung, hier ist er eine **nicht revidierbare** schlechte Näherung.
---
## Was Versorgungssicherheit kostet {#sec:supplychain-sicherheit}
Der dritte Plan legt eine CVaR-Schranke auf die nicht gedeckte Energie: Im Mittel der
schlechtesten 10 % der Szenarien darf **nichts** fehlen. Dieselbe
Rockafellar-Uryasev-Konstruktion wie in {ref:kap:cvar}, nur dass hier Megawattstunden
begrenzt werden statt Euro.
Das Ergebnis ist zunächst enttäuschend — und dann lehrreich:
| Abwurfpreis | risikoneutral | mit CVaR-Schranke | Aufpreis |
| --- | ---: | ---: | ---: |
| 3 000 €/MWh | 351 356 €, kein Abwurf | 351 356 €, kein Abwurf | **0 €** |
| 300 €/MWh | 350 512 €, Abwurf in 8/40 | 351 356 €, kein Abwurf | **844 €** |
Bei 3 000 €/MWh kostet die Schranke **nichts**: Der risikoneutrale Plan hält sie längst ein.
Bei diesem Preis lohnt sich das Vorhalten eines Blocks schon im Erwartungswert, lange bevor
jemand über Risiko spricht.
Erst bei einem Marktpreisdeckel von 300 €/MWh greift sie. Dann nimmt der risikoneutrale Plan
in 8 von 40 Szenarien einen Lastabwurf in Kauf, weil er billiger ist als Vorhaltung — und
die Schranke kostet 844 € je Tag für 18,4 vermiedene MWh im CVaR:
$$
\frac{844\ \text{€}}{18{,}4\ \text{MWh}} \approx 46\ \text{€ je vermiedener MWh}
$$
> **🎯 Merksatz**
> Eine Risikoschranke ändert genau dann etwas, wenn der Schaden **zu niedrig bepreist** ist.
> Ist er realistisch bewertet, erledigt die Minimierung der erwarteten Kosten die Absicherung
> von selbst.
Das ist keine akademische Feinheit, sondern die reale Lage der Energiewirtschaft: Ein
Marktpreisdeckel bildet den volkswirtschaftlichen Schaden eines Stromausfalls nicht ab.
Genau deshalb gibt es regulatorische Vorgaben zur Versorgungssicherheit — und diese Zahl,
46 € je vermiedener MWh, ist das, worüber sich verhandeln lässt. Sie ist dasselbe
Instrument wie die Spalte „€ je kg" in {ref:sec:mehrziel-vorlegen}.
---
## Übungsaufgaben {#sec:supplychain-uebungsaufgaben}
> Lösungen: {ref:sec:loesungen-supplychain}.
**Aufgabe ⭐ — Der Umschlagpunkt.**
Rechnen Sie den Umschlagpunkt aus dem Schnellstart für eine Leistung von 60 MW statt 120 MW
nach. In welche Richtung verschiebt er sich, und warum ist das plausibel?
**Aufgabe ⭐ — Die fehlende Nebenbedingung.**
Das Modell kennt Mindestlaufzeiten, aber keine **Mindeststillstandszeiten**. Formulieren Sie
die entsprechende Bedingung. Welchen realen Sachverhalt bildet sie ab?
**Aufgabe ⭐⭐ — Wie viele Szenarien?**
Rechnen Sie mit 10, 20, 40 und 80 Szenarien. Ab wann ändert sich der Commitment-Plan nicht
mehr? Was folgt daraus für die Praxis — und was hat das mit {ref:sec:prognose-messfalle} zu
tun?
**Aufgabe ⭐⭐ — Der Wert eines Speichers.**
Ergänzen Sie einen Batteriespeicher (100 MW, 400 MWh, Wirkungsgrad 90 %). Um wie viel sinken
die erwarteten Kosten — und was ist der Speicher damit je MWh Kapazität wert?
**Aufgabe ⭐⭐⭐ — Wenn es größer wird.**
Erhöhen Sie auf 20 Blöcke und 168 Stunden (eine Woche). Beobachten Sie MIP-Gap und
Rechenzeit ({ref:sec:milp-gap}). Ab wann wird das Problem für den exakten Solver zu groß —
und welches Verfahren aus {ref:kap:metaheuristiken} würden Sie einsetzen?
---
## Finde den Denkfehler {#sec:supplychain-denkfehler}
> **🐛 „Wir rechnen mit dem P50-Szenario"**
>
> Ein Netzbetreiber beschreibt sein Vorgehen:
>
> > *„Wir verwenden für die Tagesplanung das **P50-Szenario** der Windprognose — also den
> > Median. Die Hälfte der Tage fällt besser aus, die Hälfte schlechter, im Mittel gleicht
> > sich das aus. Für die Extremfälle haben wir zusätzlich eine Reserve von 5 % der Last
> > vorgehalten, das ist die branchenübliche Größenordnung."*
>
> Das klingt sorgfältig, und beides ist gängige Praxis.
>
> **Beide Teile der Aussage enthalten einen Fehler. Welche?**
>
> Ein Hinweis zum zweiten Teil: Sehen Sie sich in der Ausgabe des Programms an, *welche*
> Größe der Plan verändert hat, um die Lastabwürfe zu vermeiden — und vergleichen Sie das
> mit dem, was eine prozentuale Leistungsreserve leisten kann.
---
## Micro-Quiz {#sec:supplychain-quiz}
> **❓ Drei Fragen**
>
> **1. Warum ist die Merit-Order-Regel („billigstes Kraftwerk zuerst") für die Tagesplanung
> nicht ausreichend?**
> a) Weil sie die Netzverluste ignoriert.
> b) Weil Anfahrkosten und Mindestlaufzeiten die Stunden miteinander koppeln — die Entscheidung
> einer Stunde bindet die folgenden.
> c) Weil Grenzkosten in der Praxis nicht bekannt sind.
>
> **2. Was unterscheidet dieses zweistufige Modell von dem in {ref:kap:unsicherheit}?**
> a) Es hat mehr Szenarien.
> b) Die erste Stufe ist **binär** und damit nicht anteilig anpassbar.
> c) Es minimiert den CVaR statt des Erwartungswerts.
>
> **3. Bei 3 000 €/MWh kostet die CVaR-Schranke nichts, bei 300 €/MWh dagegen 844 €/Tag.
> Warum?**
> a) Weil der Solver bei kleineren Zahlen ungenauer rechnet.
> b) Weil bei realistisch hohem Abwurfpreis schon die Minimierung der erwarteten Kosten
> Vorhaltung erzwingt; die Schranke greift nur, wenn der Schaden zu niedrig bepreist ist.
> c) Weil die CVaR-Schranke bei hohen Preisen numerisch unwirksam wird.
---
## Selbsttest {#sec:supplychain-selbsttest}
1. Erklären Sie in zwei Sätzen, warum ein Kraftwerkspark nicht wie ein Regal aufgefüllt
werden kann.
2. Welche Größe ist in diesem Modell die „here and now"-Entscheidung, welche die
„wait and see"-Entscheidung?
3. Warum ist eine Planung auf den Erwartungswert hier *systematisch* zu knapp und nicht nur
ungenau?
4. Wann ändert eine Risikoschranke etwas an der Lösung — und wann nicht?
5. Sie sollen einer Regulierungsbehörde erklären, was ihre Versorgungssicherheitsvorgabe
kostet. Welche Zahl nennen Sie, und wie ist sie entstanden?
---
## Zusammenfassung {#sec:supplychain-zusammenfassung}
* **Anfahrkosten und Mindestlaufzeiten** machen aus 24 Stundenentscheidungen ein einziges
Problem. Die Merit-Order gilt nur für einen einzelnen Zeitpunkt; im Schnellstart schlägt
die fünfmal teurere Gasturbine den Kernblock bis knapp vier Stunden.
* Das Modell ist zweistufig mit **binärer erster Stufe**: Das An/Aus steht am Vorabend fest
und gilt für alle Szenarien, die Fahrweise passt sich an. Das ist eine Verschärfung
gegenüber {ref:kap:unsicherheit}, wo die erste Stufe kontinuierlich war.
* Eine Planung auf den **Wind-Erwartungswert** kostet hier 149 % mehr und führt in 28 von 40
Szenarien zum Lastabwurf. Der Grund ist nicht die Prognosegüte, sondern die Trägheit: Was
nicht angefahren wurde, ist nicht verfügbar.
* Der zweistufige Plan braucht dafür nur **sechs zusätzliche Blockstunden**.
* Eine **CVaR-Schranke** auf die nicht gedeckte Energie ändert nur dann etwas, wenn der
Schaden zu niedrig bepreist ist. Bei realistischen 3 000 €/MWh erledigt die Minimierung
der erwarteten Kosten die Absicherung von selbst.
* Wo sie greift, liefert sie den **Preis der Vorgabe**: 46 € je vermiedener MWh — eine Zahl,
über die man verhandeln kann.

View file

@ -0,0 +1,953 @@
# Kapitel: Finanzdaten-Modellierung — Renditen, Kovarianz und Shrinkage {#kap:finanzdaten}
> **📌 Kapitel auf einen Blick**
>
> **Worum geht es?** Um die Eingangsdaten der Portfoliooptimierung — und darum, warum sie
> trügerisch sind. Wer historische Kovarianzen ungefiltert in einen Optimierer gibt,
> optimiert Rauschen.
>
> **Voraussetzungen:** {ref:kap:qp-nlp} (Eigenwerte, positive Definitheit), Grundlagen der
> Statistik.
>
> **Danach können Sie:** Kursdaten korrekt laden, diskrete und logarithmische Renditen
> unterscheiden, das Schätzfehlerproblem erklären, Ledoit-Wolf-Shrinkage anwenden — und
> erkennen, wann eine Kovarianzmatrix aus zu wenigen Beobachtungen stammt.
>
> **Zeitbedarf:** ca. 5 Stunden.
>
> **Programme:**\
> `Renditen_Vergleich.py`\
> `Schaetzrauschen_Demo.py`\
> `Finanzdaten_Ledoit_Wolf.py`\
> `Kovarianz_Falle.py`
>
> **⚠️ Überspringen Sie dieses Kapitel nicht.** Wer direkt bei Markowitz ({ref:kap:markowitz})
> einsteigt, erhält absurde Portfoliogewichte und weiß nicht, warum.
---
## In 5 Minuten gelöst {#sec:finanzdaten-schnellstart}
> **🚀 In 5 Minuten gelöst: Die durchschnittliche Rendite, die es nicht gibt**
>
> Eine Anlage wurde über vier Perioden beobachtet. Der Kurs stand bei 100, dann bei 50, 75,
> 150 und schließlich bei **120**.
>
> ```python
> import numpy as np
>
> kurse = np.array([100.0, 50.0, 75.0, 150.0, 120.0])
> diskret = kurse[1:] / kurse[:-1] - 1 # (neu - alt) / alt
> log = np.log(kurse[1:] / kurse[:-1]) # ln(neu / alt)
>
> print("diskret:", np.round(diskret * 100, 2), "%")
> print("log :", np.round(log * 100, 2), "%")
> print(f"Mittelwert diskret {diskret.mean()*100:5.2f} % -> hochgerechnet "
> f"{100 * (1 + diskret.mean())**4:6.2f}")
> print(f"Mittelwert log {log.mean()*100:5.2f} % -> hochgerechnet "
> f"{100 * np.exp(log.mean() * 4):6.2f}")
> ```
>
> **Ausgabe:**
>
> ```
> diskret: [-50. 50. 100. -20.] %
> log : [-69.31 40.55 69.31 -22.31] %
> Mittelwert diskret 20.00 % -> hochgerechnet 207.36
> Mittelwert log 4.56 % -> hochgerechnet 120.00
> ```
**Die durchschnittliche Rendite beträgt 20 % pro Periode — bei einer Anlage, die insgesamt
20 % gewonnen hat.** Wer damit vier Perioden hochrechnet, landet bei 207,36 statt bei den
tatsächlichen 120,00.
Der Grund steckt in der ersten Zeile: Ein Verlust von 50 % und ein Gewinn von 50 % heben
sich **nicht** auf. Aus 100 werden 50, daraus 75 — nicht wieder 100. Diskrete Renditen darf
man deshalb nicht mitteln und nicht addieren; sie multiplizieren sich.
Logarithmische Renditen dagegen **addieren** sich. Ihre Summe beträgt hier 18,23 %, und
$100 \cdot e^{0{,}1823} = 120{,}00$ — exakt der beobachtete Endkurs.
| | diskrete Rendite | logarithmische Rendite |
| --- | --- | --- |
| Verknüpfung über die Zeit | multiplikativ: $\prod(1+r_t)$ | **additiv**: $\sum \ln(1+r_t)$ |
| Mitteln zulässig? | nein — führt systematisch zu hoch | ja |
| Verknüpfung über Anlagen | **additiv**: $\sum w_i r_i$ | nein (Summe von Logs ≠ Log der Summe) |
| Wofür also | Portfoliogewichtung, Kennzahlen je Periode | Zeitreihenanalyse, Volatilität, Modelle |
> **🎯 Merksatz**
> Beide Renditearten sind richtig — für **verschiedene Richtungen**. Diskrete Renditen
> addieren sich über die **Anlagen** eines Portfolios, logarithmische über die **Zeit**.
> Wer sie verwechselt, bekommt keine Fehlermeldung, sondern eine plausible falsche Zahl.
**Warum funktioniert das?** Weil der Logarithmus aus einem Produkt eine Summe macht:
$\ln(a \cdot b) = \ln a + \ln b$. Genau diese Eigenschaft braucht man, wenn sich Renditen
über die Zeit aufmultiplizieren. Der Preis dafür: Über mehrere Anlagen hinweg funktioniert
es nicht mehr, denn der Logarithmus einer Summe ist nicht die Summe der Logarithmen. Der
Rest dieses Kapitels behandelt die zweite große Fehlerquelle bei Finanzdaten — die
**Kovarianzmatrix**, deren Schätzung noch weit trügerischer ist als die Rendite selbst.
---
## Lernziele {#sec:finanzdaten-lernziele}
Nach diesem Kapitel können Sie …
1. … erklären, wann man diskrete und wann logarithmische Renditen verwendet.
2. … beschreiben, warum die Stichproben-Kovarianzmatrix bei vielen Titeln unbrauchbar wird.
3. … den **Error-Maximizer-Effekt**{idx:Error-Maximizer-Effekt} an einem Experiment nachweisen.
4. … Ledoit-Wolf-Shrinkage anwenden und ihre Wirkung an Eigenwerten und Konditionszahl
messen.
5. … Kursdaten so laden, dass die Spaltenreihenfolge garantiert stimmt.
6. … begründen, warum eine Schätzung aus $T < N$ Beobachtungen dem Optimierer risikofreie
Richtungen vorgaukelt — und welche drei Gegenmittel es gibt.
7. … ein Portfolio **außerhalb** des Schätzzeitraums bewerten statt darin.
---
## Diskrete und logarithmische Renditen {#sec:finanzdaten-diskrete-und-logarithmische-renditen}
Sei $P_{i,t}$ der **bereinigte** Schlusskurs (*adjusted close*){idx:Adjusted Close} von Titel $i$ zum Zeitpunkt
$t$. „Bereinigt“ heißt: um Dividenden und Aktiensplits korrigiert — sonst erscheint jede
Dividendenzahlung als Kurssturz.
### Diskrete Rendite
$$
R_{i,t} = \frac{P_{i,t} - P_{i,t-1}}{P_{i,t-1}} = \frac{P_{i,t}}{P_{i,t-1}} - 1
$$
**Eigenschaft — über Titel additiv:**{idx:Diskrete Rendite}
$$
R_{p,t} = \sum_{i=1}^n w_i R_{i,t} = \mathbf{w}^\top\mathbf{R}_t
$$
### Logarithmische Rendite
$$
r_{i,t} = \ln\!\left(\frac{P_{i,t}}{P_{i,t-1}}\right) = \ln P_{i,t} - \ln P_{i,t-1}
$$
**Eigenschaft — über die Zeit additiv:**{idx:Logarithmische Rendite}
$$
r_{i,0\to T} = \sum_{t=1}^T r_{i,t}
$$
> **🎯 Die Merkregel**
> **Diskrete Renditen addieren sich über das Portfolio** (mehrere Titel, ein Zeitpunkt) —
> deshalb braucht man sie für die **Allokation**.
> **Logarithmische Renditen addieren sich über die Zeit** (ein Titel, mehrere Perioden) —
> deshalb braucht man sie für **Zeitreihenanalyse und Volatilitätsschätzung**.
>
> Man kann nicht beides gleichzeitig haben: Die gewichtete Summe von Log-Renditen ist
> **nicht** die Log-Rendite des Portfolios.
> **✏️ Handrechnung: Der Unterschied in Zahlen**
>
> Ein Kurs steigt von 100 auf 150, dann fällt er zurück auf 100.
>
> | | Periode 1 | Periode 2 | Summe | Wahrheit |
> | --- | --- | --- | --- | --- |
> | **Diskret** | $+50{,}0\,\%$ | $-33{,}3\,\%$ | $+16{,}7\,\%$ ✗ | $0\,\%$ |
> | **Logarithmisch** | $+0{,}4055$ | $-0{,}4055$ | $0{,}0000$ ✓ | $0\,\%$ |
>
> Die diskreten Renditen **suggerieren einen Gewinn von 16,7 %**, obwohl der Kurs exakt dort
> steht, wo er begann. Die Log-Renditen heben sich korrekt auf.
>
> **Deshalb ist die Aussage „im Mittel 5 % Rendite“ mehrdeutig.** Das arithmetische Mittel
> diskreter Renditen überschätzt systematisch, was ein Anleger tatsächlich verdient hätte.
> Für Wachstumsaussagen nimmt man das **geometrische** Mittel — oder rechnet gleich in
> Log-Renditen.
```python
#!/usr/bin/env python3
# Renditen_Vergleich.py
"""
Kapitel Finanzdaten: Diskrete vs. logarithmische Renditen.
Zeigt an einem simulierten Kursverlauf, welche Eigenschaft wo gilt -
und warum das arithmetische Mittel diskreter Renditen in die Irre fuehrt.
"""
import numpy as np
import pandas as pd
if __name__ == "__main__":
kurse = np.array([100.0, 150.0, 100.0, 120.0, 90.0, 135.0])
tage = [f"t{i}" for i in range(len(kurse))]
diskret = kurse[1:] / kurse[:-1] - 1.0
logarithmisch = np.log(kurse[1:] / kurse[:-1])
print("=" * 74)
print(" DISKRETE UND LOGARITHMISCHE RENDITEN IM VERGLEICH")
print("=" * 74)
tabelle = pd.DataFrame({
"Periode": [f"{tage[i]} -> {tage[i+1]}" for i in range(len(diskret))],
"Kurs von": kurse[:-1],
"Kurs bis": kurse[1:],
"diskret R": [f"{r*100:+7.2f} %" for r in diskret],
"log r": [f"{r:+8.4f}" for r in logarithmisch],
})
print(tabelle.to_string(index=False))
# --- Zeitliche Aggregation ------------------------------------------
gesamt_wahr = kurse[-1] / kurse[0] - 1.0
summe_log = logarithmisch.sum()
aus_log_zurueck = np.exp(summe_log) - 1.0
summe_diskret = diskret.sum()
print("\n--- Aggregation ueber die Zeit ---")
print(f" Tatsaechliche Gesamtrendite: {gesamt_wahr*100:+8.2f} %")
print(f" Summe der Log-Renditen -> exp()-1: {aus_log_zurueck*100:+8.2f} % "
f"{'KORREKT' if abs(aus_log_zurueck - gesamt_wahr) < 1e-9 else 'falsch'}")
print(f" Summe der diskreten Renditen: {summe_diskret*100:+8.2f} % FALSCH")
# --- Mittelwerte ------------------------------------------------------
arithmetisch = diskret.mean()
geometrisch = np.prod(1 + diskret) ** (1 / len(diskret)) - 1
aus_log = np.exp(logarithmisch.mean()) - 1
print("\n--- Welcher Mittelwert ist der richtige? ---")
print(f" Arithmetisches Mittel (diskret): {arithmetisch*100:+8.2f} % "
f"-> ueberschaetzt")
print(f" Geometrisches Mittel: {geometrisch*100:+8.2f} % -> korrekt")
print(f" exp(Mittel der Log-Renditen) - 1: {aus_log*100:+8.2f} % "
f"-> identisch zum geometrischen")
# Probe: Endkapital mit dem jeweiligen Mittelwert hochgerechnet
n = len(diskret)
print(f"\n Probe - Startkapital 100 EUR ueber {n} Perioden:")
print(f" tatsaechlich: {kurse[-1]:8.2f} EUR")
print(f" mit arithm. Mittel hochgerechnet: {100*(1+arithmetisch)**n:8.2f} EUR")
print(f" mit geom. Mittel hochgerechnet: {100*(1+geometrisch)**n:8.2f} EUR")
print("\n--- Annualisierung (252 Handelstage) ---")
print(f" Volatilitaet aus Log-Renditen: "
f"{logarithmisch.std(ddof=1)*np.sqrt(252)*100:.2f} % p.a.")
print(" (Die Wurzel-Zeit-Regel gilt nur bei unabhaengigen Renditen -")
print(" fuer reale Maerkte ist sie eine Naeherung.)")
print("=" * 74)
```
---
## Das Schätzfehler-Problem {#sec:finanzdaten-das-schaetzfehler-problem}
Sei $\mathbf{X}\in\mathbb{R}^{T\times N}$ die Matrix zentrierter Renditen von $N$ Titeln über
$T$ Handelstage. Die **Stichproben-Kovarianzmatrix**{idx:Stichproben-Kovarianzmatrix} lautet:
$$
\mathbf{S} = \frac{1}{T-1}\mathbf{X}^\top\mathbf{X}
$$
Das Problem: $\mathbf{S}$ hat $\frac{N(N+1)}{2}$ zu schätzende Parameter, aber nur
$T \cdot N$ Datenpunkte. Bei $N = 50$ Titeln sind das **1275 Parameter** — geschätzt aus
einem Jahr Daten ($T = 252$).
![Ledoit-Wolf-Kovarianz-Shrinkage](bilder_04/kap11_ledoit_wolf_shrinkage.svg)
| Verhältnis | Folge |
| --- | --- |
| $T \gg N$ | $\mathbf{S}$ ist brauchbar |
| $T \approx N$ | Extrem instabil; Eigenwerte streuen weit |
| $T < N$ | $\mathbf{S}$ ist **singulär** — nicht invertierbar, Rang $< N$ |
Die **Zufallsmatrizentheorie**{idx:Zufallsmatrizentheorie} (*Random Matrix Theory*, Marchenko-Pastur-Gesetz{idx:Marchenko-Pastur-Gesetz}) zeigt: Die
kleinsten Eigenwerte von $\mathbf{S}$ werden **systematisch unterschätzt**, die größten
überschätzt. Und genau das ist fatal, denn:
> **🎯 Der Error-Maximizer-Effekt**
> Ein Risikominimierer sucht die Richtungen mit der **kleinsten** Varianz — also genau jene
> Eigenrichtungen, deren Eigenwerte am stärksten **nach unten verzerrt** sind. Die
> Optimierung greift damit zielsicher in das Schätzrauschen hinein. Markowitz-Optimierung
> ist deshalb kein Fehlerdämpfer, sondern ein **Fehlerverstärker**.
```python
#!/usr/bin/env python3
# Schaetzrauschen_Demo.py
"""
Kapitel Finanzdaten: Der Error-Maximizer-Effekt im Experiment.
Aufbau: Wir KENNEN die wahre Kovarianzmatrix, weil wir die Daten selbst
erzeugen. Dann schaetzen wir sie aus endlich vielen Beobachtungen und
vergleichen, wie gut das daraus optimierte Portfolio in der WAHREN Welt
abschneidet - mit und ohne Shrinkage.
Das ist der einzige saubere Weg, Schaetzfehler zu messen: Man braucht eine
Wahrheit zum Vergleich, und die gibt es nur in der Simulation.
"""
import numpy as np
from sklearn.covariance import LedoitWolf
def erzeuge_wahre_kovarianz(n, rng):
"""Ein-Faktor-Modell: gemeinsamer Marktfaktor plus titelspezifisches Rauschen."""
beta = rng.uniform(0.6, 1.4, size=n) # Marktsensitivitaeten
var_markt = 0.04 # Marktvarianz p.a.
var_spezifisch = rng.uniform(0.01, 0.09, size=n) # idiosynkratische Varianz
return np.outer(beta, beta) * var_markt + np.diag(var_spezifisch)
def minimum_varianz_gewichte(sigma):
"""Analytische GMV-Loesung: w = Sigma^-1 1 / (1' Sigma^-1 1). Ohne Restriktionen."""
eins = np.ones(len(sigma))
try:
loesung = np.linalg.solve(sigma, eins)
except np.linalg.LinAlgError:
loesung = np.linalg.pinv(sigma) @ eins # falls singulaer
return loesung / loesung.sum()
def portfoliovolatilitaet(w, sigma):
return float(np.sqrt(w @ sigma @ w))
if __name__ == "__main__":
rng = np.random.default_rng(2026)
N = 40 # Anzahl Titel
WIEDERHOLUNGEN = 200
sigma_wahr = erzeuge_wahre_kovarianz(N, rng)
w_ideal = minimum_varianz_gewichte(sigma_wahr)
vola_ideal = portfoliovolatilitaet(w_ideal, sigma_wahr)
print("=" * 92)
print(" ERROR-MAXIMIZER: WIE TEUER IST SCHAETZRAUSCHEN?")
print("=" * 92)
print(f"Aufbau: {N} Titel, wahre Kovarianz bekannt (Ein-Faktor-Modell).")
print(f"Bestmoegliche Volatilitaet bei perfektem Wissen: "
f"{vola_ideal*100:.2f} % p.a.\n")
print(f"{'T (Tage)':>9} | {'T/N':>5} | {'Stichprobe':>22} | {'Ledoit-Wolf':>22} | "
f"{'Shrink':>7}")
print(f"{'':>9} | {'':>5} | {'Vola Aufschlag':>22} | "
f"{'Vola Aufschlag':>22} | {'delta':>7}")
print("-" * 92)
for T in [60, 120, 252, 504, 1260]:
vola_stichprobe, vola_lw, deltas = [], [], []
for _ in range(WIEDERHOLUNGEN):
# Daten aus der WAHREN Verteilung ziehen (taeglich)
daten = rng.multivariate_normal(np.zeros(N), sigma_wahr / 252, size=T)
# (a) Stichproben-Kovarianz
s_stich = np.cov(daten, rowvar=False, ddof=1) * 252
w_stich = minimum_varianz_gewichte(s_stich)
# (b) Ledoit-Wolf-Shrinkage
lw = LedoitWolf().fit(daten)
s_lw = lw.covariance_ * 252
w_lw = minimum_varianz_gewichte(s_lw)
deltas.append(lw.shrinkage_)
# Bewertung IMMER mit der wahren Kovarianz - das ist der Punkt
vola_stichprobe.append(portfoliovolatilitaet(w_stich, sigma_wahr))
vola_lw.append(portfoliovolatilitaet(w_lw, sigma_wahr))
m_stich, m_lw = np.mean(vola_stichprobe), np.mean(vola_lw)
print(f"{T:>9} | {T/N:>5.1f} | {m_stich*100:>8.2f} % "
f"{(m_stich/vola_ideal-1)*100:>+9.1f} % | "
f"{m_lw*100:>8.2f} % {(m_lw/vola_ideal-1)*100:>+9.1f} % | "
f"{np.mean(deltas):>7.3f}")
print("-" * 92)
print("'Aufschlag' = wie viel mehr Risiko das Portfolio in der WAHREN Welt traegt,")
print("verglichen mit dem Portfolio bei perfektem Wissen.")
print("Lesart: Je kleiner T/N, desto teurer das Schaetzrauschen - und desto mehr")
print("schrumpft Ledoit-Wolf (delta steigt).")
print("=" * 92)
```
---
**Erwartete Ausgabe:**
```
============================================================================================
ERROR-MAXIMIZER: WIE TEUER IST SCHAETZRAUSCHEN?
============================================================================================
Aufbau: 40 Titel, wahre Kovarianz bekannt (Ein-Faktor-Modell).
Bestmoegliche Volatilitaet bei perfektem Wissen: 11.84 % p.a.
T (Tage) | T/N | Stichprobe | Ledoit-Wolf | Shrink
| | Vola Aufschlag | Vola Aufschlag | delta
--------------------------------------------------------------------------------------------
60 | 1.5 | 20.78 % +75.5 % | 15.44 % +30.4 % | 0.093
120 | 3.0 | 14.42 % +21.8 % | 13.79 % +16.5 % | 0.048
252 | 6.3 | 12.86 % +8.7 % | 12.76 % +7.8 % | 0.024
504 | 12.6 | 12.33 % +4.1 % | 12.30 % +3.9 % | 0.012
1260 | 31.5 | 12.03 % +1.6 % | 12.02 % +1.6 % | 0.005
--------------------------------------------------------------------------------------------
```
**Diese Tabelle ist das Kernargument des ganzen Kapitels.** Lesen Sie die erste Zeile: Bei
nur 60 Beobachtungen für 40 Titel trägt das „risikominimale“ Portfolio in Wahrheit
**75,5 % mehr Risiko** als nötig. Der Optimierer hat nicht das Risiko minimiert, sondern
das **geschätzte** Risiko — und die Differenz ist der Schätzfehler, in den er
hineinoptimiert hat.
Drei Beobachtungen:
1. **Der Schaden wächst dramatisch, je knapper die Daten.** Von $T/N = 31{,}5$ (+1,6 %) bis
$T/N = 1{,}5$ (+75,5 %) ist es kein gleitender Übergang, sondern eine Explosion.
2. **Shrinkage halbiert den Schaden dort, wo er groß ist** (75,5 % → 30,4 %) und schadet
dort nichts, wo er klein ist (1,6 % → 1,6 %). Sie ist damit praktisch risikolos
einzusetzen.
3. **$\delta$ passt sich automatisch an:** viel Schrumpfung bei wenig Daten (0,093), fast
keine bei vielen (0,005). Man muss nichts von Hand einstellen.
> **🎯 Die Praxisregel**
> Faustregel für Aktienportfolios: **$T \ge 5N$** sollte man mindestens haben, besser
> $T \ge 10N$. Bei 50 Titeln sind das 250 bis 500 Handelstage — ein bis zwei Jahre. Wer
> weniger hat, sollte entweder das Universum verkleinern oder mit Shrinkage arbeiten. Am
> besten beides.
## Ledoit-Wolf-Shrinkage {#sec:finanzdaten-ledoit-wolf-shrinkage}
Olivier Ledoit und Michael Wolf lösten das Dilemma durch **lineare Schrumpfung**{idx:Ledoit-Wolf-Shrinkage}:
$$
\boldsymbol{\Sigma}_{\text{LW}} = (1-\delta)\,\mathbf{S} + \delta\,\mathbf{F}
$$
> **📐 Formel-Lesehilfe**
> * $\mathbf{S}$ — die Stichprobenmatrix: **unverzerrt**, aber verrauscht.
> * $\mathbf{F}$ — das **Schrumpfungsziel**: verzerrt, aber stabil und rauschfrei.
> * $\delta \in [0,1]$ — wie weit man vom Rauschen zur Struktur zieht.
>
> **Ohne Formel gesagt:** Man mischt eine zappelige, aber im Mittel richtige Schätzung mit
> einer ruhigen, aber etwas schiefen. Die Mischung ist besser als beide Bestandteile
> einzeln — ein klassischer **Bias-Varianz-Kompromiss**. Ledoit und Wolf berechnen dabei
> dasjenige $\delta$, das den erwarteten quadratischen Fehler zur unbekannten wahren Matrix
> **minimiert** — analytisch, ohne Kreuzvalidierung.
### Welches Ziel $\mathbf{F}$?
Ein häufiges Missverständnis: Es gibt nämlich **mehrere** Ledoit-Wolf-Varianten, und Text
und Code müssen sich konsistent auf dieselbe beziehen:
| Ziel $\mathbf{F}$ | Formel | Quelle | Wer benutzt es |
| ----------------------------------- | -------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------- |
| **Skalierte Einheitsmatrix** | $\mathbf{F} = \frac{\operatorname{tr}(\mathbf{S})}{N}\,\mathbf{I}$ | Ledoit/Wolf 2004, *„A well-conditioned estimator“* | **`sklearn.covariance.LedoitWolf`** |
| **Konstante Korrelation** | $f_{ii} = s_{ii}$, $f_{ij} = \bar{r}\sqrt{s_{ii}s_{jj}}$ | Ledoit/Wolf 2003, *„Honey, I shrunk the sample covariance matrix“* | oft in der Praxis, z. B. `PyPortfolioOpt` |
| **Ein-Faktor-Modell** | aus Marktbeta geschätzt | Ledoit/Wolf 2003 | Faktormodell-Ansätze |
**Wichtig ist, Text und Code konsistent auf dieselbe Variante zu beziehen** — zum
Beispiel `sklearn.covariance.LedoitWolf`, das die skalierte Einheitsmatrix verwendet,
versus eine manuelle Implementierung des Konstant-Korrelations-Ziels. Dieses Buch nennt
beide und macht transparent, welches wo zum Einsatz kommt.
> **💡 Welches Ziel ist besser?**
> Es kommt darauf an. Die **Einheitsmatrix** unterstellt, dass alle Titel gleich riskant und
> unkorreliert sind — eine starke, aber sehr stabile Annahme. Das
> **Konstant-Korrelations-Ziel** behält die geschätzten Einzelvarianzen und glättet nur die
> Korrelationen; das ist bei Aktien meist realistischer, weil Titel tatsächlich sehr
> unterschiedliche Volatilitäten haben. In Übung 11.5 implementieren Sie es selbst und
> vergleichen.
Das Ergebnis ist in jedem Fall garantiert **positiv definit**, wohlkonditioniert und stabil
invertierbar.
---
## Praxis: Datenpipeline mit korrekter Spaltenreihenfolge {#sec:finanzdaten-praxis-datenpipeline-mit-korrekter-spaltenreihenfolge}
> **⚠️ Die wichtigste Zeile dieses Kapitels**
>
> `yfinance` liefert die Kursspalten **alphabetisch sortiert**, nicht in der Reihenfolge
> Ihrer Ticker-Liste:
>
> ```
> Ihre Liste: ['AAPL', 'MSFT', 'NVDA', 'AMZN', 'JNJ', 'PFE', 'JPM', 'GS', 'XOM', 'CVX']
> prices.columns: ['AAPL', 'AMZN', 'CVX', 'GS', 'JNJ', 'JPM', 'MSFT', 'NVDA', 'PFE', 'XOM']
> ```
>
> Wer danach mit Positionsindizes arbeitet, beschriftet Ergebnisse falsch und beschränkt
> falsche Sektoren. **Die Abhilfe ist eine Zeile:**
> ```python
> prices = raw["Close"][tickers].dropna() # erzwingt die eigene Reihenfolge
> assert list(prices.columns) == tickers
> ```
```python
#!/usr/bin/env python3
# Finanzdaten_Ledoit_Wolf.py
"""
Kapitel Finanzdaten: Automatisierte Finanzdaten-Pipeline und Kovarianz-Shrinkage.
Eigenschaften:
* Spaltenreihenfolge wird erzwungen und per assert geprueft
* beide Shrinkage-Ziele werden berechnet und verglichen
* Datenqualitaetspruefungen (Luecken, Ausreisser, Mindestlaenge)
* Eigenwertspektrum wird ausgewiesen, nicht nur die Konditionszahl
"""
import numpy as np
import pandas as pd
import yfinance as yf
from sklearn.covariance import LedoitWolf
HANDELSTAGE = 252
def lade_kurse(tickers: list[str], start, ende) -> pd.DataFrame:
"""Laedt bereinigte Schlusskurse und garantiert die Spaltenreihenfolge."""
print(f"Lade {len(tickers)} Ticker von {start} bis {ende} ...")
roh = yf.download(tickers, start=start, end=ende, auto_adjust=True, progress=False)
if roh.empty:
raise SystemExit("Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).")
if isinstance(roh.columns, pd.MultiIndex):
# [tickers] erzwingt die gewuenschte Spaltenreihenfolge
kurse = roh["Close"][tickers].dropna()
else:
kurse = roh[["Close"]].dropna()
kurse.columns = tickers
assert list(kurse.columns) == tickers, (
f"Spaltenreihenfolge weicht ab!\n erwartet: {tickers}\n"
f" erhalten: {list(kurse.columns)}")
return kurse
def pruefe_datenqualitaet(kurse: pd.DataFrame, mindest_tage: int = 200) -> None:
"""Faengt die haeufigsten Datenprobleme ab, bevor sie ins Modell gelangen."""
T, N = kurse.shape
print(f"\nDatenqualitaet: {T} Handelstage, {N} Titel (T/N = {T/N:.1f})")
if T < mindest_tage:
raise SystemExit(f"Zu wenige Beobachtungen ({T} < {mindest_tage}).")
if T < N:
print(" WARNUNG: T < N - die Stichprobenkovarianz ist singulaer!")
elif T < 3 * N:
print(" WARNUNG: T < 3N - erhebliches Schaetzrauschen zu erwarten.")
renditen = kurse.pct_change().dropna()
extreme = (renditen.abs() > 0.25).sum().sum()
if extreme:
print(f" Hinweis: {extreme} Tagesrenditen ueber 25 % "
f"(Splits, Sondersituationen oder Datenfehler pruefen).")
luecken = kurse.isna().sum().sum()
print(f" Fehlende Werte nach dropna: {luecken}")
def ziel_konstante_korrelation(renditen: np.ndarray) -> np.ndarray:
"""
Shrinkage-Ziel nach Ledoit/Wolf 2003: Einzelvarianzen behalten,
alle Korrelationen durch ihren Mittelwert ersetzen.
(sklearn nutzt stattdessen die skalierte Einheitsmatrix - siehe oben.)
"""
S = np.cov(renditen, rowvar=False, ddof=1)
d = np.sqrt(np.diag(S))
korrelation = S / np.outer(d, d)
n = len(S)
r_quer = (korrelation.sum() - n) / (n * (n - 1)) # Mittel ohne Diagonale
F = r_quer * np.outer(d, d)
np.fill_diagonal(F, np.diag(S))
return F
def analysiere(kurse: pd.DataFrame):
renditen = kurse.pct_change().dropna()
T, N = renditen.shape
mu = renditen.mean().values * HANDELSTAGE
sigma_stichprobe = renditen.cov().values * HANDELSTAGE
lw = LedoitWolf(assume_centered=False).fit(renditen.values)
sigma_lw = lw.covariance_ * HANDELSTAGE
delta = lw.shrinkage_
print("\n" + "=" * 84)
print(" FINANZDATEN-PIPELINE UND MATRIX-KONDITIONIERUNG")
print("=" * 84)
print(f"Beobachtungen T: {T}")
print(f"Titel N: {N}")
print(f"Optimales Shrinkage delta: {delta:.4f} "
f"({delta*100:.1f} % Gewicht auf dem strukturierten Ziel)")
eig_stich = np.linalg.eigvalsh(sigma_stichprobe)
eig_lw = np.linalg.eigvalsh(sigma_lw)
print("\n--- Eigenwertspektrum (annualisiert) ---")
print(f"{'':<14} {'kleinster':>12} {'Median':>12} {'groesster':>12} "
f"{'Kondition':>12}")
for name, eig in [("Stichprobe", eig_stich), ("Ledoit-Wolf", eig_lw)]:
kondition = eig.max() / max(eig.min(), 1e-12)
print(f"{name:<14} {eig.min():>12.6f} {np.median(eig):>12.6f} "
f"{eig.max():>12.6f} {kondition:>12.1f}")
print("\nDie Konditionszahl misst, wie stark sich kleine Datenaenderungen auf")
print("die Inverse auswirken. Je kleiner, desto stabiler die Portfoliogewichte.")
# --- Vergleich der beiden Shrinkage-Ziele ----------------------------
S_taeglich = np.cov(renditen.values, rowvar=False, ddof=1)
F_identitaet = np.eye(N) * np.trace(S_taeglich) / N
F_korrelation = ziel_konstante_korrelation(renditen.values)
print("\n--- Die beiden Shrinkage-Ziele im Vergleich ---")
for name, F in [("skalierte Einheitsmatrix (sklearn)", F_identitaet),
("konstante Korrelation (LW 2003)", F_korrelation)]:
gemischt = ((1 - delta) * S_taeglich + delta * F) * HANDELSTAGE
eig = np.linalg.eigvalsh(gemischt)
print(f" {name:<36}: Kondition {eig.max()/max(eig.min(),1e-12):8.1f}, "
f"kleinster EW {eig.min():.6f}")
print("\n--- Erwartete Renditen (annualisiert) ---")
uebersicht = pd.DataFrame({
"Ticker": kurse.columns,
"Rendite p.a.": [f"{r*100:+7.2f} %" for r in mu],
"Volatilitaet p.a.": [f"{np.sqrt(sigma_lw[i, i])*100:6.2f} %" for i in range(N)],
})
print(uebersicht.to_string(index=False))
print("=" * 84)
return renditen, mu, sigma_stichprobe, sigma_lw
if __name__ == "__main__":
UNIVERSUM = ["AAPL", "MSFT", "NVDA", "AMZN", # Technologie
"JNJ", "PFE", # Gesundheit
"JPM", "GS", # Banken
"XOM", "CVX"] # Energie
# Relatives 2-Jahres-Fenster, damit das Beispiel nicht "altert"
ende = pd.Timestamp.today().normalize()
start = ende - pd.DateOffset(years=2)
kurse = lade_kurse(UNIVERSUM, start.strftime("%Y-%m-%d"), ende.strftime("%Y-%m-%d"))
pruefe_datenqualitaet(kurse)
analysiere(kurse)
```
> **⚠️ Zur Reproduzierbarkeit**
> Dieses Programm lädt **Live-Daten**. Ihre Zahlen werden von den hier abgedruckten
> abweichen — je nach Abrufdatum. Das ist beabsichtigt und selbst eine Lektion: Wenn sich
> das optimale Portfolio zwischen zwei Abrufen deutlich ändert, obwohl nur ein paar Tage
> vergangen sind, sehen Sie den Schätzfehler direkt. Prüfen Sie das ruhig einmal.
---
## Übungsaufgaben {#sec:finanzdaten-uebungsaufgaben}
> Lösungen: {ref:sec:loesungen-finanzdaten}.
**Aufgabe ⭐ — Renditeart wählen.**
Welche Renditeart nehmen Sie und warum?
(a) Portfoliorendite aus Einzelrenditen berechnen.
(b) Volatilität über 5 Jahre schätzen.
(c) Kumulierte Wertentwicklung über 10 Jahre darstellen.
(d) Korrelationen zwischen Titeln schätzen.
**Aufgabe ⭐ — Mittelwertfalle.**
Ein Fonds meldet Jahresrenditen von $+60\,\%$, $-40\,\%$, $+60\,\%$, $-40\,\%$.
(a) Wie hoch ist das arithmetische Mittel?
(b) Wie viel Kapital hat ein Anleger nach 4 Jahren aus 10 000 €?
(c) Welche jährliche Rendite entspricht dem tatsächlich?
**Aufgabe ⭐⭐ — Konditionszahl verstehen.**
Erzeugen Sie eine Kovarianzmatrix für $N = 30$ Titel aus $T = 40$, $T = 100$ und $T = 1000$
simulierten Beobachtungen. Berechnen Sie jeweils kleinsten Eigenwert und Konditionszahl.
Ab welchem $T$ wird die Matrix brauchbar?
**Aufgabe ⭐⭐ — Spaltenreihenfolge prüfen.**
Laden Sie fünf Ticker in einer bewusst unsortierten Reihenfolge. Zeigen Sie, dass
`raw["Close"].columns` abweicht, und dass `raw["Close"][tickers]` das behebt. Was passiert,
wenn ein Ticker nicht existiert?
**Aufgabe ⭐⭐⭐ — Beide Shrinkage-Ziele vergleichen.**
Implementieren Sie das Konstant-Korrelations-Ziel vollständig (inklusive eigener
Berechnung von $\delta$ nach Ledoit/Wolf 2003 oder per Kreuzvalidierung). Vergleichen Sie
mit `sklearn` anhand:
(a) der Konditionszahl,
(b) der Out-of-Sample-Volatilität eines GMV-Portfolios (nach dem Muster von
`Schaetzrauschen_Demo.py`),
(c) der Stabilität der Gewichte über rollierende Fenster.
**Aufgabe ⭐⭐⭐ — Error-Maximizer selbst messen.**
Erweitern Sie `Schaetzrauschen_Demo.py` um eine dritte Variante: die
**Gleichgewichtung** ($w_i = 1/N$), die überhaupt keine Schätzung benötigt.
(a) Ab welchem $T/N$ schlägt die Stichproben-Optimierung die Gleichgewichtung?
(b) Ab welchem $T/N$ schlägt Ledoit-Wolf sie?
(c) Was folgt daraus für die Praxis?
---
## Finde den Denkfehler {#sec:finanzdaten-denkfehler}
> **🐛 Finde den Denkfehler 11.1: Das risikofreie Portfolio**
>
> Ein Analyst stellt ein Portfolio aus **30 Titeln** zusammen. Für die Kovarianzmatrix
> nimmt er das letzte Quartal — bei Wochendaten sind das **20 Beobachtungen**. Dann
> minimiert er die Varianz.
>
> Das Ergebnis begeistert ihn: Das Portfolio hat ein Risiko von **0,000 %**. Er hat ein
> risikofreies Aktienportfolio gefunden.
>
> **Ihre Aufgabe:** (a) Wie hoch ist der Rang einer Kovarianzmatrix, die aus $T$
> Beobachtungen für $N > T$ Anlagen geschätzt wurde — und was folgt daraus geometrisch?
> (b) Warum findet ausgerechnet ein **Minimum**-Varianz-Optimierer diese Stelle so
> zuverlässig? (c) Sehen Sie sich unten die Spalte „Hebel“ an. Was sagt ein Wert von 3,8
> über das Portfolio aus? (d) Warum schlägt die simple Gleichgewichtung den Optimierer —
> und was folgt daraus für Ihre eigenen Modelle, auch außerhalb der Finanzwelt?
>
> *Auflösung: {ref:sec:loesungen-finanzdaten}.*
Das folgende Programm führt den Fall vor. Es arbeitet mit **synthetischen** Daten, damit
das Ergebnis reproduzierbar ist und kein Internetzugang nötig wird — mit echten Kursdaten
sieht es genauso aus.
```python
#!/usr/bin/env python3
# Kovarianz_Falle.py
"""
Kapitel Finanzdaten: Warum die historische Kovarianzmatrix den Optimierer belaeugt.
Ein Portfolio aus 30 Anlagen soll das Risiko minimieren. Geschaetzt wird die
Kovarianzmatrix aus 20 Beobachtungen - in der Praxis ein voellig ueblicher
Fall (30 Titel, ein Quartal Wochendaten).
Das Ergebnis ist ein Portfolio mit einem gemessenen Risiko von 0,000 %. Der
Optimierer haelt es fuer risikofrei. Ausserhalb des Schaetzzeitraums ist es
das schlechteste der drei untersuchten Portfolios - schlechter sogar als
blosse Gleichgewichtung.
Der Grund ist nicht Zufall, sondern Struktur: Bei N Anlagen und T < N
Beobachtungen hat die Stichproben-Kovarianzmatrix hoechstens Rang T-1. Es gibt
dann ganze Richtungen im Gewichtsraum, in denen sie exakt null Varianz misst -
Richtungen, die es in Wirklichkeit nicht gibt. Der Optimierer findet sie
zuverlaessig, denn er sucht ja genau danach.
Die Daten sind synthetisch (ein Marktfaktor plus Eigenrauschen), damit das
Ergebnis reproduzierbar ist und kein Internetzugang noetig wird. Mit echten
Kursdaten sieht es genauso aus.
Benoetigt: numpy, cvxpy, scikit-learn
"""
from __future__ import annotations
import numpy as np
import cvxpy as cp
from sklearn.covariance import LedoitWolf
ANZAHL_ANLAGEN = 30
BEOBACHTUNGEN_SCHAETZUNG = 20 # weniger als Anlagen - genau darum geht es
BEOBACHTUNGEN_TEST = 2000 # der "spaetere Verlauf"
RNG = np.random.default_rng(11)
# Jede Anlage reagiert unterschiedlich stark auf den Gesamtmarkt.
BETA = RNG.uniform(0.6, 1.4, ANZAHL_ANLAGEN)
def erzeuge_renditen(perioden: int) -> np.ndarray:
"""Ein Marktfaktor, auf den alle reagieren, plus Eigenrauschen je Anlage."""
markt = RNG.normal(0.0, 0.010, perioden)
eigen = RNG.normal(0.0, 0.012, (perioden, ANZAHL_ANLAGEN))
return np.outer(markt, BETA) + eigen
def minimales_risiko(kovarianz: np.ndarray) -> tuple[np.ndarray, float]:
"""Minimum-Varianz-Portfolio: Gewichte summieren sich zu 1, sonst frei.
cp.psd_wrap() sagt CVXPY: 'Ich weiss, dass diese Matrix numerisch nicht
exakt positiv semidefinit ist - rechne trotzdem.' Das ist hier bewusst so
gewaehlt, denn genau diese Eigenschaft wollen wir vorfuehren. Im
Produktivbetrieb waere die DCP-Fehlermeldung das Warnsignal, dem man
nachgehen muss (siehe Denkfehler 8.1).
"""
gewichte = cp.Variable(ANZAHL_ANLAGEN)
problem = cp.Problem(cp.Minimize(cp.quad_form(gewichte, cp.psd_wrap(kovarianz))),
[cp.sum(gewichte) == 1])
problem.solve()
return np.array(gewichte.value).ravel(), max(problem.value, 0.0)
if __name__ == "__main__":
schaetzzeitraum = erzeuge_renditen(BEOBACHTUNGEN_SCHAETZUNG)
spaeterer_verlauf = erzeuge_renditen(BEOBACHTUNGEN_TEST)
stichprobe = np.cov(schaetzzeitraum, rowvar=False)
ledoit_wolf = LedoitWolf().fit(schaetzzeitraum)
print("=" * 78)
print(" DIE KOVARIANZ-FALLE")
print("=" * 78)
print(f"{ANZAHL_ANLAGEN} Anlagen, geschaetzt aus {BEOBACHTUNGEN_SCHAETZUNG} "
f"Beobachtungen.")
print(f"Rang der Stichprobenmatrix: "
f"{np.linalg.matrix_rank(stichprobe)} - noetig waeren {ANZAHL_ANLAGEN}.")
print(f"Kleinster Eigenwert: {np.linalg.eigvalsh(stichprobe).min():.2e}")
print()
print("Es gibt also Richtungen, in denen diese Matrix EXAKT null Varianz")
print("misst. Genau dort sucht ein Minimum-Varianz-Optimierer.")
print()
print(f"{'Schaetzer':<26} {'Risiko im Zeitraum':>19} "
f"{'Risiko spaeter':>16} {'Hebel':>7}")
print("-" * 78)
ergebnisse = {}
for name, matrix in [
("Stichprobe (roh)", stichprobe),
(f"Ledoit-Wolf (d={ledoit_wolf.shrinkage_:.2f})", ledoit_wolf.covariance_)]:
gewichte, varianz_intern = minimales_risiko(matrix)
risiko_spaeter = float(np.std(spaeterer_verlauf @ gewichte))
hebel = float(np.abs(gewichte).sum())
ergebnisse[name] = (risiko_spaeter, gewichte)
print(f"{name:<26} {np.sqrt(varianz_intern) * 100:>18.3f} % "
f"{risiko_spaeter * 100:>15.3f} % {hebel:>7.1f}")
gleich = np.full(ANZAHL_ANLAGEN, 1.0 / ANZAHL_ANLAGEN)
print(f"{'Gleichgewichtung 1/N':<26} "
f"{float(np.std(schaetzzeitraum @ gleich)) * 100:>18.3f} % "
f"{float(np.std(spaeterer_verlauf @ gleich)) * 100:>15.3f} % {1.0:>7.1f}")
print("-" * 78)
print("\nDrei Beobachtungen:")
print("1. Die rohe Schaetzung meldet 0,000 % Risiko - und liefert spaeter das")
print(" SCHLECHTESTE Ergebnis der drei. Sie hat kein Portfolio optimiert,")
print(" sondern eine Luecke in den eigenen Daten gefunden.")
print("2. Die Gleichgewichtung, die gar nicht optimiert, schlaegt den rohen")
print(" Optimierer deutlich. Das ist kein Zufallsbefund, sondern in der")
print(" Literatur breit belegt.")
print("3. Ledoit-Wolf ist im Schaetzzeitraum EHRLICHER (0,441 statt 0,000 %)")
print(" und spaeter am besten. Der Hebel faellt von 3,8 auf 1,8 - das")
print(" Portfolio wird auch praktisch handelbarer.")
print()
print("Merksatz: Ein Optimierer glaubt seinen Eingabedaten vollstaendig.")
print("Je mehr Freiheit Sie ihm geben, desto gruendlicher findet er deren")
print("Fehler. Das gilt fuer Kovarianzmatrizen wie fuer Lieferzeiten,")
print("Ausfallraten und jede andere geschaetzte Groesse.")
print("=" * 78)
```
**Erwartete Ausgabe:**
```
==============================================================================
DIE KOVARIANZ-FALLE
==============================================================================
30 Anlagen, geschaetzt aus 20 Beobachtungen.
Rang der Stichprobenmatrix: 19 - noetig waeren 30.
Kleinster Eigenwert: -6.47e-20
Es gibt also Richtungen, in denen diese Matrix EXAKT null Varianz
misst. Genau dort sucht ein Minimum-Varianz-Optimierer.
Schaetzer Risiko im Zeitraum Risiko spaeter Hebel
------------------------------------------------------------------------------
Stichprobe (roh) 0.000 % 1.287 % 3.8
Ledoit-Wolf (d=0.34) 0.441 % 0.974 % 1.8
Gleichgewichtung 1/N 0.808 % 1.026 % 1.0
------------------------------------------------------------------------------
Drei Beobachtungen:
1. Die rohe Schaetzung meldet 0,000 % Risiko - und liefert spaeter das
SCHLECHTESTE Ergebnis der drei. Sie hat kein Portfolio optimiert,
sondern eine Luecke in den eigenen Daten gefunden.
2. Die Gleichgewichtung, die gar nicht optimiert, schlaegt den rohen
Optimierer deutlich. Das ist kein Zufallsbefund, sondern in der
Literatur breit belegt.
3. Ledoit-Wolf ist im Schaetzzeitraum EHRLICHER (0,441 statt 0,000 %)
und spaeter am besten. Der Hebel faellt von 3,8 auf 1,8 - das
Portfolio wird auch praktisch handelbarer.
Merksatz: Ein Optimierer glaubt seinen Eingabedaten vollstaendig.
Je mehr Freiheit Sie ihm geben, desto gruendlicher findet er deren
Fehler. Das gilt fuer Kovarianzmatrizen wie fuer Lieferzeiten,
Ausfallraten und jede andere geschaetzte Groesse.
==============================================================================
```
> **🔤 Formel-Übersetzer: warum $N > T$ alles kaputt macht**
>
> | Mathematik | Alltagssprache |
> | --- | --- |
> | $\mathbf{S} = \frac{1}{T-1}\sum_{t}(\mathbf{r}_t - \bar{\mathbf{r}})(\mathbf{r}_t - \bar{\mathbf{r}})^\top$ | „Wie stark schwanken die Anlagen — und wie sehr gemeinsam?“ |
> | $\operatorname{rang}(\mathbf{S}) \le T - 1$ | „Aus 20 Beobachtungen lassen sich höchstens 19 unabhängige Richtungen ablesen.“ |
> | $N = 30 > 19$ | „Es bleiben 11 Richtungen übrig, über die die Daten **nichts** sagen.“ |
> | $\exists\,\mathbf{w}
e 0: \mathbf{w}^ op\mathbf{S}\mathbf{w} = 0$ | „In genau diesen Richtungen misst die Matrix null Risiko — nicht weil keines da ist, sondern weil sie blind ist.“ |
> | $\min_{\mathbf{w}} \mathbf{w}^ op\mathbf{S}\mathbf{w}$ | „Und der Optimierer sucht zielsicher genau dort.“ |
>
> **Die Faustregel:** Für eine halbwegs verlässliche Kovarianzschätzung braucht man
> $T \gtrsim 10 \cdot N$ Beobachtungen. Bei 30 Titeln also rund 300 Perioden — mehr als ein
> Jahr Tagesdaten. Wer weniger hat, **muss** schrumpfen.
---
## Micro-Quiz {#sec:finanzdaten-quiz}
> **❓ Micro-Quiz 11: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-finanzdaten).
>
> **1. Ein Kurs fällt um 50 % und steigt danach um 50 %. Wo steht er?**
> (a) Wieder beim Ausgangswert — die Prozente heben sich auf.
> (b) Bei 75 % des Ausgangswerts. Diskrete Renditen verknüpfen sich multiplikativ, nicht
> additiv: $1{,}0 \cdot 0{,}5 \cdot 1{,}5 = 0{,}75$.
> (c) Bei 25 % des Ausgangswerts.
>
> **2. Wofür nimmt man logarithmische Renditen, wofür diskrete?**
> (a) Logarithmische sind grundsätzlich genauer und daher immer vorzuziehen.
> (b) Logarithmische addieren sich über die **Zeit**, diskrete über die **Anlagen** eines
> Portfolios. Man wählt nach der Richtung, in der summiert wird.
> (c) Diskrete für kurze, logarithmische für lange Zeiträume.
>
> **3. Sie schätzen eine Kovarianzmatrix für 50 Anlagen aus 40 Wochenrenditen. Was ist zu
> tun?**
> (a) Nichts — 40 Beobachtungen sind eine ordentliche Stichprobe.
> (b) Die Matrix ist singulär und gaukelt dem Optimierer risikofreie Richtungen vor.
> Entweder mehr Daten beschaffen oder einen Shrinkage-Schätzer verwenden, der positive
> Definitheit garantiert.
> (c) Die Anzahl der Anlagen auf 40 reduzieren, dann passt es.
---
## Selbsttest {#sec:finanzdaten-selbsttest}
> Antworten: [Anhang A](90_Anhang_Loesungen.md#selbsttest-loesung-finanzdaten).
1. Warum addieren sich Log-Renditen über die Zeit, diskrete aber über das Portfolio?
2. Was besagt der Error-Maximizer-Effekt?
3. Warum ist $\mathbf{S}$ bei $T < N$ singulär, und was folgt daraus praktisch?
4. Was mischt Ledoit-Wolf, und wie wird $\delta$ bestimmt?
5. Welche zwei Shrinkage-Ziele gibt es, und welches verwendet `scikit-learn`?
---
## Zusammenfassung {#sec:finanzdaten-zusammenfassung}
* **Diskret über das Portfolio, logarithmisch über die Zeit** — die Wahl der Renditeart ist
keine Geschmacksfrage.
* **Das arithmetische Mittel diskreter Renditen überschätzt** die tatsächliche
Wertentwicklung systematisch.
* **Die Stichprobenkovarianz ist bei $T \approx N$ unbrauchbar** und bei $T < N$ singulär.
* **Markowitz verstärkt Schätzfehler**, statt sie auszugleichen — der Error-Maximizer-Effekt.
* **Shrinkage** mischt Rauschen mit Struktur und liefert eine stabil invertierbare Matrix.
Es gibt mehrere Ziele; `sklearn` verwendet die skalierte Einheitsmatrix.
* **Erzwingen Sie die Spaltenreihenfolge** nach jedem Datenimport und prüfen Sie sie per
`assert`.
* **Faustregel für die Datenmenge:** $T \gtrsim 10 \cdot N$. Bei 30 Titeln also rund 300
Perioden. Wer weniger hat, muss schrumpfen — oder die Freiheit des Optimierers begrenzen.
* **Bewerten Sie außerhalb des Schätzzeitraums.** Ein Portfolio, das im eigenen Datenfenster
0,000 % Risiko meldet, ist kein gutes Portfolio, sondern ein Beleg dafür, dass die Daten
nicht ausreichen.
* **Das gilt weit über Finanzdaten hinaus.** Bearbeitungszeiten, Ausfallraten,
Nachfrageprognosen: Ein Optimierer sucht nicht die beste Lösung, sondern den größten
Fehler in Ihren Schätzungen.
**Ausblick.** Mit stabilen Schätzungen können wir in {ref:kap:markowitz} endlich optimieren: die
Markowitz-Effizienzgrenze mit realistischen institutionellen Restriktionen.
---
*Weiter mit:* [{ref:kap:markowitz} — Markowitz-Portfoliotheorie](41_Markowitz.md)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,849 @@
# Kapitel: Tail-Risiko, CVaR und Transaktionskosten {#kap:cvar}
> **📌 Kapitel auf einen Blick**
>
> **Worum geht es?** Um zwei Schwächen des Markowitz-Modells: Es unterschätzt Extremverluste
> und ignoriert die Kosten des Umschichtens. Beide lassen sich mit konvexen Mitteln beheben.
>
> **Voraussetzungen:** {ref:kap:markowitz}, {ref:kap:lp} (LP-Formulierungen).
>
> **Danach können Sie:** VaR und CVaR unterscheiden, den CVaR nach Rockafellar/Uryasev als
> lineares Programm formulieren, Turnover über die $L_1$-Norm bestrafen — und begründen,
> warum VaR-Kennzahlen nicht über Einheiten addiert werden dürfen.
>
> **Zeitbedarf:** ca. 5 Stunden.
>
> **Programme:**\
> `VaR_CVaR_Demo.py`\
> `CVaR_Portfolio.py`
---
## In 5 Minuten gelöst {#sec:cvar-schnellstart}
> **🚀 In 5 Minuten gelöst: Zwei Anlagen, dasselbe Risiko?**
>
> Zwei Anlagen wurden über 100 Tage beobachtet. An 94 Tagen legten **beide** um 0,5 % zu.
> Sie unterscheiden sich nur in den sechs schlechten Tagen:
>
> * **Anlage A:** sechsmal 3 %.
> * **Anlage B:** fünfmal 3 % — und einmal **40 %**.
>
> ```python
> import numpy as np
>
> gut = np.full(94, 0.5)
> A = np.concatenate([gut, np.full(6, -3.0)])
> B = np.concatenate([gut, np.full(5, -3.0), [-40.0]])
>
> for name, r in [("Anlage A", A), ("Anlage B", B)]:
> schwelle = np.percentile(r, 5) # 5-%-Quantil
> var = -schwelle # Value at Risk
> cvar = -r[r <= schwelle].mean() # Mittel der schlimmsten 5 %
> print(f"{name}: VaR(95) {var:5.2f} % CVaR(95) {cvar:5.2f} %")
> ```
>
> **Ausgabe:**
>
> ```
> Anlage A: VaR(95) 3.00 % CVaR(95) 3.00 %
> Anlage B: VaR(95) 3.00 % CVaR(95) 9.17 %
> ```
**Der Value at Risk ist für beide Anlagen exakt gleich: 3,00 %.** Ein Risikobericht, der
nur den VaR ausweist, würde die beiden als gleich riskant einstufen.
Dabei kann Anlage B an einem einzigen Tag **40 %** verlieren. Das ist kein Randfall, den man
übersehen darf — es ist der Fall, wegen dessen es Risikomanagement gibt.
Der Grund für diese Blindheit steckt in der Definition:
| Maß | Was es beantwortet | Was es dabei übersieht |
| --- | --- | --- |
| **VaR (95 %)** | „Welchen Verlust überschreite ich an höchstens 5 % der Tage?“ | **Alles**, was jenseits dieser Schwelle passiert |
| **CVaR (95 %)** | „Wie hoch ist der Verlust *im Mittel*, **wenn** es schiefgeht?“ | nichts im Schwanz |
Der VaR ist ein **Quantil** — er markiert eine Grenze und schaut nicht dahinter. Ob hinter
der Grenze 3 % oder 40 % liegen, ändert ihn nicht. Der CVaR mittelt genau über diesen
Bereich und macht den Unterschied sichtbar: 3,00 % gegen 9,17 %.
> **🎯 Merksatz**
> Der VaR sagt Ihnen, **wie oft** es schiefgeht. Der CVaR sagt Ihnen, **wie schlimm** es dann
> ist. Für die Frage, ob ein Unternehmen einen Verlust überlebt, zählt ausschließlich die
> zweite.
**Und es kommt noch besser.** Der CVaR ist nicht nur aussagekräftiger, er ist auch
mathematisch handlicher: Er lässt sich als **lineares Programm** minimieren, während die
Minimierung des VaR ein nicht-konvexes Problem mit vielen lokalen Optima ist
({ref:kap:qp-nlp}). Das ist ein seltener Glücksfall — das bessere Maß ist hier zugleich das
leichter optimierbare. Wie das geht, zeigt {ref:sec:cvar-value-at-risk-und-conditional-value}.
---
## Lernziele {#sec:cvar-lernziele}
Nach diesem Kapitel können Sie …
1. … erklären, warum Marktrenditen keine Normalverteilung haben und was daraus folgt.
2. … VaR und CVaR definieren und begründen, warum nur der CVaR kohärent ist.
3. … das Rockafellar-Uryasev-Theorem anwenden, um den CVaR linear zu formulieren.
4. … Transaktionskosten über die $L_1$-Norm modellieren.
5. … Einheiten konsistent halten und Nebenbedingungen vektorisieren.
6. … erklären, warum zwei Anlagen mit identischem VaR völlig verschiedene Extremverluste
haben können.
7. … die Folgen fehlender Subadditivität für die Verteilung von Risikobudgets benennen.
---
## Die zwei Schwächen des Markowitz-Modells {#sec:cvar-die-zwei-schwaechen-des-markowitz-modells}
**Erstens: die Normalverteilungs-Illusion.** Die Varianz behandelt Aufwärts- und
Abwärtsschwankungen gleich und unterstellt implizit symmetrische, dünn auslaufende
Verteilungen. Reale Marktrenditen haben aber **fette Ränder** (*fat tails*){idx:Fat Tails} und **negative
Schiefe**: Extreme Verluste treten deutlich häufiger auf, als eine Normalverteilung
vorhersagt.
**Zweitens: Reibungsblindheit.** Ein ungedämpftes Mean-Variance-Modell schichtet bei
minimalen Schätzänderungen das gesamte Portfolio um. Ohne Berücksichtigung von Gebühren,
Spreads und Steuern frisst der Umschlag (*turnover*) den theoretischen Mehrertrag auf.
```python
#!/usr/bin/env python3
# VaR_CVaR_Demo.py
"""
Kapitel CVaR: Fat Tails, VaR und CVaR anschaulich.
Teil 1: Wie oft treten "unmoegliche" Tage wirklich auf?
Teil 2: Warum ist der VaR nicht subadditiv - ein Gegenbeispiel zum Nachrechnen.
"""
import numpy as np
from scipy import stats
def var_quantil(verluste: np.ndarray, alpha: float = 0.95) -> float:
"""VaR = Quantil der Verlustverteilung (Verluste positiv, Gewinne negativ)."""
return float(np.quantile(verluste, alpha))
def cvar_rockafellar(verluste: np.ndarray, alpha: float = 0.95) -> float:
"""
CVaR ueber die Rockafellar-Uryasev-Formel:
CVaR = min_gamma { gamma + 1/(1-alpha) * E[max(Verlust - gamma, 0)] }
WICHTIG: Der naheliegende Weg "Mittelwert aller Werte >= VaR" ist FALSCH,
sobald die Verteilung Atome hat (z. B. genau zwei moegliche Verluste).
Dann liegt der VaR selbst auf einem Atom, und der Vergleich '>=' erfasst
zu viel Wahrscheinlichkeitsmasse. Die Formel unten behandelt das korrekt -
und ist zugleich genau der Ausdruck, den wir im Abschnitt 'Value at Risk
und Conditional Value at Risk' optimieren.
"""
kandidaten = np.unique(verluste) # Optimum liegt immer auf einem Datenpunkt
return float(min(g + np.mean(np.maximum(verluste - g, 0.0)) / (1.0 - alpha)
for g in kandidaten))
if __name__ == "__main__":
rng = np.random.default_rng(2026)
# --- Teil 1: Fat Tails (analytisch, nicht simuliert) ------------------
print("=" * 88)
print(" TEIL 1: WIE OFT TRITT DAS 'UNMOEGLICHE' EIN?")
print("=" * 88)
print("Vergleich: Normalverteilung gegen t-Verteilung mit 3 Freiheitsgraden")
print("(beide auf Standardabweichung 1 normiert).\n")
t_verteilung = stats.t(df=3)
skalierung = t_verteilung.std() # auf Varianz 1 bringen
print(f"{'Ereignis':<22} {'Normal':>14} {'t (df=3)':>14} {'Faktor':>11} "
f"{'Normal: 1 Tag in':>18}")
print("-" * 88)
for k in [3, 4, 5, 6]:
p_normal = 2 * stats.norm.sf(k) # beidseitig
p_t = 2 * t_verteilung.sf(k * skalierung)
print(f"Abweichung > {k} Sigma {p_normal*100:>13.6f} % {p_t*100:>13.6f} % "
f"{p_t/p_normal:>10.1f}x {1/p_normal/252:>15,.0f} Jahre")
print("\nDeutung: Ein 5-Sigma-Tag ist unter Normalverteilung ein Ereignis von")
print("etwa einmal in 6.900 Jahren. Reale Aktienmaerkte liefern solche Tage")
print("mehrfach pro Jahrzehnt. Wer allein mit Varianz steuert, plant fuer")
print("eine Welt, in der Crashs praktisch nicht vorkommen.")
# --- Teil 2: VaR ist nicht subadditiv ---------------------------------
print("\n" + "=" * 88)
print(" TEIL 2: WARUM DER VaR KEIN KOHAERENTES RISIKOMASS IST")
print("=" * 88)
print("Zwei unabhaengige Anleihen, je 100 EUR Nominal.")
print("Jede faellt mit 4 % Wahrscheinlichkeit aus (Verlust 100),")
print("sonst zahlt sie 2 EUR Kupon (Verlust -2).\n")
ziehungen = 2_000_000
verlust_a = np.where(rng.random(ziehungen) < 0.04, 100.0, -2.0)
verlust_b = np.where(rng.random(ziehungen) < 0.04, 100.0, -2.0)
verlust_ab = verlust_a + verlust_b
print(f"{'':<28} {'VaR 95%':>12} {'CVaR 95%':>12}")
print("-" * 88)
werte = {}
for name, v in [("Anleihe A allein", verlust_a),
("Anleihe B allein", verlust_b),
("Portfolio A+B", verlust_ab)]:
werte[name] = (var_quantil(v), cvar_rockafellar(v))
print(f"{name:<28} {werte[name][0]:>12.2f} {werte[name][1]:>12.2f}")
var_summe = werte["Anleihe A allein"][0] + werte["Anleihe B allein"][0]
cvar_summe = werte["Anleihe A allein"][1] + werte["Anleihe B allein"][1]
print(f"{'Summe der Einzelwerte':<28} {var_summe:>12.2f} {cvar_summe:>12.2f}")
var_port, cvar_port = werte["Portfolio A+B"]
print("-" * 88)
print(f"VaR: Portfolio {var_port:7.2f} vs. Summe {var_summe:7.2f} -> "
f"{'VERLETZT die Subadditivitaet!' if var_port > var_summe else 'subadditiv'}")
print(f"CVaR: Portfolio {cvar_port:7.2f} vs. Summe {cvar_summe:7.2f} -> "
f"{'subadditiv (kohaerent)' if cvar_port <= cvar_summe + 1e-6 else 'verletzt'}")
print("\nDeutung: Einzeln betrachtet meldet der VaR fuer jede Anleihe einen")
print("GEWINN von 2 EUR - denn mit 96 % Wahrscheinlichkeit passiert nichts,")
print("und 4 % liegen unterhalb der 5-%-Schwelle. Im Portfolio steigt die")
print("Wahrscheinlichkeit mindestens eines Ausfalls auf 7,8 % und damit UEBER")
print("die Schwelle - der VaR springt auf 98. Er behauptet also, Streuung")
print("habe das Risiko erhoeht. Das ist oekonomisch unsinnig und der Grund,")
print("warum die Bankenaufsicht mit Basel III auf den Expected Shortfall")
print("umgestellt hat.")
print("=" * 88)
```
**Erwartete Ausgabe (gekürzt):**
```
========================================================================================
TEIL 1: WIE OFT TRITT DAS 'UNMOEGLICHE' EIN?
========================================================================================
Ereignis Normal t (df=3) Faktor Normal: 1 Tag in
----------------------------------------------------------------------------------------
Abweichung > 3 Sigma 0.269980 % 1.384683 % 5.1x 1 Jahre
Abweichung > 4 Sigma 0.006334 % 0.616537 % 97.3x 63 Jahre
Abweichung > 5 Sigma 0.000057 % 0.323904 % 5649.8x 6,922 Jahre
Abweichung > 6 Sigma 0.000000 % 0.190127 % 963561.0x 2,011,101 Jahre
========================================================================================
TEIL 2: WARUM DER VaR KEIN KOHAERENTES RISIKOMASS IST
========================================================================================
VaR 95% CVaR 95%
----------------------------------------------------------------------------------------
Anleihe A allein -2.00 79.59
Anleihe B allein -2.00 79.66
Portfolio A+B 98.00 101.33
Summe der Einzelwerte -4.00 159.24
----------------------------------------------------------------------------------------
VaR: Portfolio 98.00 vs. Summe -4.00 -> VERLETZT die Subadditivitaet!
CVaR: Portfolio 101.33 vs. Summe 159.24 -> subadditiv (kohaerent)
========================================================================================
```
**Die 6-Sigma-Zeile setzt die Sache ins Verhältnis:** Unter Normalverteilung wäre ein
solcher Tag ein Ereignis von einmal in **zwei Millionen Jahren**. Unter der t-Verteilung mit
drei Freiheitsgraden — die realen Aktienrenditen deutlich näher kommt — passiert er etwa
alle **zwei Jahre**. Der Faktor beträgt fast **eine Million**.
Und Teil 2 zeigt das Grundproblem des VaR an einem Beispiel, das Sie von Hand nachrechnen
können: Einzeln meldet er für jede Anleihe einen *Gewinn* von 2 €, im Portfolio einen
*Verlust* von 98 €. Diversifikation hätte demnach das Risiko um 102 € erhöht. Der CVaR
dagegen verhält sich korrekt: 101,33 € im Portfolio gegenüber 159,24 € bei getrennter
Betrachtung — die Streuung **senkt** das Risiko, wie es sein muss.
> **⚠️ Eine Falle bei der CVaR-Berechnung**
> Der naheliegende Weg — „Mittelwert aller Verluste $\ge$ VaR“ — ist **falsch**, sobald die
> Verteilung **Atome** hat (also einzelne Werte mit positiver Wahrscheinlichkeit, wie hier
> die zwei möglichen Ausgänge). Der VaR liegt dann selbst auf einem Atom, und der Vergleich
> `>=` erfasst zu viel Wahrscheinlichkeitsmasse. Im Beispiel oben liefert dieser naive
> Schätzer für Anleihe A den Wert 2,08 statt der korrekten 79,59 — ein Fehler um Faktor 38.
>
> Die Rockafellar-Uryasev-Formel behandelt Atome von sich aus korrekt. Verwenden Sie sie
> auch dann, wenn Sie „nur schnell“ einen CVaR ausrechnen wollen.
---
## Value at Risk und Conditional Value at Risk {#sec:cvar-value-at-risk-und-conditional-value}
![Der VaR ist eine Schwelle, der CVaR ein Mittelwert. 20 000 simulierte Tagesrenditen aus
dem Fat-Tail-Modell dieses Kapitels ($t$-Verteilung mit drei Freiheitsgraden). Der schraffierte
Bereich sind die schlechtesten 5 % der Tage; der VaR markiert nur ihren Rand, der CVaR ihren
Mittelwert. Erzeugt von `bilder_04/erzeuge_var_cvar.py`.](bilder_04/kap_cvar_var_vergleich.svg)
**Value at Risk ($\text{VaR}_\alpha$):**{idx:Value at Risk (VaR)} Der Verlust, der mit Wahrscheinlichkeit $\alpha$
nicht überschritten wird.
**Conditional Value at Risk ($\text{CVaR}_\alpha$, auch *Expected Shortfall*{idx:Expected Shortfall}):**{idx:Conditional Value at Risk (CVaR)} Der
**durchschnittliche Verlust in den schlimmsten $(1-\alpha)$ Prozent** der Fälle.
> **📐 Der Unterschied in einem Satz**
> Der VaR sagt: *„In 95 % der Tage verlieren Sie höchstens 1,86 %.“*
> Der CVaR sagt: *„Und wenn es doch schiefgeht, verlieren Sie im Mittel 2,99 %.“*
>
> Beide Zahlen stammen aus der Verteilung oben — nachzurechnen mit
> `bilder_04/erzeuge_var_cvar.py`.
>
> Der VaR ist eine **Schwelle**, der CVaR ein **Mittelwert dahinter**. Der VaR sagt nichts
> darüber, wie schlimm es hinter der Schwelle wird — ob dort 2,4 % oder 40 % stehen, ist ihm
> gleich. In der Stichprobe oben liegt der schlechteste Tag bei **23,0 %**; am VaR von
> 1,86 % ändert dieser eine Tag **nichts**, am CVaR sehr wohl.
| Eigenschaft | VaR | CVaR |
| --- | --- | --- |
| Berücksichtigt Verlusthöhe im Rand | ✗ nein | ✓ ja |
| **Subadditiv**{idx:Subadditivität} ($\rho(A+B) \le \rho(A)+\rho(B)$) | ✗ nein | ✓ ja |
| Kohärentes Risikomaß | ✗ nein | ✓ ja |
| Konvex und optimierbar | ✗ nein | ✓ ja |
| Regulatorischer Standard | bis Basel II | **ab Basel III** |
> **⚠️ Eine verbreitete Ungenauigkeit**
> Der CVaR wird manchmal „**streng** konvex“ genannt. Das ist zu stark: Der CVaR ist konvex, in
> der Szenario-Darstellung sogar **stückweise linear** — und damit gerade **nicht** streng
> konvex. Genau das ist sein praktischer Vorteil: Stückweise linear heißt, er lässt sich als
> **lineares Programm** lösen.
### Das Rockafellar-Uryasev-Theorem (2000)
Rockafellar und Uryasev{idx:Rockafellar-Uryasev-Theorem} zeigten, dass sich der CVaR über $S$ Szenarien exakt als lineares
Programm formulieren lässt:
$$
\text{CVaR}_\alpha(\mathbf{w}) = \min_{\gamma,\ \mathbf{u}\ \ge 0}\ \
\gamma + \frac{1}{S(1-\alpha)}\sum_{s=1}^S u_s
$$
$$
\text{u. d. N.}\qquad u_s \ \ge\ -\mathbf{R}_s^\top\mathbf{w} - \gamma \quad\forall s,
\qquad u_s \ge 0 \quad\forall s
$$
> **📐 Formel-Lesehilfe — der Trick in drei Schritten**
> * $\gamma$ ist eine **Hilfsvariable**, die im Optimum automatisch den VaR annimmt. Man
> muss ihn also **nicht vorher kennen** — das ist der eigentliche Durchbruch.
> * $-\mathbf{R}_s^\top\mathbf{w}$ ist der **Verlust** im Szenario $s$ (Rendite mit
> negativem Vorzeichen).
> * $u_s \ge \text{Verlust}_s - \gamma$ zusammen mit $u_s \ge 0$ bedeutet:
> $u_s = \max(\text{Verlust}_s - \gamma,\ 0)$ — der **Überschuss über die Schwelle**.
> Liegt der Verlust unter $\gamma$, ist $u_s = 0$ und das Szenario zählt nicht.
>
> **Ohne Formel gesagt:** „Wähle eine Schwelle $\gamma$. Zähle für jedes Szenario, wie weit
> der Verlust darüber hinausgeht. Der CVaR ist die Schwelle plus der gemittelte Überschuss
> — und zwar für diejenige Schwelle, bei der diese Summe minimal wird.“
>
> **Warum $u_s$ automatisch das Maximum wird:** Die Zielfunktion minimiert die Summe der
> $u_s$. Jedes $u_s$ wird also so klein wie möglich gedrückt — bis an die Grenze, die die
> beiden Ungleichungen erlauben. Das ist genau das Maximum der beiden Untergrenzen.
> **🔤 Formel-Übersetzer: jedes Zeichen der Zielfunktion**
>
> Die Lesehilfe oben erklärt den *Trick*. Hier steht, was die einzelnen Zeichen **bedeuten**
> vor allem der Bruch, an dem die meisten Leser hängen bleiben:
>
> | Mathematik | Alltagssprache |
> | --- | --- |
> | $\alpha$ | Das **Konfidenzniveau**, üblich $0{,}95$ oder $0{,}99$. *Nicht* der Randanteil. |
> | $1-\alpha$ | Der **Randanteil**: die schlechtesten 5 % (bzw. 1 %) der Fälle. Das ist der Teil, um den es geht. |
> | $S$ | Die Anzahl der durchgerechneten Szenarien — Handelstage, Simulationsläufe, historische Perioden. |
> | $S(1-\alpha)$ | **Wie viele Szenarien im Rand liegen.** Bei $S = 2\,000$ und $\alpha = 0{,}95$: genau 100. |
> | $\frac{1}{S(1-\alpha)}\sum_s u_s$ | Kein Mittelwert über *alle* Szenarien, sondern über die **Randszenarien allein**. Genau deshalb steht dort nicht $\frac1S$. |
> | $\gamma$ | Die Schwelle, ab der ein Verlust zum Randfall wird — im Optimum der VaR. |
> | $\gamma + \frac{1}{S(1-\alpha)}\sum_s u_s$ | „Schwelle **plus** durchschnittlicher Überschuss darüber“ — und das ist der CVaR. |
>
> **In einem Satz:** *Der CVaR ist der Mittelwert der schlimmsten $1-\alpha$ Prozent — der
> Bruch vor der Summe sorgt allein dafür, dass durch die richtige Anzahl geteilt wird.*
>
> > **⚠️ Die teuerste Verwechslung des Kapitels**
> > $\alpha$ und $1-\alpha$ zu vertauschen führt zu **keiner Fehlermeldung**. Das Modell
> > rechnet weiter, nur eben über die falsche Menge. An 2 000 simulierten Tagesrenditen mit
> > Fat Tails gemessen:
> >
> > | Rechnung | gemittelt über | CVaR |
> > | --- | --- | --- |
> > | richtig ($\alpha = 0{,}95$, Rand 5 %) | 100 von 2 000 Szenarien | **3,43 %** |
> > | vertauscht (Rand 95 %) | 1 900 von 2 000 Szenarien | **0,18 %** |
> >
> > Ein Faktor **19** — und die zweite Zahl ist kein schlecht geschätztes Randrisiko, sondern
> > überhaupt kein Randmaß mehr: Sie mittelt über fast alle Tage und schließt die schlimmen
> > gerade nicht ein. Der größte Einzelverlust der Stichprobe beträgt 21,4 %.
> >
> > **Die Gegenprobe kostet eine Zeile:** Ist der berechnete CVaR nicht deutlich größer als
> > der mittlere Verlust, stimmt $\alpha$ nicht.
---
## Transaktionskosten über die $L_1$-Norm {#sec:cvar-transaktionskosten-ueber-die-l-1-norm}
Sei $\mathbf{w}_{\text{alt}}$ das bestehende Portfolio und $\mathbf{w}$ das neue Ziel. Der
**Umschlag** (*turnover*){idx:Turnover (Umschlag)} ist:
$$
\text{Turnover} = \sum_{i=1}^N \lvert w_i - w_{\text{alt},i}\rvert
= \lVert \mathbf{w}-\mathbf{w}_{\text{alt}}\rVert_1
$$
![CVaR-Zielfunktion mit Reibungs-Penalty](bilder_04/kap13_cvar_zielfunktion.svg)
> **📐 Formel-Lesehilfe**
> Die $L_1$-Norm ist die Summe der **Beträge**. Sie misst, wie viel Prozent des Portfolios
> insgesamt bewegt werden — Käufe und Verkäufe zusammen.
>
> **Ohne Formel gesagt:** „Wenn du 5 % von A verkaufst und 5 % von B kaufst, hast du 10 %
> Umschlag und zahlst darauf Gebühren.“
>
> **Warum $L_1$ und nicht $L_2$?** Die $L_1$-Norm ist konvex (also optimierbar) und
> erzeugt zusätzlich **dünn besetzte Änderungen**: Sie bevorzugt wenige große Umschichtungen
> gegenüber vielen kleinen. Das entspricht genau dem, was man in der Praxis will — nicht 50
> Kleinstorders mit je 3 € Mindestgebühr.
In CVXPY schreibt man einfach `cp.norm1(w - w_alt)`; intern wird das in lineare
Hilfsvariablen zerlegt.
---
## Implementierung: CVaR-Portfolio mit Reibung {#sec:cvar-implementierung-cvar-portfolio-mit-reibung}
> **⚠️ Einheiten konsistent halten**
>
> Ein häufiger Fehler verrechnet eine **annualisierte** Rendite gegen einen **täglichen** CVaR:
> ```python
> mu = returns_df.mean().values * 252 # ANNUALISIERT
> cvar = gamma + (1/(S*(1-alpha))) * cp.sum(u) # TAEGLICH
> objective = cp.Maximize(mu @ w - lambda_risk * cvar - trans_costs)
> ```
> Der Risikoterm ist dadurch faktisch um Faktor 252 zu schwach gewichtet:
> `lambda_risk = 1.5` bedeutet dann effektiv eine Risikoaversion von $1{,}5/252 \approx 0{,}006$.
> Das Modell ist nicht falsch im Sinne von unlösbar — aber der Parameter bedeutet nicht,
> was er zu bedeuten scheint, und lässt sich daher nicht sinnvoll einstellen.
>
> **Dieses Programm rechnet deshalb durchgehend auf Tagesbasis** und annualisiert erst in
> der Ausgabe.
```python
#!/usr/bin/env python3
# CVaR_Portfolio.py
"""
Kapitel CVaR: CVaR-Optimierung mit L1-Transaktionskosten via CVXPY.
Eigenschaften:
* Spaltenreihenfolge erzwungen
* Einheiten konsistent (alles taeglich, Annualisierung nur in der Ausgabe)
* Szenario-Nebenbedingungen VEKTORISIERT statt in einer Python-Schleife
(eine Matrixbedingung statt S einzelner Constraints - deutlich schneller)
* Vergleich CVaR- gegen Varianz-Optimierung
* Nachrechnung von VaR/CVaR aus den realisierten Szenarien
"""
import time
import cvxpy as cp
import numpy as np
import pandas as pd
import yfinance as yf
TICKER = ["AAPL", "MSFT", "NVDA", "AMZN", "JNJ", "PFE", "JPM", "GS", "XOM", "CVX"]
ALPHA = 0.95 # Konfidenzniveau: schlechteste 5 % der Tage
MAX_GEWICHT = 0.25
GEBUEHRENSATZ = 0.002 # 0,2 % je Einheit Turnover (Spread + Brokerage)
RISIKOAVERSION = 1.5 # bezogen auf TAEGLICHE Groessen
HANDELSTAGE = 252
def lade_renditen():
ende = pd.Timestamp.today().normalize()
start = ende - pd.DateOffset(years=2)
roh = yf.download(TICKER, start=start, end=ende, auto_adjust=True, progress=False)
if roh.empty:
raise SystemExit("Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).")
if isinstance(roh.columns, pd.MultiIndex):
kurse = roh["Close"][TICKER].dropna() # erzwingt eigene Spaltenreihenfolge
else:
kurse = roh[["Close"]].dropna()
kurse.columns = TICKER
assert list(kurse.columns) == TICKER, "Spaltenreihenfolge weicht ab!"
return kurse.pct_change().dropna()
def optimiere_cvar(R, w_alt, vektorisiert=True):
"""
Maximiere: taegliche Rendite - lambda * CVaR - Transaktionskosten
Alle Groessen TAEGLICH.
"""
S, N = R.shape
mu_taeglich = R.mean(axis=0)
w = cp.Variable(N, nonneg=True)
gamma = cp.Variable() # wird im Optimum zum VaR
u = cp.Variable(S, nonneg=True) # Ueberschuss ueber die Schwelle
cvar = gamma + (1.0 / (S * (1.0 - ALPHA))) * cp.sum(u)
turnover = cp.norm1(w - w_alt)
kosten = GEBUEHRENSATZ * turnover
ziel = cp.Maximize(mu_taeglich @ w - RISIKOAVERSION * cvar - kosten)
bedingungen = [cp.sum(w) == 1, w <= MAX_GEWICHT]
if vektorisiert:
# EINE Matrixbedingung statt S einzelner - deutlich schneller
bedingungen.append(u >= -(R @ w) - gamma)
else:
for s in range(S): # Alternative: S einzelne Constraints (langsamer)
bedingungen.append(u[s] >= -R[s] @ w - gamma)
problem = cp.Problem(ziel, bedingungen)
problem.solve()
if problem.status not in ("optimal", "optimal_inaccurate"):
raise SystemExit(f"CVaR-Problem nicht loesbar: {problem.status}")
return w.value, float(gamma.value), float(cvar.value), float(turnover.value)
def optimiere_varianz(R, w_alt):
"""Klassisches Mean-Variance zum Vergleich - ebenfalls taeglich gerechnet.
ACHTUNG, DCP-Falle: Die Standardabweichung ist hier NICHT als
cp.sqrt(cp.quad_form(w, sigma)) formulierbar. cp.sqrt ist konkav und
verlangt ein konkaves Argument; quad_form ist konvex - CVXPY lehnt den
Ausdruck mit einem DCPError ab, und zwar voellig zu Recht (Anhang
Fehlerdiagnose). cp.psd_wrap() hilft dagegen nicht: Es behebt eine
NUMERISCHE Beanstandung an sigma, keine Regelverletzung im Aufbau.
Der Standardweg ist die Cholesky-Zerlegung sigma = L L^T. Damit gilt
w' sigma w = ||L^T w||^2, also ist die Standardabweichung die 2-Norm
eines AFFINEN Ausdrucks - konvex und damit regelkonform.
"""
N = R.shape[1]
mu_taeglich = R.mean(axis=0)
sigma = np.cov(R, rowvar=False, ddof=1)
# Der winzige Diagonalzuschlag faengt den Fall ab, dass sigma numerisch
# nur halbdefinit ist (mehr Titel als Handelstage, doppelte Spalten).
L = np.linalg.cholesky(sigma + 1e-12 * np.eye(N))
w = cp.Variable(N, nonneg=True)
ziel = cp.Maximize(mu_taeglich @ w
- RISIKOAVERSION * cp.norm2(L.T @ w)
- GEBUEHRENSATZ * cp.norm1(w - w_alt))
problem = cp.Problem(ziel, [cp.sum(w) == 1, w <= MAX_GEWICHT])
problem.solve()
if problem.status not in ("optimal", "optimal_inaccurate"):
raise SystemExit(f"Varianz-Problem nicht loesbar: {problem.status}")
return w.value
def realisierte_kennzahlen(w, R):
"""VaR und CVaR direkt aus den Szenarien - unabhaengige Gegenprobe."""
portfoliorenditen = R @ w
verluste = -portfoliorenditen
var = float(np.quantile(verluste, ALPHA))
cvar = float(verluste[verluste >= var].mean())
return var, cvar, float(portfoliorenditen.mean()), float(portfoliorenditen.std(ddof=1))
if __name__ == "__main__":
renditen = lade_renditen()
R = renditen.values
S, N = R.shape
w_alt = np.ones(N) / N # Ausgangslage: Gleichgewichtung
t0 = time.perf_counter()
w_cvar, var_modell, cvar_modell, turnover = optimiere_cvar(R, w_alt, vektorisiert=True)
dauer_vektor = time.perf_counter() - t0
w_var = optimiere_varianz(R, w_alt)
print("=" * 90)
print(" CVaR-PORTFOLIO-OPTIMIERUNG MIT TRANSAKTIONSKOSTEN")
print("=" * 90)
print(f"Datenbasis: {S} Handelstage, {N} Titel | Konfidenzniveau "
f"{ALPHA*100:.0f} % | Loesungszeit {dauer_vektor:.2f} s\n")
# --- Gegenprobe: Modellwerte gegen realisierte Szenariowerte ---------
var_real, cvar_real, mu_real, sd_real = realisierte_kennzahlen(w_cvar, R)
print("--- Gegenprobe: stimmen Modell und Szenarien ueberein? ---")
print(f" VaR aus dem Modell (gamma): {var_modell*100:7.4f} % | "
f"aus den Szenarien: {var_real*100:7.4f} %")
print(f" CVaR aus dem Modell: {cvar_modell*100:7.4f} % | "
f"aus den Szenarien: {cvar_real*100:7.4f} %")
assert abs(cvar_modell - cvar_real) < 1e-4, "CVaR stimmt nicht mit den Szenarien!"
print(" -> Der Rockafellar-Uryasev-Trick liefert exakt den empirischen CVaR.")
# --- Kennzahlen beider Portfolios ------------------------------------
print(f"\n{'Portfolio':<24} {'Rendite p.a.':>13} {'Vola p.a.':>11} "
f"{'VaR 95% (Tag)':>15} {'CVaR 95% (Tag)':>16} {'Turnover':>10}")
print("-" * 90)
for name, w in [("CVaR-optimiert", w_cvar), ("Varianz-optimiert", w_var),
("Gleichgewichtung", w_alt)]:
v, c, m, s = realisierte_kennzahlen(w, R)
to = float(np.abs(w - w_alt).sum())
print(f"{name:<24} {m*HANDELSTAGE*100:>12.2f} % "
f"{s*np.sqrt(HANDELSTAGE)*100:>10.2f} % {v*100:>14.3f} % "
f"{c*100:>15.3f} % {to*100:>9.1f} %")
print("\nHinweis zur Annualisierung: Renditen werden mit 252 skaliert,")
print("Volatilitaeten mit sqrt(252). Fuer VaR/CVaR ist eine solche Skalierung")
print("nur unter starken Annahmen (Unabhaengigkeit, kein Drift) zulaessig -")
print("sie werden hier deshalb bewusst als TAGESwerte ausgewiesen.")
# --- Allokationstabelle ------------------------------------------------
print("\n--- Allokation ---")
tabelle = pd.DataFrame({
"Ticker": TICKER,
"vorher": [f"{v*100:5.1f} %" for v in w_alt],
"CVaR-opt.": [f"{v*100:5.1f} %" for v in w_cvar],
"Handel": [f"{(w_cvar[i]-w_alt[i])*100:+6.1f} %" for i in range(N)],
"Varianz-opt.": [f"{v*100:5.1f} %" for v in w_var],
})
print(tabelle.to_string(index=False))
print(f"\nTurnover {turnover*100:.1f} % -> Transaktionskosten "
f"{GEBUEHRENSATZ*turnover*100:.3f} % des Portfoliowerts")
# --- Laufzeitvergleich vektorisiert vs. Schleife ----------------------
if S <= 600: # bei sehr vielen Szenarien zu langsam
t0 = time.perf_counter()
optimiere_cvar(R, w_alt, vektorisiert=False)
dauer_schleife = time.perf_counter() - t0
print(f"\n--- Laufzeit: {S} Nebenbedingungen aufbauen ---")
print(f" vektorisiert (u >= -(R @ w) - gamma): {dauer_vektor:6.2f} s")
print(f" Schleife ueber Szenarien (V01): {dauer_schleife:6.2f} s "
f"({dauer_schleife/dauer_vektor:.1f}x langsamer)")
print("=" * 90)
```
> **💻 Code-Durchgang: Vektorisierung**
>
> Die entscheidende Zeile ist
> ```python
> bedingungen.append(u >= -(R @ w) - gamma) # EINE Matrixbedingung
> ```
> statt
> ```python
> for s in range(S):
> bedingungen.append(u[s] >= -R[s] @ w - gamma) # S einzelne Bedingungen
> ```
>
> Beide beschreiben **dasselbe Modell**. Der Unterschied liegt im Aufbau: CVXPY muss im
> zweiten Fall 500 einzelne Ausdrucksbäume erzeugen, prüfen und zusammensetzen. Bei einem
> Backtest mit 60 Rebalancings ({ref:kap:handelsmaschine}) summiert sich das erheblich.
>
> **Die allgemeine Regel:** Wenn Sie in einer Modellierungssprache eine Python-Schleife über
> Datenzeilen schreiben, prüfen Sie, ob sich dasselbe als Matrixoperation ausdrücken lässt.
> Der Solver rechnet ohnehin mit Matrizen — der Umweg über einzelne Ausdrücke kostet nur
> Aufbauzeit.
> **⚠️ Die Standardabweichung ist in CVXPY keine Wurzel**
>
> Im Vergleichsmodell `optimiere_varianz()` steht die Standardabweichung als
>
> ```python
> L = np.linalg.cholesky(sigma + 1e-12 * np.eye(N))
> risiko = cp.norm2(L.T @ w) # = sqrt(w' sigma w)
> ```
>
> und **nicht** als das Naheliegende, `cp.sqrt(cp.quad_form(w, sigma))`. Der naheliegende
> Ausdruck ist nicht DCP und wird von CVXPY mit einem `DCPError` abgelehnt: `cp.sqrt` ist
> konkav und verlangt deshalb ein **konkaves** Argument, `quad_form` ist aber konvex. Die
> Regel wird verletzt, obwohl die Funktion als ganze mathematisch völlig harmlos ist —
> $\sqrt{w^\top \Sigma w}$ ist konvex, DCP kann es nur nicht *sehen*.
>
> Wichtig ist die Fehlerdiagnose dahinter, weil sie leicht in die Irre geht:
> `cp.psd_wrap(sigma)` sieht nach der Lösung aus und ist keine. Es unterdrückt die
> **numerische** Beanstandung, dass $\Sigma$ nicht als positiv semidefinit erkannt wird —
> gegen die **strukturelle** Regelverletzung im Aufbau hilft es nicht.
>
> Der Ausweg ist immer derselbe: Zerlegen Sie $\Sigma = LL^\top$ (Cholesky). Dann ist
> $w^\top \Sigma w = \lVert L^\top w\rVert_2^2$, und die Standardabweichung wird zur 2-Norm
> eines **affinen** Ausdrucks — konvex, regelkonform und für den Solver sogar die bessere
> Formulierung, weil sie direkt ein Kegelproblem ist. Der winzige Diagonalzuschlag
> $10^{-12}$ fängt den Fall ab, dass $\Sigma$ numerisch nur halbdefinit ist (mehr Titel als
> Handelstage, duplizierte Spalten). Die vollständige Fehlertabelle steht in
> {ref:anhang:fehlerdiagnose}.
> **💡 Zur Annualisierung von VaR und CVaR**
> Ein häufiger Fehler skaliert den täglichen CVaR mit $\sqrt{252}$. Diese
> **Wurzel-Zeit-Regel** gilt streng nur für **Standardabweichungen** unabhängig identisch
> verteilter Größen ohne Drift. Der CVaR ist ein Erwartungswert über einen Verteilungsrand
> — für ihn ist die Regel eine grobe Näherung, die bei fetten Rändern und Autokorrelation
> systematisch danebenliegt.
>
> **Dieses Buch weist VaR und CVaR deshalb als Tageswerte aus** und benennt die
> Skalierungsproblematik ausdrücklich. Wer Mehrtageshorizonte braucht, simuliert sie
> (Monte-Carlo aus {ref:kap:unsicherheit}) statt zu skalieren.
---
## Übungsaufgaben {#sec:cvar-uebungsaufgaben}
> Lösungen: {ref:sec:loesungen-cvar}.
**Aufgabe ⭐ — VaR und CVaR ablesen.**
Zehn Tagesverluste (in %): $-1{,}2$, $0{,}3$, $2{,}1$, $-0{,}8$, $5{,}4$, $1{,}1$,
$-2{,}0$, $0{,}6$, $8{,}9$, $-0{,}4$ (positiv = Verlust). Bestimmen Sie $\text{VaR}_{80\%}$
und $\text{CVaR}_{80\%}$ von Hand.
**Aufgabe ⭐ — Warum kohärent?**
Erklären Sie in eigenen Worten, warum ein Risikomaß subadditiv sein sollte. Was bedeutet
es wirtschaftlich, wenn diese Eigenschaft verletzt ist?
**Aufgabe ⭐⭐ — Rockafellar-Uryasev nachvollziehen.**
Zeigen Sie für die drei Szenarien mit Verlusten $(1, 4, 9)$ und $\alpha = 2/3$:
(a) Was ist der empirische CVaR?
(b) Werten Sie $\gamma + \frac{1}{S(1-\alpha)}\sum_s \max(\text{Verlust}_s-\gamma, 0)$ für
$\gamma \in \{0, 1, 4, 5, 9\}$ aus.
(c) Bei welchem $\gamma$ ist der Ausdruck minimal, und stimmt das Minimum mit (a) überein?
**Aufgabe ⭐⭐ — Einheiten prüfen.**
Angenommen, ein Modell verrechnet Jahresrendite gegen Tages-CVaR (siehe die Warnung in
{ref:sec:cvar-implementierung-cvar-portfolio-mit-reibung}). Berechnen Sie: Welchem „effektiven“ täglichen $\lambda$ entspräche
`lambda_risk = 1.5` dadurch? Welchen Wert müsste man stattdessen setzen, um dieselbe
Wirkung wie `RISIKOAVERSION = 1.5` im konsistenten Tagesmodell zu erzielen?
**Aufgabe ⭐⭐⭐ — Risikoaversion kalibrieren.**
Variieren Sie `RISIKOAVERSION` von 0 bis 20 und tragen Sie Rendite, Volatilität, CVaR und
Turnover gegeneinander auf.
(a) Wie sieht die „Effizienzgrenze“ im Rendite-CVaR-Raum aus?
(b) Bei welchem Wert entspricht das CVaR-Portfolio ungefähr dem Varianz-Portfolio?
(c) Welchen Wert würden Sie einem konservativen Stiftungsfonds empfehlen — und wie
begründen Sie ihn ohne Fachjargon?
**Aufgabe ⭐⭐⭐ — Turnover-Grenze statt Strafe.**
Ersetzen Sie den Kostenterm durch eine **harte Grenze**
$\lVert\mathbf{w}-\mathbf{w}_{\text{alt}}\rVert_1 \le \tau$ und variieren Sie
$\tau \in \{0{,}05;\ 0{,}1;\ 0{,}25;\ 0{,}5;\ 1{,}0\}$.
(a) Wie verändert sich die erreichbare Rendite?
(b) Was ist der Vorteil einer Grenze gegenüber einer Strafe — und was der Nachteil?
(c) Wann würden Sie was einsetzen?
---
## Finde den Denkfehler {#sec:cvar-denkfehler}
> **🐛 Finde den Denkfehler 13.1: Das Risikobudget, das durch Diversifikation stieg**
>
> Eine Bank vergibt Risikobudgets je Abteilung. Zwei Kreditabteilungen halten je eine
> Unternehmensanleihe über 100 € Nominal mit **4 % Ausfallwahrscheinlichkeit** (bei
> Ausfall Totalverlust, sonst 2 € Kupon). Der Risikocontroller misst für jede einzeln:
>
> $$\text{VaR}_{95}(\text{A}) = -2{,}00\,€, \qquad \text{VaR}_{95}(\text{B}) = -2{,}00\,€$$
>
> Ein *negativer* Verlust, also ein Gewinn. Auf dem 95-%-Niveau erscheint jede Anleihe
> risikolos, beide Abteilungen bekommen grünes Licht. Der Controller notiert als
> Gesamtrisiko die Summe: $-4{,}00\,€$.
>
> Dann werden die Positionen in einem gemeinsamen Buch zusammengelegt. Der VaR des
> Gesamtbuchs beträgt **+98,00 €**.
>
> **Ihre Aufgabe:**
>
> (a) Wie kann die Zusammenlegung zweier **unabhängiger** Positionen das gemessene Risiko
> von $-4\,€$ auf $+98\,€$ treiben? Rechnen Sie die entscheidende Wahrscheinlichkeit aus.
>
> (b) Warum meldet der VaR für eine einzelne Anleihe einen Gewinn, obwohl sie mit 4 %
> Wahrscheinlichkeit vollständig ausfällt?
>
> (c) Welche Eigenschaft eines Risikomaßes wird hier verletzt? Was bedeutet das konkret für
> ein Unternehmen, das Risikobudgets auf Abteilungen verteilt und wieder zusammenzählt?
>
> (d) Der CVaR liefert 79,59 € und 79,66 € einzeln, aber nur 101,33 € zusammen — statt der
> Summe von 159,24 €. Warum kann ihm das Verhalten des VaR nicht passieren?
>
> *Die Zahlen stammen aus Teil 2 von `VaR_CVaR_Demo.py` oben. Auflösung:*
> *{ref:sec:loesungen-cvar}.*
> **⚠️ Eine Falle beim Nachrechnen**
>
> Wenn Sie den CVaR selbst nachrechnen wollen: Der naheliegende Weg
> `verluste[verluste >= var].mean()` ist **falsch**, sobald die Verteilung Atome hat — und
> hier hat sie genau zwei mögliche Werte. Der VaR liegt dann selbst auf einem Atom, und der
> Vergleich `>=` erfasst weit mehr Wahrscheinlichkeitsmasse als die vorgesehenen 5 %.
>
> Deshalb verwendet `cvar_rockafellar()` oben die Optimierungsformel statt der naiven
> Mittelung. Wer stattdessen exakt abzählt, muss die schlechtesten
> $\lceil (1-\alpha)\,N \rceil$ Szenarien nehmen — nicht alle, die eine Schwelle
> überschreiten.
>
> Diese Falle ist besonders unangenehm, weil das falsche Ergebnis plausibel aussieht. Sie
> fällt nur auf, wenn man wie hier eine **unabhängige Gegenprobe** hat: Ein CVaR, der die
> Subadditivität verletzt, kann nicht stimmen.
> **🎯 Merksatz**
> Ein Risikomaß, dessen Werte man nicht addieren darf, ist als Steuerungsgröße unbrauchbar —
> denn genau das tut jede Organisation mit Kennzahlen: Sie verteilt sie auf Einheiten und
> zählt sie wieder zusammen. Der CVaR ist **kohärent** und erlaubt das; der VaR nicht. Aus
> diesem Grund hat die Bankenaufsicht mit Basel III vom VaR auf den *Expected Shortfall*
> umgestellt — dieselbe Größe, die hier CVaR heißt.
---
## Micro-Quiz {#sec:cvar-quiz}
> **❓ Micro-Quiz 13: Drei Fragen zum Selbstcheck**
>
> Genau eine Antwort ist jeweils richtig. Auflösung in
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-cvar).
>
> **1. Zwei Anlagen haben denselben VaR(95) von 3 %. Was wissen Sie über ihre
> Extremverluste?**
> (a) Sie sind gleich hoch — der VaR misst genau das.
> (b) **Nichts.** Der VaR ist ein Quantil: Er markiert die Schwelle und sagt nichts
> darüber, was dahinter liegt. Die eine kann dort 3 %, die andere 40 % haben.
> (c) Die Anlage mit der höheren Schwankung hat auch die höheren Extremverluste.
>
> **2. Warum lässt sich der CVaR als lineares Programm minimieren, der VaR aber nicht?**
> (a) Der VaR ist nicht differenzierbar.
> (b) Der CVaR lässt sich nach Rockafellar/Uryasev über eine Hilfsvariable und
> Schlupfterme als konvexes — sogar lineares — Problem schreiben. Die VaR-Minimierung ist
> dagegen nicht konvex und hat viele lokale Optima.
> (c) Der CVaR ist immer größer als der VaR.
>
> **3. Sie sollen das Risiko von drei Handelsbüchern zu einer Gesamtkennzahl
> zusammenfassen. Welches Maß nehmen Sie?**
> (a) VaR — er ist in der Praxis verbreiteter und leichter zu erklären.
> (b) CVaR, weil er **subadditiv** ist: Die Gesamtkennzahl kann nie größer werden als die
> Summe der Einzelkennzahlen, Diversifikation wird also korrekt belohnt.
> (c) Beide liefern dasselbe, solange die Bücher unabhängig sind.
---
## Selbsttest {#sec:cvar-selbsttest}
> Antworten: [Anhang A](90_Anhang_Loesungen.md#selbsttest-loesung-cvar).
1. Was misst der CVaR, was der VaR nicht misst?
2. Warum ist der CVaR konvex, aber nicht streng konvex?
3. Welche Rolle spielt die Hilfsvariable $\gamma$ bei Rockafellar/Uryasev?
4. Warum bevorzugt die $L_1$-Strafe wenige große statt vieler kleiner Umschichtungen?
5. Warum sollte man tägliche und annualisierte Größen nicht in einer Zielfunktion mischen?
---
## Zusammenfassung {#sec:cvar-zusammenfassung}
* **Reale Renditen haben fette Ränder.** Ein 5-Sigma-Tag ist unter Normalverteilung ein
Jahrtausendereignis — an echten Märkten passiert er alle paar Jahre.
* **Der VaR ist ein Quantil und schaut nicht dahinter.** Zwei Anlagen mit identischem VaR
können Extremverluste von 3 % und 40 % haben. Der VaR sagt, **wie oft** es schiefgeht;
der CVaR, **wie schlimm** es dann ist.
* **Der VaR ist nicht kohärent:** Er kann Diversifikation als Risikoerhöhung ausweisen.
Deshalb die regulatorische Umstellung auf den Expected Shortfall. Praktische Folge: Man
darf VaR-Kennzahlen **nicht über Einheiten addieren** — und genau das tut jede
Organisation mit Kennzahlen.
* **Das bessere Maß ist hier zugleich das leichter optimierbare** — ein seltener Glücksfall.
Der CVaR wird zum LP, die VaR-Minimierung bleibt nicht-konvex.
* **Rockafellar/Uryasev** machen den CVaR über eine Hilfsvariable $\gamma$ linear
optimierbar, ohne den VaR vorher zu kennen.
* **Transaktionskosten gehören ins Modell**, nicht in eine nachgelagerte Rechnung — sonst
schichtet der Optimierer bei jedem Rauschen um.
* **Einheiten konsistent halten.** Tägliche Renditen zu täglichem Risiko; annualisiert wird
erst in der Ausgabe.
* **Vektorisieren Sie Szenario-Bedingungen** — das spart bei wiederholten Läufen erheblich
Zeit.
**Ausblick.** {ref:kap:handelsmaschine} fügt alles zusammen: Datenpipeline, Signal, Optimierung,
Rebalancing und ein Walk-Forward-Backtest ohne Lookahead-Bias.
---
*Weiter mit:* [{ref:kap:handelsmaschine} — Die vollständige Handelsmaschine](43_Handelsmaschine.md)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,370 @@
# Projektwerkstatt — elf eigene Anwendungen
> **📌 Auf einen Blick**
>
> **Worum geht es?** Um den Schritt, auf den der ganze Kurs zuläuft: **eine eigene Anwendung
> bauen.** Elf vollständig ausgearbeitete Projektaufträge, jeder mit Datenquellen,
> Modellskizze, Abnahmekriterien und Stolperfallen. P1P8 bauen je ein Modell; **P9P11
> setzen dort an, wo eines schon steht** — wenn es zu langsam wird, wenn zwei Ziele
> gegeneinanderstehen, wenn es jemand anderes benutzen soll.
>
> **Voraussetzungen:** je Projekt angegeben.
>
> **Zeitbedarf:** 1025 Stunden je Projekt.
---
## Wie Sie ein Projekt bearbeiten
Halten Sie sich an diese Reihenfolge — sie ist aus vielen gescheiterten und einigen
gelungenen Projekten destilliert:
| Phase | Dauer | Ergebnis | Häufigster Fehler |
| --- | --- | --- | --- |
| **1. Zerlegen** | 10 % | Ausgefüllte Bausteine-Vorlage ({ref:sec:einfuehrung-die-vier-universellen-bausteine-jedes-or}) auf **Papier** | Sofort programmieren |
| **2. Kleinstinstanz** | 15 % | Modell mit 35 Elementen, Lösung **von Hand** geprüft | Gleich mit echten Daten anfangen |
| **3. Daten** | 20 % | Echte Daten geladen, validiert, dokumentiert | Datenqualität unterschätzen |
| **4. Skalieren** | 20 % | Volles Modell läuft im Zeitlimit | Zu viele harte Bedingungen |
| **5. Erklärbar machen** | 20 % | Report, den ein Fachanwender versteht | Ganz weglassen |
| **6. Abnahme** | 15 % | Kriterien geprüft, Grenzen dokumentiert | „Läuft ja“ als Abnahme |
> **🎯 Die wichtigste Regel**
> **Phase 2 ist nicht optional.** Ein Modell, das Sie an drei Mitarbeitenden und vier
> Schichten nicht von Hand nachrechnen können, werden Sie an 200 Mitarbeitenden nie
> debuggen. Jede Stunde in Phase 2 spart drei in Phase 4.
---
## P1 — Vertretungsplaner für eine Schule
> **Schwierigkeit:** ⭐⭐ · **Kapitel:** {ref:kap:einfuehrung}, {ref:kap:milp}, {ref:kap:cpsat}, {ref:kap:praxisfallen} · **Zeit:** ca. 15 Stunden
**Ausgangslage.** Morgens um 7:15 Uhr meldet sich die dritte Lehrkraft krank. Die
stellvertretende Schulleitung hat 20 Minuten, um einen Vertretungsplan zu erstellen, der
Qualifikationen, Arbeitszeiten, bereits geleistete Vertretungen und persönliche Wünsche
berücksichtigt.
**Daten.** Stundenplan (CSV: Klasse, Tag, Stunde, Fach, Lehrkraft, Raum) —
in diesem Projektordner erzeugen die Skripte `stundenplan*.py` genau solche Dateien und
eignen sich als Datenquelle. Zusätzlich: Qualifikationsmatrix, Deputate,
Abwesenheitsmeldungen.
**Modellskizze.**
* Variablen: $x_{p,s} \in \{0,1\}$ — Person $p$ übernimmt Slot $s$.
* Hart: genau eine Person je Slot; Qualifikation; keine Doppelbelegung; Höchstzahl
Vertretungsstunden; Ruhezeiten.
* Weich: Vorbelastung, Freistundenlöcher, Fachnähe, Wünsche, Fairness über die Woche.
* Solver: CP-SAT.
**Abnahmekriterien.**
- [ ] Läuft in unter 10 Sekunden für eine ganze Schule (60 Lehrkräfte, 30 Slots).
- [ ] Liefert **immer** einen Plan — auch wenn nicht alle Slots besetzbar sind (Relaxation).
- [ ] Weist je Zuweisung eine Begründung aus.
- [ ] Ausgabe als CSV **und** als lesbare Tagestabelle.
- [ ] Vergleich gegen die manuelle Lösung: Wie viele Wünsche werden erfüllt?
**Stolperfallen.**
* Zu viele harte Regeln → `INFEASIBLE` an genau dem Tag, an dem man das System braucht.
* Fairness nur über einen Tag statt über die Woche → dieselbe Person trifft es ständig.
* Akzeptanz: Ohne Begründungsanzeige wird der Plan überschrieben.
**Erweiterungen.** Mehrtagesplanung; Raumkonflikte; Springerstunden; Schnittstelle zum
Schulverwaltungsprogramm.
---
## P2 — Schichtplanung für ein Pflegeteam
> **Schwierigkeit:** ⭐⭐⭐ · **Kapitel:** {ref:kap:milp}, {ref:kap:cpsat}, {ref:kap:praxisfallen} · **Zeit:** ca. 20 Stunden
**Ausgangslage.** 25 Pflegekräfte, drei Schichten täglich, 28-Tage-Zyklus. Es gelten
Arbeitszeitgesetz, Tarifvertrag, Qualifikationsmix und individuelle Wünsche.
**Modellskizze.**
* Variablen: $x_{p,t,s} \in \{0,1\}$ — Person, Tag, Schicht.
* Hart: Mindestbesetzung je Schicht **und** Qualifikationsmix (mindestens eine examinierte
Kraft); nach Nachtschicht ≥ 2 freie Tage; höchstens 6 Arbeitstage in Folge; monatliche
Sollstunden ± 10 %.
* Weich: Wunschfrei, gleichmäßige Wochenendverteilung, ungeteilte Dienste, stabile
Schichtfolgen (nicht FrühNachtFrüh).
* Solver: CP-SAT mit Intervall- und Cumulative-Constraints.
**Abnahmekriterien.**
- [ ] Alle gesetzlichen Regeln nachweislich eingehalten (per `assert` geprüft).
- [ ] Wunscherfüllungsquote wird ausgewiesen und ist fair verteilt (Gini-Koeffizient{idx:Gini-Koeffizient}).
- [ ] Läuft in unter 60 Sekunden.
- [ ] Bei Unlösbarkeit: Notfallplan plus benannte Ursache.
**Stolperfallen.** Der 28-Tage-Zyklus erzeugt viele Variablen (25 × 28 × 3 = 2100) —
Symmetriebrechung und gute Suchheuristiken werden wichtig. Fairness über einen Monat ist
etwas anderes als Fairness über eine Woche.
---
## P3 — Tourenplanung für einen Lieferdienst
> **Schwierigkeit:** ⭐⭐ · **Kapitel:** {ref:kap:graphen} · **Zeit:** ca. 12 Stunden
**Ausgangslage.** Ein regionaler Lieferdienst fährt täglich 4080 Adressen mit 4 Fahrzeugen
an. Kunden haben Zeitfenster, Fahrzeuge Kapazitäten, Fahrer Arbeitszeiten.
**Daten.** Adressen aus einer CSV; Entfernungen über OpenStreetMap (`osmnx`, `openrouteservice`)
oder als Luftlinie mit Umwegfaktor 1,3 als Näherung.
**Modellskizze.** OR-Tools Routing-Bibliothek mit Kapazitäts- und Zeitdimension;
Metaheuristik `GUIDED_LOCAL_SEARCH`, Zeitlimit 30 Sekunden.
**Abnahmekriterien.**
- [ ] Alle Kunden werden innerhalb ihrer Zeitfenster beliefert.
- [ ] Vergleich gegen die bisherige manuelle Tourenplanung: Ersparnis in km und Minuten.
- [ ] Kartendarstellung der Touren (`folium`).
- [ ] Robust gegen Ausfall eines Fahrzeugs (Neuplanung in unter 30 s).
**Stolperfallen.** Luftlinie unterschätzt Fahrzeiten systematisch — mit realistischem
Umwegfaktor arbeiten oder echte Routing-Distanzen holen. Zeitfenster, die physisch nicht
erreichbar sind, führen zu „keine Lösung“ ohne Erklärung: Vorabprüfung einbauen (siehe
{ref:kap:graphen}).
---
## P4 — Standort- und Lagernetzplanung
> **Schwierigkeit:** ⭐⭐⭐ · **Kapitel:** {ref:kap:milp}, {ref:kap:graphen} · **Zeit:** ca. 15 Stunden
**Ausgangslage.** Ein Handelsunternehmen prüft, welche von 12 möglichen Lagerstandorten
eröffnet werden sollen, um 60 Filialen zu versorgen.
**Modellskizze.** Kombiniertes Standort- und Transportproblem:
* $y_j \in \{0,1\}$ — Lager $j$ eröffnen (Fixkosten).
* $x_{ij} \ge 0$ — Menge von Lager $j$ zu Filiale $i$.
* Kopplung: $x_{ij} \le M\,y_j$; Kapazität je Lager; Bedarfsdeckung je Filiale.
* Ziel: Fixkosten + Transportkosten minimieren.
**Abnahmekriterien.**
- [ ] Sensitivitätsanalyse: Wie verändert sich die Lösung bei ±20 % Transportkosten?
- [ ] Was-wäre-wenn: Standort X wird politisch vorgegeben — was kostet das?
- [ ] Kartendarstellung mit Zuordnungslinien.
- [ ] Amortisationsrechnung über 10 Jahre.
**Stolperfallen.** Big-M zu groß wählen ({ref:kap:milp}) — hier ist die Lagerkapazität die
natürliche Wahl. Fixkosten sind einmalig, Transportkosten laufend: **Barwerte rechnen**,
nicht einfach addieren.
---
## P5 — Produktionsplanung mit Rüstkosten
> **Schwierigkeit:** ⭐⭐⭐ · **Kapitel:** {ref:kap:lp}, {ref:kap:milp}, {ref:kap:cpsat} · **Zeit:** ca. 18 Stunden
**Ausgangslage.** Eine Fertigung produziert 15 Varianten auf 4 Maschinen. Jeder
Produktwechsel kostet Rüstzeit; die Rüstzeit hängt von der Reihenfolge ab.
**Modellskizze.** Los- und Reihenfolgeplanung:
* Variablen: Produktionsmengen, Rüstentscheidungen, Reihenfolge (Intervallvariablen).
* Hart: Bedarfsdeckung je Periode, Maschinenkapazität, Mindestlosgrößen.
* Ziel: Rüst- + Lager- + Fehlmengenkosten minimieren.
* Solver: CP-SAT mit `AddNoOverlap` und reihenfolgeabhängigen Übergangszeiten.
**Abnahmekriterien.**
- [ ] Gantt-Diagramm der Maschinenbelegung.
- [ ] Vergleich gegen die aktuelle Praxis (Ersparnis in Rüststunden).
- [ ] Schattenpreise: Welche Maschine ist der Engpass, was wäre eine zusätzliche Schicht wert?
---
## P6 — Portfolio-Rebalancer für ein Privatdepot
> **Schwierigkeit:** ⭐⭐⭐ · **Kapitel:** {ref:kap:finanzdaten} bis {ref:kap:handelsmaschine} · **Zeit:** ca. 20 Stunden
>
> ⚠️ **Keine Anlageberatung.** Dieses Projekt dient dem Methodenverständnis. Setzen Sie
> kein echtes Geld auf ein selbstgebautes Modell, dessen Grenzen Sie nicht vollständig
> verstehen.
**Ausgangslage.** Ein Privatdepot aus 1015 ETFs und Einzeltiteln soll quartalsweise
zurückgeführt werden — unter Berücksichtigung von Ordergebühren, Mindestordergrößen und der
Steuerfreibetragsnutzung.
**Modellskizze.**
* Variablen: Zielgewichte $w_i$; Binärvariablen für „Position wird gehandelt“.
* Hart: Vollinvestition, keine Leerverkäufe, Positionsobergrenzen, Mindestordergröße
(semikontinuierlich, {ref:kap:milp}), höchstens $K$ Transaktionen je Rebalancing.
* Ziel: erwartete Rendite Risikoterm Transaktionskosten Steuerwirkung.
* Solver: CVXPY (konvexer Teil) oder MIQP für die Kardinalität.
**Abnahmekriterien.**
- [ ] Toleranzband: Es wird nur gehandelt, wenn die Abweichung > 5 Prozentpunkte beträgt.
- [ ] Alle Kosten (Ordergebühr, Spread, Steuer) sind explizit ausgewiesen.
- [ ] Backtest über 5 Jahre mit Lookahead-Selbsttest.
- [ ] Vergleich gegen: nie rebalancen, jährlich rebalancen, Gleichgewichtung.
- [ ] Bericht, den ein Nicht-Fachmann versteht.
**Stolperfallen.** Steuern sind pfadabhängig (FIFO, Freibetrag) und passen nicht sauber in
ein einperiodiges Modell — Näherung wählen und die Näherung **dokumentieren**. Und: Wenn
Ihre Strategie die Gleichgewichtung nicht schlägt, ist das ein Ergebnis, kein Misserfolg.
---
## P7 — Risikoreport mit CVaR und Stresstests
> **Schwierigkeit:** ⭐⭐ · **Kapitel:** {ref:kap:unsicherheit}, {ref:kap:finanzdaten}, {ref:kap:cvar} · **Zeit:** ca. 12 Stunden
**Ausgangslage.** Für ein bestehendes Portfolio soll ein monatlicher Risikobericht entstehen.
**Inhalte des Berichts.**
* VaR und CVaR auf 95 % und 99 %, historisch **und** Monte-Carlo-simuliert.
* Risikobeiträge je Position (*marginal CVaR*): Wer treibt das Risiko?
* Stresstests: Was passiert bei 20 % Aktienmarkt, +200 Basispunkten Zins,
Korrelationsanstieg auf 0,9?
* Historische Szenarien nachspielen (2008, März 2020).
* Konzentrationskennzahlen (Herfindahl-Index{idx:Herfindahl-Index}, effektive Titelzahl).
**Abnahmekriterien.**
- [ ] Bericht als PDF, automatisch erzeugt.
- [ ] Alle Kennzahlen mit zwei unabhängigen Methoden berechnet und verglichen.
- [ ] Klartext-Zusammenfassung: „Im schlechtesten Prozent der Monate verlieren Sie
typischerweise X €.“
**Stolperfallen.** Die Wurzel-Zeit-Regel bei CVaR ({ref:kap:cvar}) — nicht blind anwenden.
Korrelationen sind in Krisen andere als im Mittel: Stresstest mit erhöhten Korrelationen
rechnen.
---
## P9 — Wenn der Solver aussteigt: Tourenplanung in Echtgröße
> **Schwierigkeit:** ⭐⭐⭐ · **Kapitel:** {ref:kap:graphen}, {ref:kap:metaheuristiken} · **Zeit:** ca. 18 Stunden
**Ausgangslage.** P3 hat funktioniert — mit 30 Kunden. Der Auftraggeber kommt mit 400
zurück, und derselbe Code liefert nach zwanzig Minuten noch keine Lösung. Das ist keine
Panne, sondern der erwartbare Umschlagpunkt.
**Modellskizze.** Zwei Verfahren am **selben** Problem, verglichen unter **gleichem
Zeitbudget**:
* Exakt: MILP oder CP-SAT wie in P3, mit Zeitlimit und protokolliertem Gap.
* Heuristisch: Startlösung mit einer Faustregel, dann Verbesserung durch Simulated
Annealing oder Large Neighborhood Search ({ref:kap:metaheuristiken}).
* Messgröße ist nicht „wer gewinnt", sondern **ab welcher Instanzgröße** sich das Blatt
wendet.
**Abnahmekriterien.**
- [ ] Eine Tabelle über mindestens vier Instanzgrößen: exakt gegen heuristisch, jeweils
Zielwert und Laufzeit.
- [ ] Der Umschlagpunkt ist auf ±50 Kunden eingegrenzt und **benannt**.
- [ ] Bei kleinen Instanzen wird gegen die bewiesen optimale Lösung geprüft — sonst weiß
niemand, wie gut die Heuristik wirklich ist.
- [ ] Fester Seed, reproduzierbarer Lauf; das Zugbudget ist fix, nicht die Uhrzeit.
> ⚠️ **Die Falle, in die hier fast jeder tappt:** die Heuristik nur auf großen Instanzen zu
> testen, wo man das Optimum nicht kennt. Dann sieht jede Lösung gut aus.
---
## P10 — Zwei Ziele, eine Entscheidung: Kosten gegen CO₂
> **Schwierigkeit:** ⭐⭐ · **Kapitel:** {ref:kap:milp}, {ref:kap:mehrziel} · **Zeit:** ca. 14 Stunden
**Ausgangslage.** Die Geschäftsführung will „günstiger **und** grüner". Beides zugleich
gibt es nicht — und die übliche Antwort, beide Ziele mit Gewichten zu verrechnen, verdeckt
genau die Frage, um die es geht.
**Modellskizze.** Ein Transport-, Beschaffungs- oder Produktionsmodell mit zwei Zielen:
* Erst beide Ziele **einzeln** optimieren — das gibt die Eckpunkte und damit den Rahmen.
* Dann die Front über das $\varepsilon$-Constraint-Verfahren abfahren
({ref:kap:mehrziel}): ein Ziel minimieren, das andere als Nebenbedingung schrittweise
verschärfen.
* Ergebnis ist **keine Lösung, sondern eine Kurve** — plus die Angabe, was jeder eingesparte
Kilogramm CO₂ an Mehrkosten bedeutet.
**Abnahmekriterien.**
- [ ] Die Pareto-Front ist berechnet und gezeichnet, nicht nur beschrieben.
- [ ] Für mindestens zwei Punkte ist der Aufpreis je eingesparter Einheit ausgerechnet.
- [ ] Es ist geprüft und dokumentiert, welche Punkte der Front eine **gewichtete Summe
niemals** finden würde — und warum.
- [ ] Die Entscheidungsvorlage nennt drei Punkte zur Auswahl, nicht dreißig.
---
## P11 — Vom Skript zum Dienst: das Modell übergeben
> **Schwierigkeit:** ⭐⭐⭐ · **Kapitel:** {ref:kap:praxisfallen}, {ref:kap:testing} · **Zeit:** ca. 16 Stunden
**Ausgangslage.** Ihr Modell rechnet. Jetzt soll es jemand anderes benutzen — jemand, der
weder Python noch Ihr Notebook kennt, und der es auch dann noch braucht, wenn Sie im Urlaub
sind. Nehmen Sie **eines Ihrer eigenen** Projekte P1P10 als Grundlage; dies ist kein neues
Modell, sondern der Schritt danach.
**Modellskizze.** Vier Schichten, jede einzeln abnehmbar:
* **Domäne:** Eingabedaten als validierte Objekte (Pydantic), nicht als lose Dictionaries —
ein falscher Wert soll beim Einlesen auffallen, nicht im Solver ({ref:kap:praxisfallen}).
* **Kern:** Modellaufbau und Lösung hinter einer Funktion, die ein `Loesung`-Objekt
zurückgibt und **alle** Statusfälle behandelt.
* **Tests:** eine Suite, die auch die Fälle abdeckt, in denen es *keine* Lösung gibt
({ref:kap:testing}).
* **Schnittstelle:** ein HTTP-Dienst, der ein JSON entgegennimmt und eines zurückgibt.
**Abnahmekriterien.**
- [ ] Eine ungültige Eingabe wird **vor** dem Solver abgewiesen, mit einer Meldung, die den
Fehler benennt.
- [ ] Die Testsuite läuft grün und enthält mindestens einen INFEASIBLE-Fall.
- [ ] Ein Mutationstest zeigt, dass die Tests eine absichtlich eingebaute Modelländerung
auch wirklich bemerken.
- [ ] Der Dienst antwortet auf eine Beispielanfrage per `curl` — dokumentiert samt Aufruf.
- [ ] Es gibt eine Betriebsseite: Was tun, wenn der Solver `INFEASIBLE` meldet? Der
Notfallplan gehört dazu, nicht die Fehlermeldung ({ref:anhang:fehlerdiagnose}).
> **🎯 Der Prüfstein**
> Geben Sie das Projekt einer Person, die nicht dabei war, zusammen mit dem README — und
> sagen Sie nichts. Was sie nicht allein zum Laufen bringt, ist nicht fertig.
---
## P8 — Freies Projekt aus Ihrem eigenen Umfeld
> **Schwierigkeit:** offen · **Zeit:** 1025 Stunden
**Der Auftrag.** Nehmen Sie die Notiz aus der Aufgabe *Eigenes Problem zerlegen* ({ref:kap:einfuehrung}) hervor — das Problem aus Ihrem
eigenen Alltag oder Beruf. Bauen Sie es.
**Bewährte Kandidaten aus früheren Kursen:**
| Problem | Verfahren | Kapitel |
| --- | --- | --- |
| Sitzordnung für eine Hochzeit (Sympathien/Antipathien) | CP-SAT | {ref:kap:cpsat} |
| Trainingsplan eines Sportvereins (Hallen, Trainer, Altersgruppen) | CP-SAT | {ref:kap:cpsat} |
| Budgetaufteilung auf Projekte mit Abhängigkeiten | MILP | {ref:kap:milp} |
| Schnittoptimierung für Zuschnitt (Holz, Blech, Stoff) | MILP / Spaltengenerierung | {ref:kap:dekomposition} |
| Speiseplan unter Nährwert- und Budgetgrenzen | LP | {ref:kap:lp} |
| Prüfungsplanung (keine Kollisionen, Erholungspausen) | CP-SAT | {ref:kap:cpsat} |
| Ladeplanung für E-Fahrzeugflotte (Lastspitzen vermeiden) | MILP + Cumulative | {ref:kap:milp}, {ref:kap:cpsat} |
| Bewässerungsplan im Kleingarten (Wasser, Wetterprognose) | Stochastisch | {ref:kap:unsicherheit} |
**Abnahmekriterien (für jedes freie Projekt).**
- [ ] Die Bausteine-Vorlage ist ausgefüllt und liegt dem Projekt bei.
- [ ] Eine Kleinstinstanz ist von Hand nachgerechnet und stimmt mit dem Solver überein.
- [ ] Alle Nebenbedingungen werden nach dem Lösen per `assert` geprüft.
- [ ] Es gibt einen Vergleich gegen die bisherige (manuelle) Lösung — mit Zahlen.
- [ ] Das Ergebnis ist erklärbar: Warum diese Lösung, warum nicht die naheliegende
Alternative, was würde sie verbessern?
- [ ] Die Grenzen des Modells sind dokumentiert: Was bildet es **nicht** ab?
---
## Eine Bitte zum Schluss
Wenn Sie ein Projekt fertig haben, machen Sie zwei Dinge:
**Erstens: Zeigen Sie es jemandem, der nichts von Optimierung versteht.** Wenn diese Person
nach fünf Minuten sagen kann, was das Programm tut und warum sie dem Ergebnis trauen sollte,
haben Sie es richtig gemacht. Wenn nicht, fehlt die Erklärbarkeit — nicht das Verständnis
Ihres Gegenübers.
**Zweitens: Schreiben Sie auf, was schiefgegangen ist.** Jeder, der optimiert, produziert
Fehler wie die in diesem Buch besprochenen (siehe z. B.
{ref:sec:lp-die-vorzeichenfalle-bei-schattenpreisen} oder {ref:anhang:fehlerdiagnose}).
Wer sie dokumentiert, macht sie kein zweites Mal — und hilft dem Nächsten.
Viel Erfolg.
---
*Weiter mit:* [{ref:anhang:loesungen} — Lösungen](90_Anhang_Loesungen.md)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,436 @@
# Anhang: Katalog der Modellierungsmuster {#anhang:modellierungsmuster}
> **Wofür dieser Anhang gedacht ist:** Sie sitzen vor einem konkreten Problem und wissen
> nicht, wie Sie eine bestimmte Regel in ein Modell bekommen. Suchen Sie hier das Muster,
> das zu Ihrer Formulierung passt. Jeder Eintrag nennt die Regel in Alltagssprache, die
> mathematische Formulierung, den Code und die Fallstricke.
---
## Übersicht
| # | Muster | Umgangssprachlich | Wo im Buch |
| --- | --- | --- | --- |
| **Logische Schalter** |
| B1 | Aktivierungsschalter | „Wenn genutzt, dann Fixkosten“ | {ref:kap:milp} |
| B2 | Semikontinuierlich | „Entweder 0 oder mindestens L“ | {ref:kap:milp} |
| B3 | Implikation | „Wenn A, dann auch B“ | {ref:kap:milp} |
| B4 | Entweder-Oder | „A oder B, aber nicht beides“ | {ref:kap:milp} |
| B5 | Exklusiv-Oder | „Genau eines von N“ | {ref:kap:cpsat} |
| B6 | Kardinalität | „Höchstens K von N“ | {ref:kap:milp} |
| B7 | Bedingte Kopplung | „Wenn A und B, dann C“ | {ref:kap:milp} |
| **Mengen und Grenzen** |
| B8 | Weiche Grenze | „Möglichst nicht über X“ | {ref:kap:cpsat} |
| B9 | Gestaffelte Preise | „Erste 100 Stück billiger“ | — |
| B10 | Absolutbetrag | „Abweichung nach oben wie unten“ | {ref:kap:cvar} |
| B11 | Min/Max in der Zielfunktion | „Den Schlechtesten verbessern“ | {ref:kap:cpsat} |
| B12 | Verhältnis-Bedingung | „Anteil mindestens 30 %“ | {ref:kap:markowitz} |
| B25 | Mindestabnahme im Zeitraum | „Entweder gar nicht oder 500 im Jahr“ | {ref:kap:bruecke} |
| B27 | Budgetlimit | „Mehr als 2 Mio. gibt es nicht“ | {ref:kap:milp} |
| **Zeit und Reihenfolge** |
| B13 | Vorrangbeziehung | „B erst nach A“ | {ref:kap:cpsat} |
| B14 | Nichtüberlappung | „Eine Maschine, ein Job“ | {ref:kap:cpsat} |
| B15 | Kumulative Ressource | „Höchstens 3 gleichzeitig“ | {ref:kap:cpsat} |
| B16 | Gleitendes Fenster | „Höchstens 5 Tage in Folge“ | {ref:kap:cpsat} |
| B17 | Umrüstkosten | „Wechsel kostet extra“ | {ref:kap:cpsat} |
| B26 | Rüstzeit als Kapazität | „Umbauen kostet Maschinenstunden“ | {ref:kap:supplychain} |
| **Robustheit und Diagnose** |
| B18 | Schlupf gegen Unlösbarkeit | „Regel notfalls brechen“ | {ref:kap:praxisfallen} |
| B19 | Hierarchische Ziele | „Erst A, dann B optimieren“ | {ref:kap:praxisfallen} |
| B20 | Symmetriebrechung | „Gleiche Objekte nicht doppelt zählen“ | {ref:kap:dekomposition} |
| B21 | Worst-Case-Abzug | „Gegen Schätzfehler absichern“ | {ref:kap:unsicherheit} |
| **Netzwerke** |
| B22 | Flusserhaltung | „Was reinkommt, geht raus“ | {ref:kap:graphen} |
| B23 | Zuordnung 1:1 | „Jeder genau eine Aufgabe“ | {ref:kap:graphen} |
| B24 | Subtour-Eliminierung | „Keine isolierten Kreise“ | {ref:kap:graphen} |
> Die Spalte „Wo im Buch“ nennt das Kapitel, in dem das Muster **im Zusammenhang**
> vorkommt — nicht die einzige Stelle, an der es taugt. Die Nummern entstehen beim Bauen;
> im Quelltext dieses Anhangs steht keine einzige.
---
## Logische Schalter
### B1 — Aktivierungsschalter (Fixkosten)
**Regel.**{idx:Aktivierungsschalter (Modellierungsmuster)} „Wenn überhaupt etwas produziert wird, fallen Rüstkosten $F$ an.“
$$x \le M\,y,\qquad y\in\{0,1\},\ x\ge0$$
Zielfunktion: $\dots + F\,y$
```python
modell.Add(x <= M * y) # CP-SAT
# LP/MILP: Zeile x - M*y <= 0
```
> ⚠️ **$M$ so klein wie möglich** — idealerweise die ohnehin vorhandene Kapazitätsgrenze
> von $x$. Zu großes $M$ macht die LP-Relaxation wertlos ({ref:sec:milp-modellierungstricks-big-m-und-logische-bedingungen}).
### B2 — Semikontinuierliche Variable
**Regel.**{idx:Semikontinuierliche Variable} „Entweder gar nicht oder mindestens $L$ (und höchstens $U$).“
$$L\,y \le x \le U\,y,\qquad y\in\{0,1\}$$
Typisch für Mindestordergrößen, Mindestlosgrößen, Mindestabnahmemengen.
### B3 — Implikation
**Regel.**{idx:Implikation (Modellierungsmuster)} „Wenn A gewählt wird, muss auch B gewählt werden.“
$$y_A \le y_B$$
```python
modell.AddImplication(y_a, y_b) # CP-SAT, gleichwertig und lesbarer
```
Für die Umkehrung („nur wenn“) einfach vertauschen. Für Äquivalenz: $y_A = y_B$.
### B4 — Entweder-Oder (disjunktive Bedingung)
**Regel.**{idx:Entweder-Oder-Bedingung} „Es muss $f(\mathbf{x}) \le b_1$ **oder** $g(\mathbf{x}) \le b_2$ gelten.“
$$f(\mathbf{x}) \le b_1 + M(1-y),\qquad g(\mathbf{x}) \le b_2 + M y$$
```python
# CP-SAT: viel eleganter ueber Reifizierung
modell.Add(f_ausdruck <= b1).OnlyEnforceIf(y)
modell.Add(g_ausdruck <= b2).OnlyEnforceIf(y.Not())
```
### B5 — Exklusiv-Oder
**Regel.**{idx:Exklusiv-Oder} „Genau eine der Optionen wird gewählt.“
$$\sum_{j=1}^N y_j = 1$$
```python
modell.AddExactlyOne(y) # bzw. AddAtMostOne / AddBoolOr
```
### B6 — Kardinalität
**Regel.**{idx:Kardinalitätsbeschränkung} „Höchstens (mindestens, genau) $K$ von $N$.“
$$\sum_j y_j \le K \qquad(\ \ge K,\ = K\ )$$
### B7 — Bedingte Kopplung (Konjunktion)
**Regel.**{idx:Bedingte Kopplung (Konjunktion)} „Wenn A **und** B, dann auch C.“
$$y_A + y_B - 1 \le y_C$$
Prüfen Sie die vier Fälle: nur bei $y_A = y_B = 1$ erzwingt die Ungleichung $y_C \ge 1$.
Für „Wenn A **oder** B, dann C“: $y_A \le y_C$ **und** $y_B \le y_C$.
---
## Mengen und Grenzen
### B8 — Weiche Grenze mit Strafkosten
**Regel.**{idx:Weiche Grenze mit Strafkosten} „Möglichst nicht über $b$ — wenn doch, kostet es.“
$$f(\mathbf{x}) \le b + s,\qquad s \ge 0$$
Zielfunktion: $\dots + c_{\text{Strafe}}\cdot s$
Das ist das wichtigste Muster überhaupt für praxistaugliche Modelle (siehe B18).
### B9 — Gestaffelte Preise (stückweise linear)
**Regel.**{idx:Gestaffelte Preise (stückweise linear)} „Die ersten 100 Stück kosten 5 €, danach 8 €.“
Variablen je Stufe: $x = x_1 + x_2$ mit $0 \le x_1 \le 100$, $x_2 \ge 0$;
Kosten $5x_1 + 8x_2$.
> ⚠️ Das funktioniert **nur bei steigenden** Preisen (konvexe Kostenfunktion) ohne
> Binärvariablen — der Optimierer füllt dann automatisch erst die billige Stufe. Bei
> **fallenden** Preisen (Mengenrabatt, konkav) braucht man Binärvariablen je Stufe, sonst
> „schummelt“ das Modell.
### B10 — Absolutbetrag / Abweichung
**Regel.**{idx:Absolutbetrag (Modellierungsmuster)} „Die Abweichung vom Zielwert soll klein sein, egal in welche Richtung.“
$$|x - z| \le d \quad\Longleftrightarrow\quad x - z \le d\ \ \text{und}\ \ z - x \le d$$
In der **Zielfunktion** (Minimierung von $|x-z|$) genügt:
$$\min d \quad\text{u. d. N.}\quad x-z \le d,\quad z-x \le d$$
```python
turnover = cp.norm1(w - w_alt) # CVXPY macht das automatisch
```
### B11 — Min/Max in der Zielfunktion
**Regel.**{idx:Maximin-Fairness} „Der am schlechtesten gestellte Beteiligte soll möglichst gut dastehen“
(Maximin/Fairness).
$$\max t \quad\text{u. d. N.}\quad t \le f_i(\mathbf{x})\ \ \forall i$$
```python
modell.AddMaxEquality(max_var, liste) # CP-SAT
modell.AddMinEquality(min_var, liste)
# Fairness ueber die Spannweite:
modell.Add(spannweite == max_var - min_var) # dann minimieren
```
### B12 — Verhältnis-Bedingung
**Regel.**{idx:Verhältnis-Bedingung} „Der Anteil von Gruppe G soll mindestens 30 % betragen.“
$$\frac{\sum_{i\in G} x_i}{\sum_i x_i} \ge 0{,}3
\quad\Longleftrightarrow\quad
\sum_{i\in G} x_i \ge 0{,}3\sum_i x_i$$
> ⚠️ **Brüche immer wegmultiplizieren** — ein Quotient von Variablen ist nichtlinear und
> meist nicht konvex. Nach dem Umstellen ist die Bedingung linear.
### B25 — Mindestabnahmemenge über einen Zeitraum
**Regel.**{idx:Mindestabnahmemenge} „Entweder wir arbeiten mit diesem Lieferanten gar nicht, oder wir nehmen
ihm im Jahr mindestens 500 Einheiten ab.“
Ein **Schalter für den ganzen Zeitraum**, nicht je Periode:
$$Q\,y \le \sum_{t} x_{t} \le U\,y, \qquad y \in \{0,1\},\ x_t \ge 0$$
```python
# y_s ist EINE Variable je Lieferant - nicht eine je Lieferant UND Periode
y = {s: modell.NewBoolVar(f"vertrag_{s}") for s in lieferanten}
for s in lieferanten:
jahresmenge = sum(x[s, t] for t in perioden)
modell.Add(jahresmenge >= MINDESTMENGE[s] * y[s])
modell.Add(jahresmenge <= JAHRESKAPAZITAET[s] * y[s]) # koppelt x an y
```
> ⚠️ **Die häufigste Verwechslung:** $L\,y_t \le x_t$ **je Periode** (B2) ist ein *anderes*
> Modell — es verlangt in jeder einzelnen Periode eine Mindestmenge und ist erheblich
> strenger. Wer den Jahresvertrag so formuliert, erzeugt ein unlösbares Modell und sucht
> den Fehler dann in den Daten.
> ⚠️ **Die obere Kopplung nicht vergessen.** Ohne $\sum_t x_t \le U\,y$ kann das Modell
> $y = 0$ setzen und trotzdem einkaufen — der Vertrag gilt dann als nicht geschlossen, die
> Ware fließt aber. Das ist der *Trickle Flow* aus
> {ref:sec:milp-modellierungstricks-big-m-und-logische-bedingungen}, nur andersherum.
> $U$ ist die Jahreskapazität des Lieferanten, keine runde Zahl.
### B27 — Budgetlimit
**Regel.**{idx:Budgetlimit} „Alle Maßnahmen zusammen dürfen 2 Mio. € nicht überschreiten.“
$$\sum_i c_i x_i \le B$$
Mit $x_i \in \{0,1\}$ ist das ein **Rucksackproblem**{idx:Rucksackproblem} — dasselbe
Muster, das {ref:kap:milp} an `Rucksack.py` vorrechnet und
{ref:kap:dekomposition} als Pricing-Teilproblem wiederverwendet.
```python
modell.Add(sum(KOSTEN[i] * x[i] for i in massnahmen) <= BUDGET)
# Mehrere Toepfe: je Topf eine Zeile - NICHT die Summe ueber alle Toepfe
for topf, grenze in BUDGETS.items():
modell.Add(sum(KOSTEN[i] * x[i] for i in massnahmen
if TOPF[i] == topf) <= grenze)
```
> 💡 **Der Schattenpreis des Budgets ist die Zahl, nach der die Geschäftsführung fragt:**
> „Was bringt der nächste Euro?“ Bei einem **LP** ist er direkt ablesbar. Bei
> **Ganzzahligkeit gibt es ihn nicht** — die Dualwerte der Relaxation sind keine gültige
> Antwort. Rechnen Sie stattdessen mit erhöhtem Budget neu und vergleichen Sie die
> Zielwerte ({ref:sec:lp-entartung}).
> ⚠️ **Die LP-Relaxation verspricht zu viel.** Sie darf die letzte Maßnahme anteilig kaufen
> und liefert deshalb eine Schranke, die spürbar über dem tatsächlich Erreichbaren liegen
> kann. Wer sie als Prognose berichtet, verspricht Geld, das nicht kommt.
---
## Zeit und Reihenfolge
### B13 — Vorrangbeziehung
**Regel.**{idx:Vorrangbeziehung} „Arbeitsgang B darf erst beginnen, wenn A fertig ist.“
$$\text{start}_B \ge \text{ende}_A$$
Mit Mindestwartezeit $w$: $\text{start}_B \ge \text{ende}_A + w$.
### B14 — Nichtüberlappung
**Regel.**{idx:Nichtüberlappung} „Eine Maschine bearbeitet nur einen Job gleichzeitig.“
```python
modell.AddNoOverlap([intervall_1, intervall_2, ...])
```
In MILP bräuchte man je Paar eine Disjunktion (B4) — bei $k$ Jobs sind das
$\binom{k}{2}$ Konstruktionen. **Nehmen Sie hier CP-SAT.**
### B15 — Kumulative Ressource
**Regel.**{idx:Kumulative Ressource} „Zu keinem Zeitpunkt dürfen mehr als 3 Arbeiten gleichzeitig laufen“ bzw. „die
Stromlast darf 500 kW nie übersteigen“.
```python
modell.AddCumulative(intervalle, bedarfe, kapazitaet)
```
### B16 — Gleitendes Fenster
**Regel.**{idx:Gleitendes Fenster} „Höchstens 5 Arbeitstage in Folge“ / „mindestens 2 freie Tage je Woche“.
$$\sum_{u=t}^{t+5} x_u \le 5 \qquad \forall t$$
```python
for t in range(len(tage) - 5):
modell.Add(sum(x[p, u] for u in range(t, t + 6)) <= 5)
```
### B17 — Umrüst- bzw. Wechselkosten
**Regel.**{idx:Umrüstkosten (Modellierungsmuster)} „Ein Produktwechsel kostet Rüstzeit.“
Hilfsvariable $z_t \in \{0,1\}$ = „in Periode $t$ wird gewechselt“:
$$z_t \ge y_{j,t} - y_{j,t-1} \qquad \forall j, t$$
Zielfunktion: $\dots + c_{\text{Ruest}}\sum_t z_t$. Für **reihenfolgeabhängige**
Rüstzeiten: `AddCircuit` mit Übergangsmatrix.
### B26 — Rüstzeit als Kapazitätsverbrauch
**Regel.**{idx:Rüstzeit} „Das Umrüsten kostet nicht nur Geld, es kostet **Maschinenstunden** — und
die fehlen dann für die Produktion.“
B17 verbucht den Wechsel in der *Zielfunktion*. Sobald die Maschine ausgelastet ist, gehört
er zusätzlich in die *Kapazitätszeile*:
$$\sum_j a_j\,x_{j,t} \;+\; \sum_j r_j\,y_{j,t} \;\le\; C_t \qquad \forall t$$
mit $a_j$ Stückzeit, $r_j$ Rüstzeit, $y_{j,t}\in\{0,1\}$ = „Produkt $j$ läuft in Periode
$t$“ (gekoppelt über B1: $x_{j,t} \le M\,y_{j,t}$).
```python
for t in perioden:
modell.Add(sum(STUECKZEIT[j] * x[j, t] for j in produkte)
+ sum(RUESTZEIT[j] * y[j, t] for j in produkte) <= KAPAZITAET[t])
```
> ⚠️ **Wer die Rüstzeit nur als Kosten führt, erhält Pläne, die in der Halle nicht
> laufen.** Das Modell verteilt die Produktion dann auf viele kleine Lose, weil ein
> zusätzlicher Wechsel zwar etwas kostet, aber keine Zeit verbraucht — die Rechnung geht
> auf dem Papier auf und in der Schicht nicht.
**Reihenfolgeabhängig** ($r_{ij}$ statt $r_j$ — von Weiß auf Schwarz ist schneller als
umgekehrt) ist es kein Kapazitätsproblem mehr, sondern ein Rundreiseproblem: `AddCircuit`
mit der Übergangsmatrix als Kantengewicht (B24, {ref:kap:cpsat}).
---
## Robustheit und Diagnose
### B18 — Schlupfvariablen gegen Unlösbarkeit
**Das wichtigste Muster für den Produktivbetrieb.**
**Regel.**{idx:Schlupfvariable!gegen Unlösbarkeit} „Diese Bedingung soll gelten — aber lieber ein schlechter Plan als gar keiner.“
$$\sum_p x_{p,s} + u_s = 1,\qquad u_s\in\{0,1\}$$
Zielfunktion: $\dots + 10\,000\cdot u_s$
Die Strafe muss **hoch genug** sein, dass der Solver sie nur im Notfall in Kauf nimmt, aber
**endlich**, damit es überhaupt eine Lösung gibt. Faustregel: eine Größenordnung über der
Summe aller weichen Ziele.
### B19 — Hierarchische Ziele (lexikografisch)
**Regel.**{idx:Lexikografische Optimierung} „Erst die Besetzung sicherstellen, dann die Fairness optimieren.“
**Variante A — Gewichtung:** Strafen um Größenordnungen staffeln
($10\,000 \gg 100 \gg 1$). Einfach, aber bei extremen Skalenunterschieden numerisch heikel.
**Variante B — Zweistufig lösen (sauberer):**
```python
# Stufe 1: nur das Hauptziel
modell.Minimize(unbesetzte_schichten)
loeser.Solve(modell)
bestwert = loeser.ObjectiveValue()
# Stufe 2: Hauptziel fixieren, Nebenziel optimieren
modell.Add(unbesetzte_schichten <= int(bestwert))
modell.Minimize(unfairness)
loeser.Solve(modell)
```
### B20 — Symmetriebrechung
**Regel.**{idx:Symmetriebrechung} „Drei identische Maschinen — der Solver soll nicht alle Vertauschungen
durchprobieren.“
$$\text{start}_1 \le \text{start}_2 \le \text{start}_3
\qquad\text{bzw.}\qquad \sum_j x_{1j} \ge \sum_j x_{2j}$$
Ohne Symmetriebrechung durchsucht Branch-and-Bound $k!$ gleichwertige Lösungen. **Eine
einzige Ordnungsbedingung kann die Laufzeit um Größenordnungen senken.**
### B21 — Worst-Case-Abzug (robuste Formulierung)
**Regel.**{idx:Robuste Optimierung!Worst-Case-Abzug} „Rechne nicht mit dem geschätzten Wert, sondern mit dem ungünstigsten
plausiblen.“
Bei Box-Unsicherheit $\mu_i \in [\hat\mu_i - \delta_i, \hat\mu_i + \delta_i]$ und
$w \ge 0$:
$$\hat{\boldsymbol{\mu}}^\top\mathbf{w} \;\longrightarrow\;
\hat{\boldsymbol{\mu}}^\top\mathbf{w} - \boldsymbol{\delta}^\top\mathbf{w}$$
Abgestuft (Bertsimas/Sim): nur die $\Gamma$ ungünstigsten gleichzeitig:
```python
abzug = cp.sum_largest(cp.multiply(delta, w), Gamma)
```
---
## Netzwerke
### B22 — Flusserhaltung
$$\sum_{j:(i,j)\in E} x_{ij} - \sum_{k:(k,i)\in E} x_{ki} = b_i \qquad \forall i$$
{idx:Flusserhaltung}$b_i > 0$ Quelle, $b_i < 0$ Senke, $b_i = 0$ Umschlagknoten. **Voraussetzung:**
$\sum_i b_i = 0$ — sonst unlösbar (Dummy-Knoten einführen).
### B23 — Zuordnung 1:1
{idx:Zuordnungsproblem (1:1)}
$$\sum_j x_{ij} = 1\ \forall i,\qquad \sum_i x_{ij} = 1\ \forall j,\qquad x_{ij} \ge 0$$
> 💡 **Ganzzahligkeit nicht fordern!** Die Matrix ist total unimodular; ein LP-Solver liefert
> automatisch 0/1-Lösungen. Für reine Zuordnungen ist
> `scipy.optimize.linear_sum_assignment` (Ungarischer Algorithmus, $O(n^3)$) noch deutlich
> schneller.
### B24 — Subtour-Eliminierung
**MTZ (einfach, aber schwach):**{idx:MTZ-Formulierung}
$$u_i - u_j + C x_{ij} \le C - d_j \qquad \forall i\ne j$$
**Besser in der Praxis:**{idx:Subtour!Eliminierung} `AddCircuit` in CP-SAT oder die Routing-Bibliothek von OR-Tools.
---
## Ein Wort zur Auswahl
Wenn mehrere Muster passen, entscheiden Sie nach dieser Reihenfolge:
1. **Gibt es ein globales Constraint dafür?** (`AddAllDifferent`, `AddNoOverlap`,
`AddCumulative`, `AddCircuit`) → nehmen Sie es. Es propagiert stärker und ist lesbarer.
2. **Kommt man ohne Big-M aus?** (z. B. B3 statt B4) → ja, dann so.
3. **Muss es wirklich hart sein?** → sonst B8/B18.
4. **Ist $M$ so klein wie möglich?** → prüfen.
---
*Weiter mit:* [{ref:anhang:fehlerdiagnose} — Fehlerdiagnose](92_Anhang_Fehlerdiagnose.md)

View file

@ -0,0 +1,796 @@
# Anhang: Fehlerdiagnose-Handbuch {#anhang:fehlerdiagnose}
> **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](#c1-infeasible) |
| `INFEASIBLE`, aber keine einzelne Bedingung ist schuld | [C1](#c1-infeasible), Deletion Filter |
| Solver meldet `UNBOUNDED` | [C2](#c2-unbounded) |
| Solver läuft ewig / Timeout | [C3](#c3-zu-langsam) |
| Ergebnis ist offensichtlich unsinnig | [C4](#c4-unsinniges-ergebnis) |
| Ergebnis ändert sich bei kleinsten Datenänderungen stark | [C5](#c5-instabile-lösung) |
| Schattenpreise sehen falsch aus | [C6](#c6-falsche-dualwerte) |
| Zwei Solver liefern verschiedene Ergebnisse | [C7](#c7-widersprüchliche-solver) |
| CVXPY wirft `DCPError` | [C8](#c8-dcperror) |
| `ImportError` bei ortools/highspy | [C9](#c9-importfehler) |
| Backtest sieht zu gut aus | [C10](#c10-verdächtig-guter-backtest) |
| Ergebnisse sind falsch beschriftet | [C11](#c11-vertauschte-spalten) |
---
## C1 — INFEASIBLE
**Bedeutung.**{idx:Infeasibility} 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:
```python
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).**
```python
# 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?
```python
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**{idx: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*{idx:IIS (Irreducible Infeasible Subset)}, kurz
IIS. 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.
```python
#!/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:
```text
====================================================================
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
> Bedingungs*blö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`, {ref:sec:praxisfallen-die-fuenf-typischen-praxisfallen}).
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 ({ref:sec:praxisfallen-die-fuenf-typischen-praxisfallen}).
---
## C2 — UNBOUNDED
**Bedeutung.**{idx:Unbounded (Fehlerbild)} 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)]`.
```python
# 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
{idx:Laufzeitexplosion (Fehlerbild)}**1. Ist es überhaupt das Lösen?**
```python
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 {ref:sec:cvar-implementierung-cvar-portfolio-mit-reibung}).
**2. Big-M zu groß?** Der häufigste Grund für explodierende MILP-Laufzeiten. Setzen Sie $M$
auf die kleinste gültige Schranke ({ref:sec:milp-modellierungstricks-big-m-und-logische-bedingungen}).
**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-SAT von Hand
nachgebaut, konvexe Probleme mit `scipy.optimize.minimize` — jeweils Größenordnungen
langsamer als das passende Werkzeug ({ref:sec:oekosystem-wann-lohnt-sich-welche-ebene}).
**5. Wird vektorisiert?**
```python
# langsam: # schnell:
for s in range(S): constraints.append(u >= -(R @ w) - gamma)
constraints.append(u[s] >= -R[s] @ w - gamma)
```
**6. Gap akzeptieren.**
```python
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
{idx:Unsinniges Ergebnis (Fehlerbild)}**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
- [ ] **Einheiten.** Euro gegen Cent? Stunden gegen Minuten? Jahres- gegen Tagesgrößen?
(Ein Beispiel dafür finden Sie in {ref:sec:cvar-implementierung-cvar-portfolio-mit-reibung}.)
- [ ] **Vorzeichen.** Minimieren Sie, wo Sie maximieren wollten? Ist der Ertrag negativ
eingetragen?
- [ ] **Fehlende Bedingung.** Formulieren Sie in Worten, was am Ergebnis falsch ist —
meistens ist genau das die vergessene Nebenbedingung.
- [ ] **Variablenbedeutung.** Ist $x_{ij}$ „Person $i$ macht Aufgabe $j$“ oder umgekehrt?
Bei asymmetrischen Matrizen fällt das nicht auf.
- [ ] **Index-Reihenfolge.** Zeilen und Spalten vertauscht? Siehe C11.
### Die wirksamste Gegenmaßnahme
```python
# 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.**{idx:Instabile Lösung (Fehlerbild)} 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 ({ref:kap:finanzdaten}) |
| 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 ({ref:sec:cvar-transaktionskosten-ueber-die-l-1-norm}) |
```python
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{idx: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.
```python
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 ({ref:sec:fundament-kondition}).
---
## C6 — Falsche Dualwerte
**Symptom.**{idx:Falsche Dualwerte (Fehlerbild)} 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?
```python
schattenpreise = -res.ineqlin.marginals # bei linprog nach Negation
```
Genau das ist die Vorzeichenfalle aus {ref:sec:lp-die-vorzeichenfalle-bei-schattenpreisen}.
**2. Komplementärer Schlupf.**
```python
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.**
```python
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{idx:Entartung!und Dualwerte} — 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.
```python
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
({ref:sec:lp-entartung}).
---
## C7 — Widersprüchliche Solver
**Symptom.**{idx:Widersprüchliche Solver (Fehlerbild)} `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.**{idx:DCPError} 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 |
| `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, 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 ({ref:sec:markowitz-das-mean-variance-modell}) |
| `cp.log(x)` maximieren | konkav — das ist **erlaubt** | in `cp.Maximize` verwenden |
```python
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.**{idx:Importfehler (ortools und highspy)} `ortools` und `highspy` bringen beide eine eigene HiGHS-Kopie mit; sie lassen
sich auf vielen Systemen **nicht im selben Prozess** importieren (siehe {ref:sec:oekosystem-ein-system-vier-programmieransaetze}).
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 (`subprocess`) — 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:**{idx:Verdächtig guter Backtest (Fehlerbild)} Eine Sharpe Ratio über 2 bei einer einfachen Strategie ist fast immer ein
Fehler, kein Fund.
### Prüfreihenfolge
**1. Lookahead-Selbsttest.**
```python
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 ({ref:sec:handelsmaschine-die-fuenf-selbsttaeuschungen-des-backtestens}).
**6. Nach Teilzeiträumen aufschlüsseln.** Stammt die gesamte Überrendite aus einem einzigen
Quartal?
---
## C11 — Vertauschte Spalten
**Symptom.**{idx:Vertauschte Spalten (Fehlerbild)} 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.
```python
# 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.
---
*Weiter mit:* [{ref:anhang:spickzettel} — Spickzettel der Solver](93_Anhang_Spickzettel.md)

View file

@ -0,0 +1,414 @@
# Anhang: Spickzettel der Solver {#anhang:spickzettel}
> **Wofür dieser Anhang gedacht ist:** Sie wissen, was Sie modellieren wollen, und suchen
> nur noch, wie die gewählte Bibliothek es schreibt. Jede Seite hat denselben Aufbau —
> Modell, Variablen, Nebenbedingungen, Lösen, **alle** Statusfälle, Lösung auslesen,
> Stolpersteine. So lassen sich die Seiten nebeneinanderlegen.
**Alle Schnipsel lösen dasselbe Problem** — das Produktionsprogramm aus
{ref:sec:oekosystem-schnellstart} mit dem bekannten Optimum **530** und den Schattenpreisen
**12** und **1**:
$$\max\; 10x_1 + 15x_2 + 25x_3 \quad\text{u.d.N.}\quad
x_1 + x_2 + 2x_3 \le 40,\;\; 2x_1 + 3x_2 + x_3 \le 50,\;\; x \ge 0$$
Damit ist jeder Schnipsel selbstprüfend: Kommt bei Ihnen etwas anderes als 530 heraus, liegt
es an der Übertragung, nicht am Modell. (Die Ausnahme ist CP-SAT — ein rein stetiges LP ist
dort das falsche Werkzeug; die Seite zeigt stattdessen die CP-SAT-eigenen Bausteine.)
> **Was dieser Anhang *nicht* ist.** Kein Vergleich und keine Empfehlung. Welche Bibliothek
> für welche Aufgabe taugt, steht in {ref:sec:oekosystem-wann-lohnt-sich-welche-ebene};
> denselben Fall in vier Bibliotheken *nebeneinander* zeigt `Ein_System_Vier_Ansaetze.py`,
> die beiden Modellierungssprachen `Modellierungsschichten.py`. Hier geht es allein ums
> Nachschlagen.
---
## D1 — SciPy: `linprog` und `milp`
**Wofür.** Die Einstiegsschicht: keine zusätzliche Installation, HiGHS als Unterbau, ideal
für lineare und gemischt-ganzzahlige Probleme in Matrixform. **Wofür nicht:** alles, was
sich nicht als Matrix schreiben lässt, und jede nichtlineare Zielfunktion.
### Lineares Programm
```python
import numpy as np
from scipy.optimize import linprog
# linprog MINIMIERT immer -> zum Maximieren die Zielfunktion negieren
ergebnis = linprog(
c=[-10.0, -15.0, -25.0], # Zielkoeffizienten
A_ub=[[1, 1, 2], [2, 3, 1]], b_ub=[40, 50], # A_ub @ x <= b_ub
A_eq=None, b_eq=None, # Gleichungen, falls vorhanden
bounds=[(0, None)] * 3, # je Variable (unten, oben)
method="highs")
if ergebnis.status == 0:
print(f"optimal: {-ergebnis.fun:.2f}") # Vorzeichen zuruecknehmen!
print(f"x = {np.round(ergebnis.x, 4)}")
print(f"Schattenpreise: {-ergebnis.ineqlin.marginals}")
elif ergebnis.status == 2:
print("INFEASIBLE - kein zulaessiger Punkt")
elif ergebnis.status == 3:
print("UNBOUNDED - Zielfunktion unbeschraenkt")
elif ergebnis.status == 1:
print("Iterations- oder Zeitlimit erreicht")
else:
print(f"numerisches Problem (status {ergebnis.status}): {ergebnis.message}")
```
### Ganzzahlig: `milp`
```python
import numpy as np
from scipy.optimize import milp, LinearConstraint, Bounds
# Ganzzahlig: dasselbe Problem, aber x muss ganzzahlig sein
ergebnis = milp(
c=[-10.0, -15.0, -25.0], # auch milp MINIMIERT
constraints=LinearConstraint([[1, 1, 2], [2, 3, 1]], -np.inf, [40, 50]),
integrality=[1, 1, 1], # 0 = stetig, 1 = ganzzahlig
bounds=Bounds(0, np.inf))
if ergebnis.status == 0:
print(f"optimal: {-ergebnis.fun:.2f} x = {np.round(ergebnis.x).astype(int)}")
print(f"MIP-Gap: {ergebnis.mip_gap:.4f}")
elif ergebnis.status == 1:
print("Zeitlimit - beste gefundene Loesung nutzen, Gap pruefen")
elif ergebnis.status == 2:
print("INFEASIBLE")
elif ergebnis.status == 3:
print("UNBOUNDED")
else:
print(f"kein Ergebnis: {ergebnis.message}")
```
### Die drei häufigsten Stolpersteine
1. **`linprog` minimiert immer.** Zum Maximieren `c` negieren — und beim Ausgeben des
Zielwerts das Vorzeichen wieder zurücknehmen. Dieselbe Negation dreht auch die
Schattenpreise ({ref:sec:lp-die-vorzeichenfalle-bei-schattenpreisen}).
2. **`status == 0` prüfen, nicht `res.success` allein.** `success` ist bei
`status == 1` (Limit erreicht) `False`, obwohl eine brauchbare Lösung vorliegen kann.
3. **`bounds` gilt je Variable.** `bounds=(0, None)` setzt alle Variablen gleich;
`bounds=[(0, None), (0, 10), ...]` einzeln. Wer die Liste vergisst, bekommt stillschweigend
überall dieselbe Schranke.
---
## D2 — HiGHS über `highspy`
**Wofür.** Derselbe Solver wie unter SciPy, aber direkt gesteuert: Optionen, Warm-Starts,
inkrementelles Ändern eines bestehenden Modells. **Wofür nicht:** schnelles Hinschreiben —
die CSR-Matrixübergabe ist fehleranfällig.
```python
import numpy as np
import highspy
h = highspy.Highs()
h.setOptionValue("output_flag", False) # Solverprotokoll abschalten
h.setOptionValue("time_limit", 60.0)
h.setOptionValue("mip_rel_gap", 0.01) # 1 % Gap genuegt
# Variablen: Anzahl, Untergrenzen, Obergrenzen
h.addVars(3, np.zeros(3), np.full(3, highspy.kHighsInf))
h.changeObjectiveSense(highspy.ObjSense.kMaximize)
for spalte, wert in enumerate([10.0, 15.0, 25.0]):
h.changeColCost(spalte, wert)
# Zeilen im CSR-Format: starts[i] = Beginn von Zeile i in indices/values
h.addRows(2, np.full(2, -highspy.kHighsInf), np.array([40.0, 50.0]), 6,
np.array([0, 3], dtype=np.int32), # starts
np.array([0, 1, 2, 0, 1, 2], dtype=np.int32), # Spaltenindizes
np.array([1.0, 1.0, 2.0, 2.0, 3.0, 1.0])) # Koeffizienten
# Ganzzahligkeit: h.changeColsIntegrality(...) mit highspy.HighsVarType.kInteger
h.run()
status = h.getModelStatus()
if status == highspy.HighsModelStatus.kOptimal:
print(f"optimal: {h.getInfo().objective_function_value:.2f}")
print(f"x = {np.round(h.getSolution().col_value[:3], 4)}")
print(f"Schattenpreise: {np.round(h.getSolution().row_dual[:2], 4)}")
elif status == highspy.HighsModelStatus.kInfeasible:
print("INFEASIBLE")
elif status == highspy.HighsModelStatus.kUnbounded:
print("UNBOUNDED")
elif status == highspy.HighsModelStatus.kTimeLimit:
print(f"Zeitlimit, Gap {h.getInfo().mip_gap:.3f}")
else:
print("kein Optimum:", h.modelStatusToString(status))
```
### Die drei häufigsten Stolpersteine
1. **Nicht zusammen mit `ortools` importieren.** Beide bringen eine eigene HiGHS-Kopie mit;
im selben Prozess endet das in `undefined symbol` ({ref:anhang:fehlerdiagnose}, C9).
Auch `cvxpy` zieht `highspy` bei der Solver-Erkennung mit hinein.
2. **Das CSR-Format stimmt oder es stimmt still nicht.** `starts` hat so viele Einträge wie
Zeilen, `indices` und `values` so viele wie Nichtnullen. Ein falscher `starts`-Eintrag
erzeugt ein *anderes*, aber lösbares Modell — es fällt nur durch ein falsches Ergebnis auf.
3. **`output_flag` abschalten**, sonst überschwemmt das Solverprotokoll jede Ausgabe.
---
## D3 — OR-Tools: `pywraplp`
**Wofür.** Bequeme algebraische Schreibweise für LP und MILP mit umschaltbarem Backend
(`GLOP`, `SCIP`, `CBC`, `SAT`). **Wofür nicht:** Scheduling und kombinatorische
Bedingungen — dafür ist CP-SAT (D4) da.
```python
from ortools.linear_solver import pywraplp
# "GLOP" = LP, "SCIP" oder "CBC" = MILP, "SAT" = CP-SAT als MILP-Backend
loeser = pywraplp.Solver.CreateSolver("GLOP")
if loeser is None:
raise SystemExit("Solver nicht verfuegbar")
loeser.SetTimeLimit(60_000) # Millisekunden!
unendlich = loeser.infinity()
x = [loeser.NumVar(0, unendlich, f"x{j}") for j in range(3)]
# ganzzahlig: loeser.IntVar(0, unendlich, "n") | binaer: loeser.BoolVar("b")
loeser.Add(x[0] + x[1] + 2 * x[2] <= 40)
loeser.Add(2 * x[0] + 3 * x[1] + x[2] <= 50)
loeser.Maximize(10 * x[0] + 15 * x[1] + 25 * x[2])
status = loeser.Solve()
if status == pywraplp.Solver.OPTIMAL:
print(f"optimal: {loeser.Objective().Value():.2f}")
print(f"x = {[round(v.solution_value(), 4) for v in x]}")
elif status == pywraplp.Solver.FEASIBLE:
print("zulaessig, nicht bewiesen optimal (Zeitlimit)")
elif status == pywraplp.Solver.INFEASIBLE:
print("INFEASIBLE")
elif status == pywraplp.Solver.UNBOUNDED:
print("UNBOUNDED")
else:
print("ABNORMAL / NOT_SOLVED - Modell oder Solver pruefen")
```
### Die drei häufigsten Stolpersteine
1. **`CreateSolver` liefert `None`**, wenn der Backend-Name unbekannt oder nicht gebaut ist —
immer prüfen, statt am `None` später zu scheitern.
2. **`SetTimeLimit` erwartet Millisekunden**, nicht Sekunden. Ein `SetTimeLimit(60)` bricht
nach einer sechzigstel Sekunde ab.
3. **`FEASIBLE` ist kein `OPTIMAL`.** Bei Zeitlimit liefert der Solver eine gültige, aber
möglicherweise schlechte Lösung — den Gap mitberichten, nicht die Zahl allein.
---
## D4 — CP-SAT: `cp_model`
**Wofür.** Scheduling, Zuordnung, Reihenfolgen, alles Kombinatorische mit globalen
Bedingungen. **Wofür nicht:** stetige Größen — CP-SAT rechnet ausschließlich ganzzahlig.
Wer Nachkommastellen braucht, skaliert (Cent statt Euro, Promille statt Anteil).
```python
from ortools.sat.python import cp_model
modell = cp_model.CpModel()
# Variablen - CP-SAT rechnet ausschliesslich mit GANZEN Zahlen
x = modell.NewIntVar(0, 100, "x") # untere, obere Schranke, Name
y = modell.NewIntVar(0, 100, "y")
b = modell.NewBoolVar("b") # 0/1
# Nebenbedingungen
modell.Add(2 * x + 3 * y <= 50)
modell.Add(x >= 5).OnlyEnforceIf(b) # gilt nur, wenn b wahr ist
modell.AddAllDifferent([x, y]) # globale Bedingung
modell.AddMaxEquality(z := modell.NewIntVar(0, 100, "z"), [x, y])
modell.Maximize(10 * x + 15 * y - 3 * z)
loeser = cp_model.CpSolver()
loeser.parameters.max_time_in_seconds = 10.0
loeser.parameters.num_workers = 1 # 1 = reproduzierbar
loeser.parameters.random_seed = 1
status = loeser.Solve(modell)
if status == cp_model.OPTIMAL:
print(f"optimal: {loeser.ObjectiveValue():.0f} x={loeser.Value(x)} y={loeser.Value(y)}")
elif status == cp_model.FEASIBLE:
print(f"zulaessig, nicht bewiesen optimal - Gap-Schranke: {loeser.BestObjectiveBound()}")
elif status == cp_model.INFEASIBLE:
print("INFEASIBLE - Bedingungen widersprechen sich")
elif status == cp_model.MODEL_INVALID:
print("Modellfehler:", modell.Validate())
else:
print("UNKNOWN - Zeit abgelaufen, ohne eine Loesung zu finden")
print(f"Laufzeit {loeser.WallTime():.3f}s, {loeser.NumBranches()} Verzweigungen")
```
### Die drei häufigsten Stolpersteine
1. **Alles ist ganzzahlig.** `0.5 * x` gibt es nicht. Skalieren Sie das ganze Modell auf eine
feinere Einheit, statt zu runden.
2. **`UNKNOWN` heißt nicht `INFEASIBLE`.** Es heißt: Die Zeit war zu knapp, um überhaupt
etwas zu finden. Die beiden zu verwechseln ist einer der teuersten Fehler in Produktion —
das Modell wird für widersprüchlich erklärt, obwohl es lösbar ist.
3. **Ohne `num_workers = 1` ist der Lauf nicht reproduzierbar.** Mehrere Suchstränge finden
je nach Zeitverlauf verschiedene, gleich gute Lösungen. Für Tests und für abgedruckte
Ausgaben Worker und Seed festnageln.
---
## D5 — CVXPY
**Wofür.** Konvexe Probleme: quadratische Ziele, Normen, CVaR, alles mit
Regularisierungstermen. **Wofür nicht:** große kombinatorische Modelle — der Aufbau der
Ausdrücke wird dann selbst zum Engpass.
```python
import numpy as np
import cvxpy as cp
x = cp.Variable(3, nonneg=True) # nonneg=True statt x >= 0
gewichte = cp.Variable(3)
ganzzahlig = cp.Variable(3, integer=True) # macht daraus ein MIP
ziel = cp.Maximize(np.array([10.0, 15.0, 25.0]) @ x)
bedingungen = [np.array([[1, 1, 2], [2, 3, 1]]) @ x <= np.array([40.0, 50.0])]
problem = cp.Problem(ziel, bedingungen)
if not problem.is_dcp(): # VOR dem Loesen pruefen
print("nicht DCP:", [c for c in bedingungen if not c.is_dcp()])
problem.solve()
if problem.status == cp.OPTIMAL:
print(f"optimal: {problem.value:.2f} x = {np.round(x.value, 4)}")
print(f"Schattenpreis: {np.round(bedingungen[0].dual_value, 4)}")
elif problem.status == cp.OPTIMAL_INACCURATE:
print("Loesung numerisch unsicher - Skalierung pruefen, anderen Solver testen")
elif problem.status == cp.INFEASIBLE:
print("INFEASIBLE")
elif problem.status == cp.UNBOUNDED:
print("UNBOUNDED")
else:
print("Solverfehler:", problem.status)
```
### Die drei häufigsten Stolpersteine
1. **DCP-Regeln vor dem Lösen prüfen.** `problem.is_dcp()` und dann die einzelnen
Bedingungen — das nennt die Schuldige, statt einen `DCPError` ohne Ort zu werfen
({ref:anhang:fehlerdiagnose}, C8).
2. **`OPTIMAL_INACCURATE` ist kein Erfolg.** Der Solver hat aufgegeben und meldet das leise.
Diesen Fall immer eigens behandeln.
3. **Der Aufbau kann teurer sein als das Lösen.** Schleifen über Szenarien durch
Vektorausdrücke ersetzen; `Parameter` statt Neuaufbau, wenn sich nur Zahlen ändern.
---
## D6 — Modellierungssprachen: Pyomo und Linopy
**Wofür.** Beide trennen *Modell* von *Solver*: dasselbe Modell läuft ohne Änderung unter
HiGHS, CBC, Gurobi. Pyomo denkt in **Mengen und Indizes** wie eine mathematische
Formulierung; Linopy denkt in **beschrifteten Arrays** und baut Nebenbedingungen als
Matrixoperation statt in Python-Schleifen. **Wofür nicht:** ein Modell mit zehn
Nebenbedingungen — dort ist der Aufwand höher als der Nutzen.
### Pyomo
```python
import pyomo.environ as pyo
modell = pyo.ConcreteModel()
modell.J = pyo.RangeSet(0, 2) # Indexmenge
modell.x = pyo.Var(modell.J, domain=pyo.NonNegativeReals)
# ganzzahlig: domain=pyo.NonNegativeIntegers | binaer: domain=pyo.Binary
ertrag = {0: 10.0, 1: 15.0, 2: 25.0}
modell.ziel = pyo.Objective(expr=sum(ertrag[j] * modell.x[j] for j in modell.J),
sense=pyo.maximize)
modell.montage = pyo.Constraint(
expr=modell.x[0] + modell.x[1] + 2 * modell.x[2] <= 40)
modell.pruefung = pyo.Constraint(
expr=2 * modell.x[0] + 3 * modell.x[1] + modell.x[2] <= 50)
modell.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) # fuer Schattenpreise
ergebnis = pyo.SolverFactory("appsi_highs").solve(modell)
zustand = ergebnis.solver.termination_condition
if zustand == pyo.TerminationCondition.optimal:
print(f"optimal: {pyo.value(modell.ziel):.2f}")
print(f"x = {[round(pyo.value(modell.x[j]), 4) for j in modell.J]}")
print(f"Schattenpreis Montage: {modell.dual[modell.montage]:.4f}")
elif zustand == pyo.TerminationCondition.infeasible:
print("INFEASIBLE")
elif zustand == pyo.TerminationCondition.unbounded:
print("UNBOUNDED")
elif zustand == pyo.TerminationCondition.maxTimeLimit:
print("Zeitlimit")
else:
print("kein Optimum:", zustand)
```
### Linopy
```python
import linopy
import numpy as np
import pandas as pd
import xarray as xr
# Benannte Indizes statt blosser Listen - sonst heissen die Achsen "dim_0"
produkt = pd.Index(["Rahmen", "Gehaeuse", "Deckel"], name="produkt")
ressource = pd.Index(["Montage", "Pruefung"], name="ressource")
modell = linopy.Model()
modell.add_variables(lower=0, coords=[produkt], name="menge")
x = modell.variables["menge"]
# ganzzahlig: integer=True | binaer: binary=True
ertrag = xr.DataArray([10.0, 15.0, 25.0], coords=[produkt])
verbrauch = xr.DataArray([[1.0, 1.0, 2.0], [2.0, 3.0, 1.0]],
coords=[ressource, produkt])
vorrat = xr.DataArray([40.0, 50.0], coords=[ressource])
modell.add_objective((ertrag * x).sum(), sense="max")
# EINE Zeile, so viele Nebenbedingungen wie 'ressource' Eintraege hat:
modell.add_constraints((verbrauch * x).sum("produkt") <= vorrat, name="kapazitaet")
modell.solve(solver_name="highs", output_flag=False)
if modell.termination_condition == "optimal":
print(f"optimal: {modell.objective.value:.2f}")
print(x.solution.to_series().round(4).to_dict())
print("Schattenpreise:",
modell.constraints["kapazitaet"].dual.to_series().round(4).to_dict())
elif modell.termination_condition == "infeasible":
print("INFEASIBLE")
elif modell.termination_condition == "unbounded":
print("UNBOUNDED")
else:
print("kein Optimum:", modell.status, modell.termination_condition)
```
### Die drei häufigsten Stolpersteine
1. **Der Solver ist ein eigenes Programm.** `SolverFactory("appsi_highs")` scheitert, wenn
HiGHS nicht auffindbar ist — die Fehlermeldung nennt dann das Modell, nicht die fehlende
Installation.
2. **Schattenpreise kommen nur auf Anforderung.** Bei Pyomo braucht es
`Suffix(direction=IMPORT)` *vor* dem Lösen; wer ihn vergisst, bekommt einen `KeyError`
statt einer Warnung.
3. **Bei Linopy die Indizes benennen** (`pd.Index(..., name="produkt")`). Ohne Namen heißen
die Achsen `dim_0`, und jede spätere Zuordnung wird zum Ratespiel.
---
## Die gemeinsame Regel
Alle sechs Seiten haben denselben längsten Abschnitt: die **Statusauswertung**. Das ist kein
Zufall. Ein Solveraufruf hat nie zwei Ausgänge, sondern mindestens fünf — optimal, zulässig
ohne Beweis, unlösbar, unbeschränkt, abgebrochen. Code, der nur `if erfolgreich:` prüft,
verwechselt früher oder später „keine Lösung gefunden“ mit „es gibt keine Lösung“, und diese
Verwechslung merkt niemand, bis sie teuer wird.
---
*Weiter mit:* [{ref:anhang:glossar-literatur} — Glossar und Literatur](94_Anhang_Glossar_und_Literatur.md)

View file

@ -0,0 +1,373 @@
# Anhang: Glossar und Literatur {#anhang:glossar-literatur}
---
# Glossar
**A**
* **Almgren-Chriss-Modell**{idx:Almgren-Chriss-Modell} — Standardmodell der optimalen Orderausführung. Es löst den
Zielkonflikt zwischen Marktauswirkung bei schnellem Handeln und Volatilitätsrisiko bei
langsamem Handeln. → {ref:sec:dynamische-programmierung-das-almgren-chriss-problem-optimale-orderausfuehrung}
* **Alternativoptima**{idx:Alternativoptima} — Mehrere Lösungen mit identischem Zielfunktionswert. Tritt auf, wenn
die Zielfunktion parallel zu einer Kante des Polyeders verläuft oder die Matrix nur
semidefinit ist. → {ref:sec:qp-nlp-das-quadratische-programm-qp}
**B**
* **Bellman-Gleichung**{idx:Bellman-Gleichung} — Rekursionsgleichung der dynamischen Programmierung: Der Wert eines
Zustands ist die Summe aus den unmittelbaren Kosten der besten Aktion und dem Wert des
Folgezustands. → {ref:sec:dynamische-programmierung-das-bellmansche-optimalitaetsprinzip}
* **Big-M-Methode**{idx:Big-M-Methode} — Modellierungstrick, der logische Bedingungen über eine hinreichend
große Konstante $M$ an eine Binärvariable koppelt. $M$ sollte **so klein wie möglich**
gewählt werden, da große Werte die Relaxation aufweichen und die Laufzeit verschlechtern.
→ {ref:sec:milp-modellierungstricks-big-m-und-logische-bedingungen}
* **Binärvariable**{idx:Binärvariable} — Entscheidungsvariable mit Wertebereich $\{0,1\}$; sie schaltet
Fixkosten, Kapazitäten oder logische Alternativen an und aus. → {ref:sec:einfuehrung-die-vier-universellen-bausteine-jedes-or}
* **Bipartites Matching**{idx:Bipartites Matching} — Zuordnung zwischen zwei disjunkten Mengen mit maximalem Nutzen
oder minimalen Kosten; klassisch gelöst durch den Ungarischen Algorithmus. → {ref:sec:graphen-bipartites-matching-das-zuordnungsproblem}
* **Branch-and-Bound**{idx:Branch-and-Bound} — Exaktes Verfahren für ganzzahlige Probleme: Der Suchraum wird
rekursiv zerlegt (Branching), Zweige werden verworfen, sobald ihre Relaxation schlechter
ist als die beste bekannte Lösung (Bounding). → {ref:sec:milp-branch-and-bound}
* **Budgeted Uncertainty**{idx:Budgeted Uncertainty} — Robustheitsansatz nach Bertsimas/Sim: Höchstens $\Gamma$ von
$n$ Parametern nehmen gleichzeitig ihren ungünstigsten Wert an. → {ref:sec:unsicherheit-robuste-optimierung-gegen-den-worst-case}
**C**
* **Calmar Ratio**{idx:Calmar Ratio} — Jahresrendite geteilt durch den Betrag des maximalen Drawdowns.
Ergänzt die Sharpe Ratio um die Verlustperspektive. → {ref:sec:handelsmaschine-die-engine}
* **Conditional Value at Risk (CVaR)**{idx:Conditional Value at Risk (CVaR)} — Erwarteter Verlust in den schlechtesten Szenarien
jenseits des VaR. Kohärent, subadditiv und nach Rockafellar/Uryasev exakt als lineares
Programm formulierbar. → {ref:sec:cvar-value-at-risk-und-conditional-value}
* **Conflict Learning**{idx:CDCL!Conflict Learning} — Technik moderner SAT-Solver, aus jedem Widerspruch eine
Sperrklausel abzuleiten, damit dieselbe Sackgasse nicht erneut betreten wird. → {ref:sec:cpsat-ein-anderes-denkmodell}
* **Constraint Programming (CP)**{idx:Constraint Programming (CP)} — Paradigma, das nicht über Zielfunktionsgradienten,
sondern über logische Verträglichkeit sucht: Constraints schränken Wertebereiche ein, bis
eine zulässige Belegung gefunden ist. → {ref:sec:cpsat-ein-anderes-denkmodell}
* **Constraint Propagation**{idx:Constraint Propagation} — Kernmechanismus von CP-Solvern: Aus einer Zuweisung werden
unmögliche Werte anderer Variablen sofort entfernt, wodurch der Suchbaum schrumpft.
→ {ref:sec:cpsat-ein-anderes-denkmodell}
* **CP-SAT**{idx:CP-SAT} — Constraint-Programming-Solver von OR-Tools, der ein Modell in boolesche
Erfüllbarkeit übersetzt und mit Propagation, Conflict Learning und paralleler Suche löst.
→ {ref:sec:cpsat-ein-anderes-denkmodell}
* **CVXPY**{idx:CVXPY} — Modellierungssprache für konvexe Optimierung: schreibt das Problem in
mathematiknaher Notation, prüft die Konvexität und reicht es an einen Solver weiter.
→ {ref:sec:oekosystem-die-werkzeuge-im-vergleich}
**D**
* **Dualitätstheorie**{idx:Dualitätstheorie} — Jedem Optimierungsproblem (Primal) steht ein Dualproblem gegenüber.
Der starke Dualitätssatz besagt, dass beide im Optimum denselben Zielfunktionswert
besitzen. → {ref:sec:lp-dualitaet-und-schattenpreise}
* **Dynamische Programmierung (DP)**{idx:Dynamische Programmierung} — Lösungsprinzip für mehrstufige Entscheidungen: Das
Problem wird in Zustände und Stufen zerlegt und rückwärts gelöst. → {ref:sec:dynamische-programmierung-das-bellmansche-optimalitaetsprinzip}
**E**
* **Efficient Frontier**{idx:Effizienzgrenze} — Kurve aller Portfolios, die zu gegebenem Risiko die höchste
erwartete Rendite liefern. → {ref:sec:markowitz-das-mean-variance-modell}
* **Entscheidungsvariable**{idx:Entscheidungsvariable} — Die vom Solver frei wählbare Größe eines Modells. Ihr
Wertebereich (kontinuierlich, ganzzahlig, binär) bestimmt die Problemklasse. → {ref:sec:einfuehrung-die-vier-universellen-bausteine-jedes-or}
* **Error-Maximizer-Effekt**{idx:Error-Maximizer-Effekt} — Eigenschaft der Mean-Variance-Optimierung, Schätzfehler zu
verstärken statt auszugleichen: Sie sucht genau die Richtungen, deren Varianz am stärksten
unterschätzt wurde. → {ref:sec:finanzdaten-das-schaetzfehler-problem}
* **EVPI (Expected Value of Perfect Information)**{idx:EVPI} — Differenz zwischen den Kosten unter
Unsicherheit und den Kosten bei perfektem Wissen; Obergrenze für den Wert jeder
Prognoseverbesserung. → {ref:sec:unsicherheit-zweistufige-stochastische-programmierung}
* **Explainable OR**{idx:Explainable OR} — Nachvollziehbarmachung von Solver-Ergebnissen über Schattenpreise,
aktive Restriktionen und Kostenzerlegung. → {ref:sec:praxisfallen-die-fuenf-typischen-praxisfallen}
**F**
* **Fat Tails**{idx:Fat Tails} — Verteilungsränder, die dicker auslaufen als bei der Normalverteilung:
Extremereignisse sind deutlich häufiger, als das Normalmodell vorhersagt. → {ref:sec:cvar-die-zwei-schwaechen-des-markowitz-modells}
* **Fluch der Dimensionalität**{idx:Fluch der Dimensionalität} — Exponentielles Wachstum des Zustandsraums mit jeder
zusätzlichen Zustandsdimension; begrenzt die dynamische Programmierung. → {ref:sec:dynamische-programmierung-der-fluch-der-dimensionalitaet}
* **Fluch des Durchschnitts** (*Flaw of Averages*) — Systematischer Fehler beim Planen mit
Erwartungswerten statt mit Verteilungen; folgt aus der Jensenschen Ungleichung.
→ {ref:sec:unsicherheit-der-fluch-des-durchschnitts}
* **Flusserhaltung**{idx:Flusserhaltung} — Bedingung, dass an jedem Knoten Abfluss minus Zufluss dem Saldo des
Knotens entspricht — das Kirchhoff-Gesetz der Netzwerkoptimierung. → {ref:sec:graphen-graphen-als-modellsprache}
* **Fundamentalsatz der linearen Optimierung**{idx:Fundamentalsatz der linearen Optimierung} — Das Optimum eines lösbaren LP wird stets in
mindestens einer Ecke des zulässigen Polyeders angenommen. → {ref:sec:fundament-der-zulaessige-loesungsraum-und-das-polyeder}
**G**
* **Gemischt-ganzzahlige Optimierung (MILP)**{idx:MILP} — Lineares Modell mit mindestens einer
ganzzahligen oder binären Variablen; NP-schwer. → {ref:sec:milp-warum-runden-fundamental-scheitert}
* **Global Minimum Variance Portfolio (GMV)**{idx:GMV (Global Minimum Variance)} — Portfolio kleinstmöglicher Varianz. Es
benötigt **keine Renditeprognose** und ist deshalb robuster gegen Schätzfehler.
→ {ref:sec:markowitz-das-mean-variance-modell}
* **Globale Constraints**{idx:Globale Constraints} — Vorgefertigte Bausteine wie `AllDifferent`, `NoOverlap` oder
`Cumulative`, die häufige Strukturen kompakt ausdrücken und spezialisierte Propagatoren
besitzen. → {ref:sec:cpsat-globale-constraints-die-bausteine-von-cp}
**H**
* **HiGHS**{idx:HiGHS} — Offener Hochleistungs-Solver für LP, MILP und QP; Backend von
`scipy.optimize.linprog` und CVXPY, direkt ansprechbar über `highspy`. → {ref:sec:oekosystem-die-werkzeuge-im-vergleich}
**I**
* **Infeasibility**{idx:Infeasibility} — Zustand eines Modells ohne zulässige Lösung. In der Praxis über
Schlupfvariablen mit hohem Strafgewicht aufzufangen. → {ref:sec:praxisfallen-die-fuenf-typischen-praxisfallen}
* **Intervallvariable**{idx:Intervallvariable} — Variable mit Start, Dauer und Ende, die in CP-SAT eine Aktivität
beschreibt und Überschneidungsverbote auf Ressourcen ermöglicht. → **{ref:sec:cpsat-globale-constraints-die-bausteine-von-cp}**
**J**
* **Jensensche Ungleichung**{idx:Jensensche Ungleichung} — Für konvexe Funktionen gilt
$\mathbb{E}[f(X)] \ge f(\mathbb{E}[X])$; die formale Grundlage des Fluchs des
Durchschnitts. → {ref:sec:unsicherheit-der-fluch-des-durchschnitts}
**K**
* **Kanonische Standardform**{idx:Kanonische Standardform} — Einheitliche Matrixschreibweise eines LP als
$\min \mathbf{c}^\top\mathbf{x}$ u. d. N. $\mathbf{A}\mathbf{x} \le \mathbf{b}$,
$\mathbf{x} \ge 0$. → {ref:sec:fundament-warum-ueberhaupt-vektoren-und-matrizen}
* **Kardinalitätsbeschränkung**{idx:Kardinalitätsbeschränkung} — Obergrenze für die Anzahl gleichzeitig aktiver
Entscheidungen; über die Summe der zugehörigen Binärvariablen formuliert. → {ref:sec:milp-modellierungstricks-big-m-und-logische-bedingungen}
* **Karush-Kuhn-Tucker-Bedingungen (KKT)**{idx:KKT-Bedingungen} — Notwendige Optimalitätsbedingungen
restringierter Probleme: Stationarität, primale und duale Zulässigkeit sowie
komplementärer Schlupf. Bei konvexen Problemen zugleich hinreichend. → {ref:sec:qp-nlp-die-karush-kuhn-tucker-bedingungen}
* **Kohärentes Risikomaß**{idx:Kohärentes Risikomaß} — Risikomaß mit den Eigenschaften Monotonie, Subadditivität,
positive Homogenität und Translationsinvarianz. Der CVaR erfüllt sie, der VaR nicht.
→ {ref:sec:cvar-value-at-risk-und-conditional-value}
* **Kombinatorische Explosion**{idx:Kombinatorische Explosion} — Überproportionales Wachstum des Lösungsraums mit der
Problemgröße. → {ref:sec:einfuehrung-warum-ausprobieren-scheitert-mit-eigener-rechnung}
* **Komplementärer Schlupf**{idx:Komplementärer Schlupf} — Bedingung $s_i \cdot y_i = 0$: Eine Nebenbedingung ist
entweder aktiv oder ihr Multiplikator verschwindet. → {ref:sec:lp-dualitaet-und-schattenpreise}
* **Konditionszahl**{idx:Konditionszahl} — Verhältnis von größtem zu kleinstem Eigenwert; misst, wie stark sich
kleine Datenänderungen auf die Inverse auswirken. → {ref:sec:finanzdaten-praxis-datenpipeline-mit-korrekter-spaltenreihenfolge}
* **Konvexität**{idx:Konvexität} — Eigenschaft, bei der jede Verbindungsstrecke zweier Punkte innerhalb der
Menge bzw. unterhalb des Funktionsgraphen liegt. Bei konvexen Problemen ist jedes lokale
Optimum global. → {ref:sec:fundament-konvexitaet-die-grenze-zwischen-leicht-und}
* **Korn-Transformation**{idx:Korn-Transformation} — Umformung der nicht-konvexen Sharpe-Ratio-Maximierung in ein
konvexes QP durch Homogenisierung. → {ref:sec:markowitz-das-mean-variance-modell}
* **Kovarianzmatrix**{idx:Kovarianzmatrix} — Matrix der paarweisen Kovarianzen. Steuert im Markowitz-Modell den
Diversifikationseffekt; bei vielen Titeln und wenigen Beobachtungen notorisch schlecht
konditioniert. → {ref:sec:finanzdaten-das-schaetzfehler-problem}
**L**
* **Lagrange-Multiplikator**{idx:Lagrange-Multiplikator} — Gewicht, mit dem eine Nebenbedingung in die Lagrange-Funktion
eingeht; sein Optimalwert entspricht dem Schattenpreis. → {ref:sec:qp-nlp-die-karush-kuhn-tucker-bedingungen}
* **Ledoit-Wolf-Shrinkage**{idx:Ledoit-Wolf-Shrinkage} — Schrumpfung der Stichprobenkovarianz in Richtung eines
strukturierten Ziels; der optimale Faktor wird analytisch bestimmt. `scikit-learn`
verwendet die skalierte Einheitsmatrix als Ziel. → {ref:sec:finanzdaten-ledoit-wolf-shrinkage}
* **Lineare Programmierung (LP)**{idx:Lineare Programmierung (LP)} — Optimierung einer linearen Zielfunktion unter linearen
Nebenbedingungen mit kontinuierlichen Variablen. → {ref:sec:lp-die-standardform-und-schlupfvariablen}
* **Logarithmische Rendite**{idx:Logarithmische Rendite} — Stetige Rendite als Logarithmus des Preisverhältnisses;
**über die Zeit** additiv. → {ref:sec:finanzdaten-diskrete-und-logarithmische-renditen}
* **Lookahead-Bias**{idx:Lookahead-Bias} — Fehler, bei dem Informationen einfließen, die zum
Entscheidungszeitpunkt nicht vorlagen. → {ref:sec:handelsmaschine-die-fuenf-selbsttaeuschungen-des-backtestens}
**M**
* **Market Impact**{idx:Marktauswirkung} — Preisverschlechterung, die eine eigene Order durch ihr Volumen
auslöst; wächst überproportional mit der Handelsgeschwindigkeit. → {ref:sec:dynamische-programmierung-das-almgren-chriss-problem-optimale-orderausfuehrung}
* **Maximum Drawdown**{idx:Maximum Drawdown} — Größter prozentualer Rückgang vom bisherigen Höchststand.
→ {ref:sec:handelsmaschine-die-engine}
* **Min-Cost-Flow-Problem (MCNFP)**{idx:Min-Cost-Flow} — Kostengünstigster Transport durch ein Netzwerk unter
Kapazitäts- und Flusserhaltungsbedingungen; total unimodular, daher ganzzahlige
LP-Lösungen. → {ref:sec:graphen-graphen-als-modellsprache}
* **MIP-Gap**{idx:MIP-Gap} — Relativer Abstand zwischen bester gefundener Lösung und bester bekannter
Schranke. → {ref:sec:praxisfallen-die-fuenf-typischen-praxisfallen}
* **Moderne Portfoliotheorie (Markowitz)**{idx:Markowitz-Modell (Mean-Variance)} — Rahmenwerk, das ein Portfolio über das
Zusammenspiel von erwarteter Rendite und Kovarianz bewertet. → {ref:sec:markowitz-das-mean-variance-modell}
* **Monte-Carlo-Simulation**{idx:Monte-Carlo-Simulation} — Erzeugung vieler Zufallsszenarien, um Kennzahlen empirisch zu
schätzen. **Bewertet**, optimiert aber nicht. → {ref:sec:unsicherheit-monte-carlo-simulation}
* **MTZ-Formulierung**{idx:MTZ-Formulierung} — Miller-Tucker-Zemlin-Bedingungen, die über Rangvariablen
Kurzzyklen ausschließen. → {ref:sec:graphen-das-vehicle-routing-problem-mit-zeitfenstern}
**N**
* **Nebenbedingung (Constraint)**{idx:Nebenbedingung} — Gleichung oder Ungleichung, die zulässige von
unzulässigen Lösungen trennt. Harte müssen erfüllt sein, weiche werden über Strafterme
lediglich bestraft. → {ref:sec:einfuehrung-die-vier-universellen-bausteine-jedes-or}
* **Nichtlineare Programmierung (NLP)**{idx:Nichtlineare Programmierung (NLP)} — Problemklasse mit nichtlinearer Ziel- oder
Nebenbedingungsfunktion. Ohne Konvexität liefern Verfahren wie SLSQP nur lokale Optima.
→ {ref:sec:qp-nlp-nichtlineare-optimierung-mit-scipy-optimize-minimize}
**O**
* **Operations Research (OR)**{idx:Operations Research} — Disziplin, die reale Entscheidungsprobleme in mathematische
Modelle überführt und mit exakten oder heuristischen Algorithmen löst. → {ref:sec:einfuehrung-was-ist-operations-research-wirklich}
* **Optimalitätsprinzip**{idx:Optimalitätsprinzip} — Grundsatz von Bellman: Jede Teilpolitik einer optimalen Politik
ist ihrerseits optimal für den erreichten Zustand. → {ref:sec:dynamische-programmierung-das-bellmansche-optimalitaetsprinzip}
**P**
* **Pivotisierung**{idx:Pivotisierung} — Basiswechsel im Simplex-Verfahren. → {ref:sec:lp-der-simplex-algorithmus-schritt-fuer-schritt}
* **Polyeder**{idx:Polyeder} — Schnittmenge endlich vieler Halbräume; die geometrische Gestalt des
zulässigen Bereichs eines LP. → {ref:sec:fundament-der-zulaessige-loesungsraum-und-das-polyeder}
* **Positiv (semi-)definit**{idx:Positiv (semi-)definit} — Eigenschaft einer symmetrischen Matrix, deren Eigenwerte alle
$> 0$ (definit) bzw. $\ge 0$ (semidefinit) sind. Definit ⟹ streng konvex, eindeutige
Lösung; semidefinit ⟹ konvex, evtl. mehrere Lösungen. → {ref:sec:qp-nlp-das-quadratische-programm-qp}
* **Präskriptive Analytik**{idx:Präskriptive Analytik} — Analysestufe, die vorschreibt, welche Handlung unter den
gegebenen Bedingungen die beste ist. → {ref:sec:einfuehrung-was-ist-operations-research-wirklich}
**Q**
* **Quadratische Programmierung (QP)**{idx:Quadratische Programmierung (QP)} — Optimierung einer quadratischen Zielfunktion unter
linearen Nebenbedingungen. → {ref:sec:qp-nlp-das-quadratische-programm-qp}
**R**
* **Rebalancing**{idx:Rebalancing} — Periodische Rückführung des Portfolios auf die Zielgewichte.
**{ref:sec:handelsmaschine-rebalancing-termine-richtig-bestimmen}**
* **Regime-Shift**{idx:Regime-Shift} — Strukturbruch in den Daten, nach dem historisch geschätzte Momente ihre
Gültigkeit verlieren. → {ref:sec:praxisfallen-die-fuenf-typischen-praxisfallen}
* **Relaxation**{idx:Relaxation} — Absichtliches Weglassen einschränkender Forderungen — typischerweise der
Ganzzahligkeit —, um eine schnell berechenbare Schranke zu gewinnen. → {ref:sec:milp-branch-and-bound}
* **Robuste Optimierung**{idx:Robuste Optimierung} — Auslegung auf den ungünstigsten Fall innerhalb einer
Unsicherheitsmenge; verlangt keine Wahrscheinlichkeiten. → {ref:sec:unsicherheit-robuste-optimierung-gegen-den-worst-case}
* **Rockafellar-Uryasev-Theorem**{idx:Rockafellar-Uryasev-Theorem} — Ergebnis, das die CVaR-Minimierung in eine konvexe
Hilfsfunktion überführt und die Optimierung des Tail-Risikos mit linearen Solvern erlaubt.
→ {ref:sec:cvar-value-at-risk-und-conditional-value}
**S**
* **Schattenpreis**{idx:Schattenpreis} — Optimalwert einer Dualvariablen: Um wie viel ändert sich der Zielwert,
wenn die zugehörige Ressource um eine Einheit erweitert wird. → {ref:sec:lp-dualitaet-und-schattenpreise}
* **Schätzfehler**{idx:Schätzfehler} — Abweichung geschätzter Momente von den wahren Werten. → {ref:sec:finanzdaten-das-schaetzfehler-problem}
* **Schlupfvariable**{idx:Schlupfvariable} — Nichtnegative Hilfsvariable, die eine Ungleichung in eine Gleichung
überführt; ihr Wert zeigt die ungenutzte Reserve. → {ref:sec:lp-die-standardform-und-schlupfvariablen}
* **Schnittebenenverfahren (Cutting Planes)**{idx:Schnittebenen} — Zusätzliche gültige Ungleichungen, die
gebrochene LP-Lösungen abschneiden, ohne ganzzahlige Punkte zu verlieren. → {ref:sec:milp-branch-and-bound}
* **Sensitivitätsanalyse**{idx:Sensitivitätsanalyse} — Untersuchung, in welchem Bereich sich Koeffizienten und
Kapazitäten ändern dürfen, ohne die Struktur der Optimallösung zu verändern. → {ref:sec:lp-praxisfall-sensitivitaetsanalyse-mit-korrekten-schattenpreisen}
* **Sharpe Ratio**{idx:Sharpe-Ratio} — Überrendite über den risikofreien Zins je Einheit Volatilität.
→ {ref:sec:markowitz-das-mean-variance-modell}
* **Simplex-Algorithmus**{idx:Simplex-Algorithmus} — Verfahren von Dantzig, das von Ecke zu Ecke wandert und dabei
den Zielfunktionswert monoton verbessert. → {ref:sec:lp-der-simplex-algorithmus-schritt-fuer-schritt}
* **SLSQP**{idx:SLSQP} — *Sequential Least Squares Programming*; gradientenbasiertes Verfahren in
`scipy.optimize.minimize` für nichtlineare Probleme. → {ref:sec:qp-nlp-nichtlineare-optimierung-mit-scipy-optimize-minimize}
* **Subadditivität**{idx:Subadditivität} — Eigenschaft $\rho(A+B) \le \rho(A)+\rho(B)$: Diversifikation darf das
Risiko nicht erhöhen. Der VaR verletzt sie. → {ref:sec:cvar-value-at-risk-und-conditional-value}
* **Survivorship-Bias**{idx:Survivorship-Bias} — Verzerrung durch Auswahl nur der heute noch existierenden Titel.
→ {ref:sec:handelsmaschine-die-fuenf-selbsttaeuschungen-des-backtestens}
* **Symmetriebrechung**{idx:Symmetriebrechung} — Zusätzliche Ordnungsbedingungen, die verhindern, dass der Solver
gleichwertige Vertauschungen mehrfach durchsucht. → {ref:anhang:modellierungsmuster}, Muster B20
**T**
* **Totale Unimodularität**{idx:Totale Unimodularität} — Eigenschaft einer Matrix, bei der jede quadratische
Teilmatrix die Determinante $0$, $+1$ oder $-1$ hat. Folge: Alle Ecken des Polyeders sind
ganzzahlig — Ganzzahligkeit muss nicht gefordert werden. → {ref:sec:graphen-bipartites-matching-das-zuordnungsproblem}
* **Transaktionskosten**{idx:Transaktionskosten} — Beim Umschichten anfallende Kosten aus Gebühren, Spread und
Market Impact; über eine $L_1$-Strafe modellierbar. → {ref:sec:cvar-transaktionskosten-ueber-die-l-1-norm}
* **Turnover**{idx:Turnover (Umschlag)} — Summe der absoluten Gewichtsänderungen einer Umschichtung. → {ref:sec:cvar-transaktionskosten-ueber-die-l-1-norm}
**U**
* **Unsicherheitsmenge**{idx:Unsicherheitsmenge} — Vorab definierter Bereich möglicher Parameterwerte, gegen dessen
ungünstigstes Element eine robuste Lösung abgesichert wird. → {ref:sec:unsicherheit-robuste-optimierung-gegen-den-worst-case}
**V**
* **Value at Risk (VaR)**{idx:Value at Risk (VaR)} — Verlustschwelle, die mit vorgegebener Wahrscheinlichkeit nicht
überschritten wird. Sagt nichts über die Verlusthöhe dahinter und ist nicht subadditiv.
→ {ref:sec:cvar-value-at-risk-und-conditional-value}
* **Vehicle Routing Problem (VRP)**{idx:Vehicle Routing Problem (VRP)} — Verallgemeinerung des TSP auf mehrere Fahrzeuge mit
Depot, Kapazitäten und — in der Variante VRPTW — Zeitfenstern. → {ref:sec:graphen-das-vehicle-routing-problem-mit-zeitfenstern}
* **Volatilität**{idx:Volatilität} — Standardabweichung der Renditen, üblicherweise auf ein Jahr skaliert.
**{ref:sec:finanzdaten-diskrete-und-logarithmische-renditen}**
**W**
* **Walk-Forward-Backtest**{idx:Walk-Forward-Backtest} — Rollierende Auswertung, bei der Parameter stets nur auf
Vergangenheitsdaten geschätzt und auf dem folgenden Zeitraum getestet werden.
**{ref:sec:handelsmaschine-die-engine}**
* **Wurzel-Zeit-Regel**{idx:Wurzel-Zeit-Regel} — Skalierung der Volatilität mit $\sqrt{T}$. Gilt streng nur für
Standardabweichungen unabhängiger Größen ohne Drift — für VaR/CVaR nur als grobe Näherung.
→ {ref:sec:cvar-implementierung-cvar-portfolio-mit-reibung}
**Z**
* **Zielfunktion**{idx:Zielfunktion} — Der zu minimierende oder maximierende Ausdruck, der die
Entscheidungsvariablen zu einer einzigen Bewertungszahl verdichtet. → {ref:sec:einfuehrung-die-vier-universellen-bausteine-jedes-or}
* **Zulässiger Bereich (Feasible Region)**{idx:Zulässiger Bereich} — Menge aller Punkte, die sämtliche
Nebenbedingungen gleichzeitig erfüllen. → {ref:sec:fundament-der-zulaessige-loesungsraum-und-das-polyeder}
---
# Literaturverzeichnis
## Mathematische Optimierung — Grundlagen
* **Bertsimas, D. & Tsitsiklis, J. N. (1997):** *Introduction to Linear Optimization.*
Athena Scientific, Belmont.
*Standardwerk zur geometrischen und algebraischen Theorie des Simplex-Verfahrens,
Dualität, Sensitivitätsanalyse und Netzwerkflüssen.*
* **Dantzig, G. B. (1963):** *Linear Programming and Extensions.* Princeton University Press.
*Das historische Originalwerk des Erfinders des Simplex-Algorithmus.*
* **Wolsey, L. A. (2020):** *Integer Programming* (2. Aufl.). Wiley, Hoboken.
*Branch-and-Bound, Branch-and-Cut, Polyedertheorie, Formulierungsstärke.*
* **Hillier, F. S. & Lieberman, G. J. (2021):** *Introduction to Operations Research*
(11. Aufl.). McGraw-Hill.
*Breite Einführung inkl. Warteschlangen, DP und Entscheidungsmodellen.*
## Konvexe, nichtlineare und robuste Optimierung
* **Boyd, S. & Vandenberghe, L. (2004):** *Convex Optimization.* Cambridge University Press.
*Referenzwerk für konvexe Mengen, KKT, QP und SDP; theoretische Basis von CVXPY.
Frei verfügbar unter stanford.edu/~boyd/cvxbook/.*
* **Ben-Tal, A., El Ghaoui, L. & Nemirovski, A. (2009):** *Robust Optimization.*
Princeton University Press.
* **Bertsimas, D. & Sim, M. (2004):** *The Price of Robustness.* In: *Operations Research*
52(1), S. 3553. *Quelle des Budgeted-Uncertainty-Ansatzes.*
* **Bellman, R. (1957):** *Dynamic Programming.* Princeton University Press.
## Constraint Programming und Scheduling
* **Rossi, F., van Beek, P. & Walsh, T. (2006):** *Handbook of Constraint Programming.*
Elsevier.
* **Perron, L. & Furnon, V.:** *OR-Tools CP-SAT Solver Documentation.*
[developers.google.com/optimization/cp](https://developers.google.com/optimization/cp)
## Quantitative Finanzmathematik
* **Markowitz, H. (1952):** *Portfolio Selection.* In: *The Journal of Finance* 7(1),
S. 7791.
* **Rockafellar, R. T. & Uryasev, S. (2000):** *Optimization of Conditional Value-at-Risk.*
In: *Journal of Risk* 2(3), S. 2142.
* **Ledoit, O. & Wolf, M. (2003):** *Improved estimation of the covariance matrix of stock
returns with an application to portfolio selection.* In: *Journal of Empirical Finance*
10(5), S. 603621. *Quelle des Konstant-Korrelations-Ziels.*
* **Ledoit, O. & Wolf, M. (2004):** *A well-conditioned estimator for large-dimensional
covariance matrices.* In: *Journal of Multivariate Analysis* 88(2), S. 365411.
*Quelle des von `scikit-learn` verwendeten Ziels (skalierte Einheitsmatrix).*
* **Almgren, R. & Chriss, N. (2000):** *Optimal Execution of Portfolio Transactions.*
In: *Journal of Risk* 3(2), S. 539.
* **DeMiguel, V., Garlappi, L. & Uppal, R. (2009):** *Optimal Versus Naive Diversification.*
In: *Review of Financial Studies* 22(5), S. 19151953.
*Die Studie, die 1/N als ernstzunehmenden Vergleichsmaßstab etablierte.*
* **López de Prado, M. (2018):** *Advances in Financial Machine Learning.* Wiley.
*Backtest-Overfitting, Deflated Sharpe Ratio, Denoising.*
* **Cornuéjols, G., Peña, J. & Tütüncü, R. (2018):** *Optimization Methods in Finance*
(2. Aufl.). Cambridge University Press.
## Software und Dokumentation
* **Google OR-Tools** — [developers.google.com/optimization](https://developers.google.com/optimization)
· Quellcode: [github.com/google/or-tools](https://github.com/google/or-tools)
* **Diamond, S. & Boyd, S. (2016):** *CVXPY: A Python-embedded modeling language for convex
optimization.* In: *Journal of Machine Learning Research* 17(83), S. 15.
· [www.cvxpy.org](https://www.cvxpy.org)
* **Huangfu, Q. & Hall, J. A. J. (2018):** *Parallelizing the dual revised simplex method.*
In: *Mathematical Programming Computation* 10(1), S. 119142. · [highs.dev](https://highs.dev)
* **SciPy** — [docs.scipy.org/doc/scipy/reference/optimize.html](https://docs.scipy.org/doc/scipy/reference/optimize.html)
* **scikit-learn, Covariance Estimation** — [scikit-learn.org/stable/modules/covariance.html](https://scikit-learn.org/stable/modules/covariance.html)
## Verbände und Normen
* **INFORMS** — [www.informs.org](https://www.informs.org)
* **GOR (Gesellschaft für Operations Research e. V.)** — deutschsprachiger Fachverband.
* **Basel Committee on Banking Supervision:** *Minimum capital requirements for market risk*
(Basel III, FRTB) — Grundlage der Umstellung von VaR auf Expected Shortfall.
* **Europäische Union:** MiFID II / ESMA-Leitlinien zum algorithmischen Handel.
---
*Zurück zum* [Wegweiser](README.md)

View file

@ -0,0 +1,137 @@
# Optimierte Entscheidungsfindung mit Python
**Wegweiser durch diese Ausgabe.** Dieses Kompendium verfolgt einen klaren Anspruch:
**Kursteilnehmerinnen und Kursteilnehmer sollen den Text nicht nur lesen, sondern damit
arbeiten, üben und eigene Anwendungen bauen können.**
---
## Was dieses Kompendium bietet
| Merkmal | Umfang | Warum |
| --- | --- | --- |
| **Formel-Lesehilfen** | bei jeder nicht-trivialen Formel | Nicht jede/r liest Formelsprache flüssig |
| **Handrechnungen** | Simplex, Big-M, Dualität, Bellman, CVaR | Verstehen kommt vor Programmieren |
| **Zeile-für-Zeile-Code-Erklärungen** | alle Kernprogramme | Code lesen lernen, nicht nur ausführen |
| **Erwartete Programmausgaben** | alle Programme | Selbstkontrolle: „Läuft es bei mir richtig?“ |
| **~130 Übungsaufgaben mit Lösungen** | jedes Kapitel | Aktives Üben statt passives Lesen |
| **Selbsttests** | jedes Kapitel | Schnelle Wissenskontrolle |
| **Codebeispiele durchgängig lauffähig** | u. a. Rucksack, Zuordnung, Job-Shop, Monte-Carlo, robuste Optimierung, Min-Cost-Flow | Theorie und Code gehören zusammen |
| **Notations- und Abkürzungsverzeichnis** | [`01_Notation_und_Abkuerzungen.md`](01_Notation_und_Abkuerzungen.md) | Jede Abkürzung genau einmal sauber eingeführt |
| **Projektwerkstatt** | [`59_Projektwerkstatt.md`](59_Projektwerkstatt.md) | 8 vollständige Projektaufträge für eigene Anwendungen |
| **Modellierungsmuster-Katalog** | [`91_Anhang_Modellierungsmuster.md`](91_Anhang_Modellierungsmuster.md) | Nachschlagewerk: „Wie modelliere ich X?“ |
| **Fehlerdiagnose-Handbuch** | [`92_Anhang_Fehlerdiagnose.md`](92_Anhang_Fehlerdiagnose.md) | Was tun, wenn der Solver `INFEASIBLE` meldet? |
---
## Aufbau der Dateien
Der Text ist auf mehrere Dateien verteilt, damit er handhabbar bleibt. Die Nummern-Präfixe
legen die Lesereihenfolge fest.
### Vorspann
| Datei | Inhalt |
| --- | --- |
| [`00_Vorwort_und_Lesehilfe.md`](00_Vorwort_und_Lesehilfe.md) | Vorwort, Lernpfade, Verzeichnis der Beispielprogramme, Setup, wie die Lernelemente funktionieren |
| [`01_Notation_und_Abkuerzungen.md`](01_Notation_und_Abkuerzungen.md) | Alle Symbole und Abkürzungen, ausgeschrieben und erklärt |
### Teil I — Grundlagen
| Datei | Kapitel |
| --- | --- |
| [`10_Einfuehrung_OR.md`](10_Einfuehrung_OR.md) | 1 — Einführung in Operations Research |
| [`11_Mathematisches_Fundament.md`](11_Mathematisches_Fundament.md) | 2 — Vektoren, Matrizen, Konvexität |
| [`12_Python_Oekosystem.md`](12_Python_Oekosystem.md) | 3 — Das Python-Ökosystem für OR |
### Teil II — Deterministische Kernverfahren
| Datei | Kapitel |
| --- | --- |
| [`20_Lineare_Programmierung.md`](20_Lineare_Programmierung.md) | 4 — Lineare Programmierung, Simplex, Dualität |
| [`21_MILP.md`](21_MILP.md) | 5 — Gemischt-ganzzahlige Optimierung |
| [`22_CP_SAT.md`](22_CP_SAT.md) | 6 — Constraint Programming mit CP-SAT |
| [`23_Graphen_Fluesse_Touren.md`](23_Graphen_Fluesse_Touren.md) | 7 — Graphen, Flüsse, Tourenplanung |
### Teil III — Nichtlinearität, Unsicherheit, Dynamik
| Datei | Kapitel |
| --- | --- |
| [`30_QP_und_NLP.md`](30_QP_und_NLP.md) | 8 — Quadratische und nichtlineare Optimierung, KKT |
| [`31_Unsicherheit.md`](31_Unsicherheit.md) | 9 — Monte-Carlo, stochastische und robuste Optimierung |
| [`32_Dynamische_Programmierung.md`](32_Dynamische_Programmierung.md) | 10 — Dynamische Programmierung, Bellman |
### Teil IV — Operations Research an den Finanzmärkten
| Datei | Kapitel |
| --- | --- |
| [`40_Finanzdaten.md`](40_Finanzdaten.md) | 11 — Finanzdaten, Kovarianz, Shrinkage |
| [`41_Markowitz.md`](41_Markowitz.md) | 12 — Markowitz-Portfoliotheorie |
| [`42_CVaR.md`](42_CVaR.md) | 13 — Tail-Risiko, CVaR, Transaktionskosten |
| [`43_Handelsmaschine.md`](43_Handelsmaschine.md) | 14 — Die vollständige Handelsmaschine |
### Teil V — Praxis
| Datei | Kapitel |
| --- | --- |
| [`50_Praxis.md`](50_Praxis.md) | 15 — Praxisfallen und produktiver Einsatz |
| [`59_Projektwerkstatt.md`](59_Projektwerkstatt.md) | Projektwerkstatt: 8 eigene Anwendungen |
### Anhang
| Datei | Inhalt |
| --- | --- |
| [`90_Anhang_Loesungen.md`](90_Anhang_Loesungen.md) | Lösungen zu **allen** Übungsaufgaben und Selbsttests |
| [`91_Anhang_Modellierungsmuster.md`](91_Anhang_Modellierungsmuster.md) | Musterkatalog: 24 wiederkehrende Modellierungstricks |
| [`92_Anhang_Fehlerdiagnose.md`](92_Anhang_Fehlerdiagnose.md) | Diagnose-Handbuch für Solver-Probleme |
| [`94_Anhang_Glossar_und_Literatur.md`](94_Anhang_Glossar_und_Literatur.md) | Glossar, Literatur |
---
## Eine Einzeldatei erzeugen
Wer lieber ein einziges Dokument möchte (z. B. zum Drucken oder für die PDF-Erzeugung),
baut es aus den Einzeldateien:
```bash
cd OR_mit_Python/Version_04 # Wurzel der Version 04
python3 Operations_Research_mit_Python_Version_04/build_version_04.py
```
Das Skript erzeugt `Operations_Research_mit_Python_Version_04.md` im übergeordneten
Verzeichnis — in derselben Reihenfolge wie oben, mit Titelblock, automatisch gesetztem
Inhaltsverzeichnis-Platzhalter und entfernten unnötigen Zeilenumbrüchen innerhalb von
Absätzen. Optionen:
```bash
python3 .../build_version_03.py --pdf # zusätzlich PDF über pandoc + xelatex
python3 .../build_version_03.py --html # zusätzlich Single-Page-HTML
python3 .../build_version_03.py --check # nur Konsistenzprüfung, schreibt nichts
```
---
## Empfohlene Reihenfolge für Kursteilnehmer
**Vollständiger Lehrgang (empfohlen, ca. 100140 Stunden):**
Vorwort → Notation → alle Kapitel der Reihe nach → Projektwerkstatt.
Jedes Kapitel endet mit Übungsaufgaben — bearbeiten Sie diese, **bevor** Sie weiterlesen.
**Schnellpfad Planung & Disposition (ca. 25 Stunden):**
Einführung, Fundament, Vom Wunsch zum Modell, LP, MILP, CP-SAT → Projekt P1 oder P2.
**Schnellpfad Logistik (ca. 25 Stunden):**
Einführung, Fundament, LP, MILP, Graphen → Projekt P3.
**Schnellpfad Quantitative Finance (ca. 30 Stunden):**
Einführung, Fundament, QP/NLP, Finanzdaten, Markowitz, CVaR, Handelsmaschine
→ Projekt P6 oder P7.
*(Ohne Kapitelnummern — die verschieben sich beim Einfügen neuer Kapitel. Die
maßgebliche, immer aktuelle Fassung der Lernpfade steht im Vorwort.)*
Details zu den Lernpfaden stehen in [`00_Vorwort_und_Lesehilfe.md`](00_Vorwort_und_Lesehilfe.md).
---
*Autor / Herausgeber: Dieter Schlüter · Stand: 8. September 2026*

View file

@ -0,0 +1,50 @@
# Beispielprogramme zu „Optimierte Entscheidungsfindung mit Python" (Version 04)
Diese {anzahl} Dateien sind automatisch aus den Kapiteldateien in
`../Operations_Research_mit_Python_Version_04/` extrahiert — jede Datei entspricht
genau einem im Buch vollständig abgedruckten Programm. Sie sind hier zusätzlich als
eigenständige, direkt ausführbare `.py`-Dateien verfügbar, damit man sie ohne
Copy-Paste aus dem Buch starten kann.
**Diese Dateien sind ein generiertes Artefakt, keine Quelle.** Ändern Sie stattdessen
den Codeblock im jeweiligen Kapitel und lassen Sie das Extraktionsskript erneut laufen:
```bash
cd OR_mit_Python/Version_04 # Wurzel der Version 04
python3 Operations_Research_mit_Python_Version_04/extract_programme_04.py
```
Das Verzeichnis „Verzeichnis der Beispielprogramme" im Vorwort des Buchs
(`00_Vorwort_und_Lesehilfe.md`) listet dieselben {anzahl} Programme mit Kurzbeschreibung.
Auch dieses README ist selbst generiert: `extract_programme.py` erzeugt es bei jedem
Lauf aus der Vorlage `README_Programme.md` im Quellverzeichnis und setzt die
Programmzahl automatisch ein — Textänderungen gehören daher in die Vorlage, nicht in
diese Datei.
## Installation
```bash
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
## Hinweise zum Ausführen
- **Die Finanzkapitel** (Finanzdaten, Markowitz, CVaR, Handelsmaschine) laden
Kursdaten live über `yfinance` — es wird eine
Internetverbindung benötigt. Ergebnisse und abgedruckte Zahlen weichen je nach
Abrufzeitpunkt vom Buchtext ab; das ist beabsichtigt (relative Zeitfenster statt
fester Daten).
- **Programme mit Diagrammausgabe** schreiben PNGs in
einen Unterordner `output/` neben dem jeweiligen Skript. Für Headless-Läufe ohne
Anzeige `MPLBACKEND=Agg` setzen:
```bash
MPLBACKEND=Agg python3 Markowitz_CVXPY.py
```
- `Ein_System_Vier_Ansaetze.py` startet `ortools` und `highspy` jeweils in
einem eigenen Unterprozess, weil beide Pakete auf vielen Systemen dieselbe
HiGHS-Bibliothek mitbringen und sich nicht im selben Prozess importieren lassen.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,172 @@
#!/usr/bin/env python3
# extract_programme_04.py
"""
Extrahiert alle vollstaendigen Beispielprogramme aus den Kapiteldateien dieses
Verzeichnisses und schreibt sie unter ihrem eigenen Dateinamen nach
../Operations_Research_mit_Python_Version_04_Programme/
Ein Codeblock gilt als vollstaendiges Programm, wenn er direkt mit
#!/usr/bin/env python3
<Leerzeile>
# Name.py
beginnt (die im Buch durchgaengig verwendete Konvention). Kurze, illustrative
Codeschnipsel (Formel-Lesehilfen, Anhang B/C, Projektwerkstatt) folgen diesem
Muster nicht und werden deshalb korrekt ignoriert.
Der Dateiname traegt bewusst KEINE Kapitelnummer (z.B. "Sensitivitaetsanalyse.py",
nicht "Kap_04_Sensitivitaetsanalyse.py") - die Kapitelzuordnung wird dynamisch
aus der Quelldatei berechnet, damit beim Umsortieren von Kapiteln nichts
umbenannt werden muss.
Das Skript ist bewusst wiederholbar: Nach jeder inhaltlichen Aenderung an einem
Kapitel-Codeblock einfach erneut ausfuehren, um das Programme-Verzeichnis auf
den aktuellen Stand zu bringen.
Das README.md des Zielverzeichnisses wird ebenfalls bei jedem Lauf erzeugt -
aus der Vorlage README_Programme.md in diesem Verzeichnis. Der Platzhalter
{anzahl} wird dabei durch die tatsaechliche Programmzahl ersetzt, damit die
Angabe im README nie veraltet.
Aufruf:
python3 Operations_Research_mit_Python_Version_04/extract_programme_04.py
python3 .../extract_programme.py --check # nur pruefen, nichts schreiben
"""
import argparse
import os
import re
import sys
HIER = os.path.dirname(os.path.abspath(__file__))
BASIS = os.path.dirname(HIER)
ZIEL_DIR = os.path.join(BASIS, "Operations_Research_mit_Python_Version_04_Programme")
# Das README im Zielverzeichnis wird bei jedem Lauf aus dieser Vorlage erzeugt;
# der Platzhalter {anzahl} steht dort fuer die tatsaechliche Programmzahl.
VORLAGE = os.path.join(HIER, "README_Programme.md")
ZIEL_README = os.path.join(ZIEL_DIR, "README.md")
FENCE_RE = re.compile(r"^```python\s*$")
FENCE_END_RE = re.compile(r"^```\s*$")
HEADER_RE = re.compile(r"\A#!/usr/bin/env python3\n\n# ([^\n]+\.py)\n")
def finde_programme():
"""Liefert {dateiname: (quelldatei, code)} fuer alle gefundenen Vollprogramme."""
programme = {}
for name in sorted(os.listdir(HIER)):
if not name.endswith(".md"):
continue
pfad = os.path.join(HIER, name)
with open(pfad, encoding="utf-8") as f:
zeilen = f.read().split("\n")
i = 0
n = len(zeilen)
while i < n:
if FENCE_RE.match(zeilen[i]):
start = i + 1
j = start
while j < n and not FENCE_END_RE.match(zeilen[j]):
j += 1
code = "\n".join(zeilen[start:j]) + "\n"
m = HEADER_RE.match(code)
if m:
dateiname = m.group(1)
if dateiname in programme and programme[dateiname][1] != code:
print(f"WARNUNG: {dateiname} kommt mehrfach mit "
f"unterschiedlichem Inhalt vor "
f"({programme[dateiname][0]} und {name}).")
programme[dateiname] = (name, code)
i = j + 1
else:
i += 1
return programme
def rendere_readme(anzahl: int) -> str | None:
"""Liest die README-Vorlage und setzt {anzahl} ein.
Liefert None, wenn die Vorlage fehlt - dann bleibt ein vorhandenes README
im Zielverzeichnis unangetastet.
"""
if not os.path.exists(VORLAGE):
return None
with open(VORLAGE, encoding="utf-8") as f:
return f.read().replace("{anzahl}", str(anzahl))
def pruefe_readme(anzahl: int) -> None:
"""Meldet fuer --check, ob das README im Zielverzeichnis zur Vorlage passt."""
readme = rendere_readme(anzahl)
if readme is None:
print(f"WARNUNG: Vorlage {VORLAGE} fehlt - README wird nicht erzeugt.")
return
if not os.path.exists(ZIEL_README):
print(f"README im Zielverzeichnis fehlt: {ZIEL_README}")
return
with open(ZIEL_README, encoding="utf-8") as f:
steht = f.read()
if steht == readme:
print("README im Zielverzeichnis ist aktuell.")
else:
print("README im Zielverzeichnis weicht von der Vorlage ab "
"(wird beim naechsten Extraktionslauf neu erzeugt).")
def schreibe_readme(anzahl: int) -> None:
"""Erzeugt das README im Zielverzeichnis aus der Vorlage."""
readme = rendere_readme(anzahl)
if readme is None:
print(f"WARNUNG: Vorlage {VORLAGE} fehlt - README nicht aktualisiert.")
return
with open(ZIEL_README, "w", encoding="utf-8") as f:
f.write(readme)
print(f"README erzeugt: {ZIEL_README}")
def main() -> int:
parser = argparse.ArgumentParser(
description="Extrahiert Beispielprogramme aus den Kapiteldateien.")
parser.add_argument("--check", action="store_true",
help="nur pruefen und auflisten (inkl. README-Abgleich), "
"nichts schreiben")
args = parser.parse_args()
programme = finde_programme()
print(f"Gefunden: {len(programme)} vollstaendige Programme in {HIER}.")
if args.check:
for dateiname, (quelle, _) in sorted(programme.items()):
print(f" {dateiname} <- {quelle}")
pruefe_readme(len(programme))
return 0
os.makedirs(ZIEL_DIR, exist_ok=True)
vorher = {f for f in os.listdir(ZIEL_DIR) if f.endswith(".py")}
geschrieben = set()
for dateiname, (quelle, code) in sorted(programme.items()):
ziel = os.path.join(ZIEL_DIR, dateiname)
with open(ziel, "w", encoding="utf-8") as f:
f.write(code)
geschrieben.add(dateiname)
verwaist = vorher - geschrieben
if verwaist:
print("ACHTUNG - verwaiste Dateien im Zielverzeichnis (kein passendes "
"Programm mehr in den Kapiteln, wurden NICHT geloescht):")
for f in sorted(verwaist):
print(f" {f}")
print(f"Geschrieben: {len(geschrieben)} Dateien nach {ZIEL_DIR}")
schreibe_readme(len(programme))
return 0
if __name__ == "__main__":
sys.exit(main())