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
35 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}, 11–14 |
| **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 (100–140 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
4–6 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 (2–4 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** | Karush–Kuhn–Tucker (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** | Miller–Tucker–Zemlin (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** | Ledoit–Wolf (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 | 1–2 % 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 380–2 200 mm) | 309 | 38 | 12,3 % |
| schmale Zuschnitte (380–1 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)

Some files were not shown because too many files have changed in this diff Show more