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:
commit
b7af2f1d9a
35 changed files with 262141 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
1262
Operations_Research_mit_Python_Version_04/10_Einfuehrung_OR.md
Normal file
1262
Operations_Research_mit_Python_Version_04/10_Einfuehrung_OR.md
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1267
Operations_Research_mit_Python_Version_04/12_Python_Oekosystem.md
Normal file
1267
Operations_Research_mit_Python_Version_04/12_Python_Oekosystem.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
1829
Operations_Research_mit_Python_Version_04/21_MILP.md
Normal file
1829
Operations_Research_mit_Python_Version_04/21_MILP.md
Normal file
File diff suppressed because it is too large
Load diff
1500
Operations_Research_mit_Python_Version_04/22_CP_SAT.md
Normal file
1500
Operations_Research_mit_Python_Version_04/22_CP_SAT.md
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1528
Operations_Research_mit_Python_Version_04/24_Metaheuristiken.md
Normal file
1528
Operations_Research_mit_Python_Version_04/24_Metaheuristiken.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
||||
1351
Operations_Research_mit_Python_Version_04/30_QP_und_NLP.md
Normal file
1351
Operations_Research_mit_Python_Version_04/30_QP_und_NLP.md
Normal file
File diff suppressed because it is too large
Load diff
962
Operations_Research_mit_Python_Version_04/31_Unsicherheit.md
Normal file
962
Operations_Research_mit_Python_Version_04/31_Unsicherheit.md
Normal 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
|
||||
|
||||

|
||||
|
||||
| | **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)
|
||||
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
### 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)
|
||||
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
{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.
|
||||
|
|
@ -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.
|
||||
737
Operations_Research_mit_Python_Version_04/35_Strukturbruecke.md
Normal file
737
Operations_Research_mit_Python_Version_04/35_Strukturbruecke.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
953
Operations_Research_mit_Python_Version_04/40_Finanzdaten.md
Normal file
953
Operations_Research_mit_Python_Version_04/40_Finanzdaten.md
Normal 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$).
|
||||
|
||||

|
||||
|
||||
| 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
Loading…
Reference in a new issue