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
468 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)
|
||||
1058
Operations_Research_mit_Python_Version_04/41_Markowitz.md
Normal file
1058
Operations_Research_mit_Python_Version_04/41_Markowitz.md
Normal file
File diff suppressed because it is too large
Load diff
849
Operations_Research_mit_Python_Version_04/42_CVaR.md
Normal file
849
Operations_Research_mit_Python_Version_04/42_CVaR.md
Normal file
|
|
@ -0,0 +1,849 @@
|
|||
# Kapitel: Tail-Risiko, CVaR und Transaktionskosten {#kap:cvar}
|
||||
|
||||
> **📌 Kapitel auf einen Blick**
|
||||
>
|
||||
> **Worum geht es?** Um zwei Schwächen des Markowitz-Modells: Es unterschätzt Extremverluste
|
||||
> und ignoriert die Kosten des Umschichtens. Beide lassen sich mit konvexen Mitteln beheben.
|
||||
>
|
||||
> **Voraussetzungen:** {ref:kap:markowitz}, {ref:kap:lp} (LP-Formulierungen).
|
||||
>
|
||||
> **Danach können Sie:** VaR und CVaR unterscheiden, den CVaR nach Rockafellar/Uryasev als
|
||||
> lineares Programm formulieren, Turnover über die $L_1$-Norm bestrafen — und begründen,
|
||||
> warum VaR-Kennzahlen nicht über Einheiten addiert werden dürfen.
|
||||
>
|
||||
> **Zeitbedarf:** ca. 5 Stunden.
|
||||
>
|
||||
> **Programme:**\
|
||||
> `VaR_CVaR_Demo.py`\
|
||||
> `CVaR_Portfolio.py`
|
||||
|
||||
---
|
||||
|
||||
## In 5 Minuten gelöst {#sec:cvar-schnellstart}
|
||||
|
||||
> **🚀 In 5 Minuten gelöst: Zwei Anlagen, dasselbe Risiko?**
|
||||
>
|
||||
> Zwei Anlagen wurden über 100 Tage beobachtet. An 94 Tagen legten **beide** um 0,5 % zu.
|
||||
> Sie unterscheiden sich nur in den sechs schlechten Tagen:
|
||||
>
|
||||
> * **Anlage A:** sechsmal −3 %.
|
||||
> * **Anlage B:** fünfmal −3 % — und einmal **−40 %**.
|
||||
>
|
||||
> ```python
|
||||
> import numpy as np
|
||||
>
|
||||
> gut = np.full(94, 0.5)
|
||||
> A = np.concatenate([gut, np.full(6, -3.0)])
|
||||
> B = np.concatenate([gut, np.full(5, -3.0), [-40.0]])
|
||||
>
|
||||
> for name, r in [("Anlage A", A), ("Anlage B", B)]:
|
||||
> schwelle = np.percentile(r, 5) # 5-%-Quantil
|
||||
> var = -schwelle # Value at Risk
|
||||
> cvar = -r[r <= schwelle].mean() # Mittel der schlimmsten 5 %
|
||||
> print(f"{name}: VaR(95) {var:5.2f} % CVaR(95) {cvar:5.2f} %")
|
||||
> ```
|
||||
>
|
||||
> **Ausgabe:**
|
||||
>
|
||||
> ```
|
||||
> Anlage A: VaR(95) 3.00 % CVaR(95) 3.00 %
|
||||
> Anlage B: VaR(95) 3.00 % CVaR(95) 9.17 %
|
||||
> ```
|
||||
|
||||
**Der Value at Risk ist für beide Anlagen exakt gleich: 3,00 %.** Ein Risikobericht, der
|
||||
nur den VaR ausweist, würde die beiden als gleich riskant einstufen.
|
||||
|
||||
Dabei kann Anlage B an einem einzigen Tag **40 %** verlieren. Das ist kein Randfall, den man
|
||||
übersehen darf — es ist der Fall, wegen dessen es Risikomanagement gibt.
|
||||
|
||||
Der Grund für diese Blindheit steckt in der Definition:
|
||||
|
||||
| Maß | Was es beantwortet | Was es dabei übersieht |
|
||||
| --- | --- | --- |
|
||||
| **VaR (95 %)** | „Welchen Verlust überschreite ich an höchstens 5 % der Tage?“ | **Alles**, was jenseits dieser Schwelle passiert |
|
||||
| **CVaR (95 %)** | „Wie hoch ist der Verlust *im Mittel*, **wenn** es schiefgeht?“ | nichts im Schwanz |
|
||||
|
||||
Der VaR ist ein **Quantil** — er markiert eine Grenze und schaut nicht dahinter. Ob hinter
|
||||
der Grenze −3 % oder −40 % liegen, ändert ihn nicht. Der CVaR mittelt genau über diesen
|
||||
Bereich und macht den Unterschied sichtbar: 3,00 % gegen 9,17 %.
|
||||
|
||||
> **🎯 Merksatz**
|
||||
> Der VaR sagt Ihnen, **wie oft** es schiefgeht. Der CVaR sagt Ihnen, **wie schlimm** es dann
|
||||
> ist. Für die Frage, ob ein Unternehmen einen Verlust überlebt, zählt ausschließlich die
|
||||
> zweite.
|
||||
|
||||
**Und es kommt noch besser.** Der CVaR ist nicht nur aussagekräftiger, er ist auch
|
||||
mathematisch handlicher: Er lässt sich als **lineares Programm** minimieren, während die
|
||||
Minimierung des VaR ein nicht-konvexes Problem mit vielen lokalen Optima ist
|
||||
({ref:kap:qp-nlp}). Das ist ein seltener Glücksfall — das bessere Maß ist hier zugleich das
|
||||
leichter optimierbare. Wie das geht, zeigt {ref:sec:cvar-value-at-risk-und-conditional-value}.
|
||||
|
||||
---
|
||||
|
||||
## Lernziele {#sec:cvar-lernziele}
|
||||
|
||||
Nach diesem Kapitel können Sie …
|
||||
|
||||
1. … erklären, warum Marktrenditen keine Normalverteilung haben und was daraus folgt.
|
||||
2. … VaR und CVaR definieren und begründen, warum nur der CVaR kohärent ist.
|
||||
3. … das Rockafellar-Uryasev-Theorem anwenden, um den CVaR linear zu formulieren.
|
||||
4. … Transaktionskosten über die $L_1$-Norm modellieren.
|
||||
5. … Einheiten konsistent halten und Nebenbedingungen vektorisieren.
|
||||
6. … erklären, warum zwei Anlagen mit identischem VaR völlig verschiedene Extremverluste
|
||||
haben können.
|
||||
7. … die Folgen fehlender Subadditivität für die Verteilung von Risikobudgets benennen.
|
||||
|
||||
---
|
||||
|
||||
## Die zwei Schwächen des Markowitz-Modells {#sec:cvar-die-zwei-schwaechen-des-markowitz-modells}
|
||||
|
||||
**Erstens: die Normalverteilungs-Illusion.** Die Varianz behandelt Aufwärts- und
|
||||
Abwärtsschwankungen gleich und unterstellt implizit symmetrische, dünn auslaufende
|
||||
Verteilungen. Reale Marktrenditen haben aber **fette Ränder** (*fat tails*){idx:Fat Tails} und **negative
|
||||
Schiefe**: Extreme Verluste treten deutlich häufiger auf, als eine Normalverteilung
|
||||
vorhersagt.
|
||||
|
||||
**Zweitens: Reibungsblindheit.** Ein ungedämpftes Mean-Variance-Modell schichtet bei
|
||||
minimalen Schätzänderungen das gesamte Portfolio um. Ohne Berücksichtigung von Gebühren,
|
||||
Spreads und Steuern frisst der Umschlag (*turnover*) den theoretischen Mehrertrag auf.
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# VaR_CVaR_Demo.py
|
||||
"""
|
||||
Kapitel CVaR: Fat Tails, VaR und CVaR anschaulich.
|
||||
|
||||
Teil 1: Wie oft treten "unmoegliche" Tage wirklich auf?
|
||||
Teil 2: Warum ist der VaR nicht subadditiv - ein Gegenbeispiel zum Nachrechnen.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from scipy import stats
|
||||
|
||||
|
||||
def var_quantil(verluste: np.ndarray, alpha: float = 0.95) -> float:
|
||||
"""VaR = Quantil der Verlustverteilung (Verluste positiv, Gewinne negativ)."""
|
||||
return float(np.quantile(verluste, alpha))
|
||||
|
||||
|
||||
def cvar_rockafellar(verluste: np.ndarray, alpha: float = 0.95) -> float:
|
||||
"""
|
||||
CVaR ueber die Rockafellar-Uryasev-Formel:
|
||||
CVaR = min_gamma { gamma + 1/(1-alpha) * E[max(Verlust - gamma, 0)] }
|
||||
|
||||
WICHTIG: Der naheliegende Weg "Mittelwert aller Werte >= VaR" ist FALSCH,
|
||||
sobald die Verteilung Atome hat (z. B. genau zwei moegliche Verluste).
|
||||
Dann liegt der VaR selbst auf einem Atom, und der Vergleich '>=' erfasst
|
||||
zu viel Wahrscheinlichkeitsmasse. Die Formel unten behandelt das korrekt -
|
||||
und ist zugleich genau der Ausdruck, den wir im Abschnitt 'Value at Risk
|
||||
und Conditional Value at Risk' optimieren.
|
||||
"""
|
||||
kandidaten = np.unique(verluste) # Optimum liegt immer auf einem Datenpunkt
|
||||
return float(min(g + np.mean(np.maximum(verluste - g, 0.0)) / (1.0 - alpha)
|
||||
for g in kandidaten))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
rng = np.random.default_rng(2026)
|
||||
|
||||
# --- Teil 1: Fat Tails (analytisch, nicht simuliert) ------------------
|
||||
print("=" * 88)
|
||||
print(" TEIL 1: WIE OFT TRITT DAS 'UNMOEGLICHE' EIN?")
|
||||
print("=" * 88)
|
||||
print("Vergleich: Normalverteilung gegen t-Verteilung mit 3 Freiheitsgraden")
|
||||
print("(beide auf Standardabweichung 1 normiert).\n")
|
||||
|
||||
t_verteilung = stats.t(df=3)
|
||||
skalierung = t_verteilung.std() # auf Varianz 1 bringen
|
||||
|
||||
print(f"{'Ereignis':<22} {'Normal':>14} {'t (df=3)':>14} {'Faktor':>11} "
|
||||
f"{'Normal: 1 Tag in':>18}")
|
||||
print("-" * 88)
|
||||
for k in [3, 4, 5, 6]:
|
||||
p_normal = 2 * stats.norm.sf(k) # beidseitig
|
||||
p_t = 2 * t_verteilung.sf(k * skalierung)
|
||||
print(f"Abweichung > {k} Sigma {p_normal*100:>13.6f} % {p_t*100:>13.6f} % "
|
||||
f"{p_t/p_normal:>10.1f}x {1/p_normal/252:>15,.0f} Jahre")
|
||||
|
||||
print("\nDeutung: Ein 5-Sigma-Tag ist unter Normalverteilung ein Ereignis von")
|
||||
print("etwa einmal in 6.900 Jahren. Reale Aktienmaerkte liefern solche Tage")
|
||||
print("mehrfach pro Jahrzehnt. Wer allein mit Varianz steuert, plant fuer")
|
||||
print("eine Welt, in der Crashs praktisch nicht vorkommen.")
|
||||
|
||||
# --- Teil 2: VaR ist nicht subadditiv ---------------------------------
|
||||
print("\n" + "=" * 88)
|
||||
print(" TEIL 2: WARUM DER VaR KEIN KOHAERENTES RISIKOMASS IST")
|
||||
print("=" * 88)
|
||||
print("Zwei unabhaengige Anleihen, je 100 EUR Nominal.")
|
||||
print("Jede faellt mit 4 % Wahrscheinlichkeit aus (Verlust 100),")
|
||||
print("sonst zahlt sie 2 EUR Kupon (Verlust -2).\n")
|
||||
|
||||
ziehungen = 2_000_000
|
||||
verlust_a = np.where(rng.random(ziehungen) < 0.04, 100.0, -2.0)
|
||||
verlust_b = np.where(rng.random(ziehungen) < 0.04, 100.0, -2.0)
|
||||
verlust_ab = verlust_a + verlust_b
|
||||
|
||||
print(f"{'':<28} {'VaR 95%':>12} {'CVaR 95%':>12}")
|
||||
print("-" * 88)
|
||||
werte = {}
|
||||
for name, v in [("Anleihe A allein", verlust_a),
|
||||
("Anleihe B allein", verlust_b),
|
||||
("Portfolio A+B", verlust_ab)]:
|
||||
werte[name] = (var_quantil(v), cvar_rockafellar(v))
|
||||
print(f"{name:<28} {werte[name][0]:>12.2f} {werte[name][1]:>12.2f}")
|
||||
|
||||
var_summe = werte["Anleihe A allein"][0] + werte["Anleihe B allein"][0]
|
||||
cvar_summe = werte["Anleihe A allein"][1] + werte["Anleihe B allein"][1]
|
||||
print(f"{'Summe der Einzelwerte':<28} {var_summe:>12.2f} {cvar_summe:>12.2f}")
|
||||
|
||||
var_port, cvar_port = werte["Portfolio A+B"]
|
||||
print("-" * 88)
|
||||
print(f"VaR: Portfolio {var_port:7.2f} vs. Summe {var_summe:7.2f} -> "
|
||||
f"{'VERLETZT die Subadditivitaet!' if var_port > var_summe else 'subadditiv'}")
|
||||
print(f"CVaR: Portfolio {cvar_port:7.2f} vs. Summe {cvar_summe:7.2f} -> "
|
||||
f"{'subadditiv (kohaerent)' if cvar_port <= cvar_summe + 1e-6 else 'verletzt'}")
|
||||
|
||||
print("\nDeutung: Einzeln betrachtet meldet der VaR fuer jede Anleihe einen")
|
||||
print("GEWINN von 2 EUR - denn mit 96 % Wahrscheinlichkeit passiert nichts,")
|
||||
print("und 4 % liegen unterhalb der 5-%-Schwelle. Im Portfolio steigt die")
|
||||
print("Wahrscheinlichkeit mindestens eines Ausfalls auf 7,8 % und damit UEBER")
|
||||
print("die Schwelle - der VaR springt auf 98. Er behauptet also, Streuung")
|
||||
print("habe das Risiko erhoeht. Das ist oekonomisch unsinnig und der Grund,")
|
||||
print("warum die Bankenaufsicht mit Basel III auf den Expected Shortfall")
|
||||
print("umgestellt hat.")
|
||||
print("=" * 88)
|
||||
```
|
||||
|
||||
**Erwartete Ausgabe (gekürzt):**
|
||||
|
||||
```
|
||||
========================================================================================
|
||||
TEIL 1: WIE OFT TRITT DAS 'UNMOEGLICHE' EIN?
|
||||
========================================================================================
|
||||
Ereignis Normal t (df=3) Faktor Normal: 1 Tag in
|
||||
----------------------------------------------------------------------------------------
|
||||
Abweichung > 3 Sigma 0.269980 % 1.384683 % 5.1x 1 Jahre
|
||||
Abweichung > 4 Sigma 0.006334 % 0.616537 % 97.3x 63 Jahre
|
||||
Abweichung > 5 Sigma 0.000057 % 0.323904 % 5649.8x 6,922 Jahre
|
||||
Abweichung > 6 Sigma 0.000000 % 0.190127 % 963561.0x 2,011,101 Jahre
|
||||
|
||||
========================================================================================
|
||||
TEIL 2: WARUM DER VaR KEIN KOHAERENTES RISIKOMASS IST
|
||||
========================================================================================
|
||||
VaR 95% CVaR 95%
|
||||
----------------------------------------------------------------------------------------
|
||||
Anleihe A allein -2.00 79.59
|
||||
Anleihe B allein -2.00 79.66
|
||||
Portfolio A+B 98.00 101.33
|
||||
Summe der Einzelwerte -4.00 159.24
|
||||
----------------------------------------------------------------------------------------
|
||||
VaR: Portfolio 98.00 vs. Summe -4.00 -> VERLETZT die Subadditivitaet!
|
||||
CVaR: Portfolio 101.33 vs. Summe 159.24 -> subadditiv (kohaerent)
|
||||
========================================================================================
|
||||
```
|
||||
|
||||
**Die 6-Sigma-Zeile setzt die Sache ins Verhältnis:** Unter Normalverteilung wäre ein
|
||||
solcher Tag ein Ereignis von einmal in **zwei Millionen Jahren**. Unter der t-Verteilung mit
|
||||
drei Freiheitsgraden — die realen Aktienrenditen deutlich näher kommt — passiert er etwa
|
||||
alle **zwei Jahre**. Der Faktor beträgt fast **eine Million**.
|
||||
|
||||
Und Teil 2 zeigt das Grundproblem des VaR an einem Beispiel, das Sie von Hand nachrechnen
|
||||
können: Einzeln meldet er für jede Anleihe einen *Gewinn* von 2 €, im Portfolio einen
|
||||
*Verlust* von 98 €. Diversifikation hätte demnach das Risiko um 102 € erhöht. Der CVaR
|
||||
dagegen verhält sich korrekt: 101,33 € im Portfolio gegenüber 159,24 € bei getrennter
|
||||
Betrachtung — die Streuung **senkt** das Risiko, wie es sein muss.
|
||||
|
||||
> **⚠️ Eine Falle bei der CVaR-Berechnung**
|
||||
> Der naheliegende Weg — „Mittelwert aller Verluste $\ge$ VaR“ — ist **falsch**, sobald die
|
||||
> Verteilung **Atome** hat (also einzelne Werte mit positiver Wahrscheinlichkeit, wie hier
|
||||
> die zwei möglichen Ausgänge). Der VaR liegt dann selbst auf einem Atom, und der Vergleich
|
||||
> `>=` erfasst zu viel Wahrscheinlichkeitsmasse. Im Beispiel oben liefert dieser naive
|
||||
> Schätzer für Anleihe A den Wert 2,08 statt der korrekten 79,59 — ein Fehler um Faktor 38.
|
||||
>
|
||||
> Die Rockafellar-Uryasev-Formel behandelt Atome von sich aus korrekt. Verwenden Sie sie
|
||||
> auch dann, wenn Sie „nur schnell“ einen CVaR ausrechnen wollen.
|
||||
|
||||
---
|
||||
|
||||
## Value at Risk und Conditional Value at Risk {#sec:cvar-value-at-risk-und-conditional-value}
|
||||
|
||||

|
||||
|
||||
**Value at Risk ($\text{VaR}_\alpha$):**{idx:Value at Risk (VaR)} Der Verlust, der mit Wahrscheinlichkeit $\alpha$
|
||||
nicht überschritten wird.
|
||||
|
||||
**Conditional Value at Risk ($\text{CVaR}_\alpha$, auch *Expected Shortfall*{idx:Expected Shortfall}):**{idx:Conditional Value at Risk (CVaR)} Der
|
||||
**durchschnittliche Verlust in den schlimmsten $(1-\alpha)$ Prozent** der Fälle.
|
||||
|
||||
> **📐 Der Unterschied in einem Satz**
|
||||
> Der VaR sagt: *„In 95 % der Tage verlieren Sie höchstens 1,86 %.“*
|
||||
> Der CVaR sagt: *„Und wenn es doch schiefgeht, verlieren Sie im Mittel 2,99 %.“*
|
||||
>
|
||||
> Beide Zahlen stammen aus der Verteilung oben — nachzurechnen mit
|
||||
> `bilder_04/erzeuge_var_cvar.py`.
|
||||
>
|
||||
> Der VaR ist eine **Schwelle**, der CVaR ein **Mittelwert dahinter**. Der VaR sagt nichts
|
||||
> darüber, wie schlimm es hinter der Schwelle wird — ob dort 2,4 % oder 40 % stehen, ist ihm
|
||||
> gleich. In der Stichprobe oben liegt der schlechteste Tag bei **−23,0 %**; am VaR von
|
||||
> 1,86 % ändert dieser eine Tag **nichts**, am CVaR sehr wohl.
|
||||
|
||||
| Eigenschaft | VaR | CVaR |
|
||||
| --- | --- | --- |
|
||||
| Berücksichtigt Verlusthöhe im Rand | ✗ nein | ✓ ja |
|
||||
| **Subadditiv**{idx:Subadditivität} ($\rho(A+B) \le \rho(A)+\rho(B)$) | ✗ nein | ✓ ja |
|
||||
| Kohärentes Risikomaß | ✗ nein | ✓ ja |
|
||||
| Konvex und optimierbar | ✗ nein | ✓ ja |
|
||||
| Regulatorischer Standard | bis Basel II | **ab Basel III** |
|
||||
|
||||
> **⚠️ Eine verbreitete Ungenauigkeit**
|
||||
> Der CVaR wird manchmal „**streng** konvex“ genannt. Das ist zu stark: Der CVaR ist konvex, in
|
||||
> der Szenario-Darstellung sogar **stückweise linear** — und damit gerade **nicht** streng
|
||||
> konvex. Genau das ist sein praktischer Vorteil: Stückweise linear heißt, er lässt sich als
|
||||
> **lineares Programm** lösen.
|
||||
|
||||
### Das Rockafellar-Uryasev-Theorem (2000)
|
||||
|
||||
Rockafellar und Uryasev{idx:Rockafellar-Uryasev-Theorem} zeigten, dass sich der CVaR über $S$ Szenarien exakt als lineares
|
||||
Programm formulieren lässt:
|
||||
|
||||
$$
|
||||
\text{CVaR}_\alpha(\mathbf{w}) = \min_{\gamma,\ \mathbf{u}\ \ge 0}\ \
|
||||
\gamma + \frac{1}{S(1-\alpha)}\sum_{s=1}^S u_s
|
||||
$$
|
||||
|
||||
$$
|
||||
\text{u. d. N.}\qquad u_s \ \ge\ -\mathbf{R}_s^\top\mathbf{w} - \gamma \quad\forall s,
|
||||
\qquad u_s \ge 0 \quad\forall s
|
||||
$$
|
||||
|
||||
> **📐 Formel-Lesehilfe — der Trick in drei Schritten**
|
||||
> * $\gamma$ ist eine **Hilfsvariable**, die im Optimum automatisch den VaR annimmt. Man
|
||||
> muss ihn also **nicht vorher kennen** — das ist der eigentliche Durchbruch.
|
||||
> * $-\mathbf{R}_s^\top\mathbf{w}$ ist der **Verlust** im Szenario $s$ (Rendite mit
|
||||
> negativem Vorzeichen).
|
||||
> * $u_s \ge \text{Verlust}_s - \gamma$ zusammen mit $u_s \ge 0$ bedeutet:
|
||||
> $u_s = \max(\text{Verlust}_s - \gamma,\ 0)$ — der **Überschuss über die Schwelle**.
|
||||
> Liegt der Verlust unter $\gamma$, ist $u_s = 0$ und das Szenario zählt nicht.
|
||||
>
|
||||
> **Ohne Formel gesagt:** „Wähle eine Schwelle $\gamma$. Zähle für jedes Szenario, wie weit
|
||||
> der Verlust darüber hinausgeht. Der CVaR ist die Schwelle plus der gemittelte Überschuss
|
||||
> — und zwar für diejenige Schwelle, bei der diese Summe minimal wird.“
|
||||
>
|
||||
> **Warum $u_s$ automatisch das Maximum wird:** Die Zielfunktion minimiert die Summe der
|
||||
> $u_s$. Jedes $u_s$ wird also so klein wie möglich gedrückt — bis an die Grenze, die die
|
||||
> beiden Ungleichungen erlauben. Das ist genau das Maximum der beiden Untergrenzen.
|
||||
|
||||
> **🔤 Formel-Übersetzer: jedes Zeichen der Zielfunktion**
|
||||
>
|
||||
> Die Lesehilfe oben erklärt den *Trick*. Hier steht, was die einzelnen Zeichen **bedeuten** —
|
||||
> vor allem der Bruch, an dem die meisten Leser hängen bleiben:
|
||||
>
|
||||
> | Mathematik | Alltagssprache |
|
||||
> | --- | --- |
|
||||
> | $\alpha$ | Das **Konfidenzniveau**, üblich $0{,}95$ oder $0{,}99$. *Nicht* der Randanteil. |
|
||||
> | $1-\alpha$ | Der **Randanteil**: die schlechtesten 5 % (bzw. 1 %) der Fälle. Das ist der Teil, um den es geht. |
|
||||
> | $S$ | Die Anzahl der durchgerechneten Szenarien — Handelstage, Simulationsläufe, historische Perioden. |
|
||||
> | $S(1-\alpha)$ | **Wie viele Szenarien im Rand liegen.** Bei $S = 2\,000$ und $\alpha = 0{,}95$: genau 100. |
|
||||
> | $\frac{1}{S(1-\alpha)}\sum_s u_s$ | Kein Mittelwert über *alle* Szenarien, sondern über die **Randszenarien allein**. Genau deshalb steht dort nicht $\frac1S$. |
|
||||
> | $\gamma$ | Die Schwelle, ab der ein Verlust zum Randfall wird — im Optimum der VaR. |
|
||||
> | $\gamma + \frac{1}{S(1-\alpha)}\sum_s u_s$ | „Schwelle **plus** durchschnittlicher Überschuss darüber“ — und das ist der CVaR. |
|
||||
>
|
||||
> **In einem Satz:** *Der CVaR ist der Mittelwert der schlimmsten $1-\alpha$ Prozent — der
|
||||
> Bruch vor der Summe sorgt allein dafür, dass durch die richtige Anzahl geteilt wird.*
|
||||
>
|
||||
> > **⚠️ Die teuerste Verwechslung des Kapitels**
|
||||
> > $\alpha$ und $1-\alpha$ zu vertauschen führt zu **keiner Fehlermeldung**. Das Modell
|
||||
> > rechnet weiter, nur eben über die falsche Menge. An 2 000 simulierten Tagesrenditen mit
|
||||
> > Fat Tails gemessen:
|
||||
> >
|
||||
> > | Rechnung | gemittelt über | CVaR |
|
||||
> > | --- | --- | --- |
|
||||
> > | richtig ($\alpha = 0{,}95$, Rand 5 %) | 100 von 2 000 Szenarien | **3,43 %** |
|
||||
> > | vertauscht (Rand 95 %) | 1 900 von 2 000 Szenarien | **0,18 %** |
|
||||
> >
|
||||
> > Ein Faktor **19** — und die zweite Zahl ist kein schlecht geschätztes Randrisiko, sondern
|
||||
> > überhaupt kein Randmaß mehr: Sie mittelt über fast alle Tage und schließt die schlimmen
|
||||
> > gerade nicht ein. Der größte Einzelverlust der Stichprobe beträgt 21,4 %.
|
||||
> >
|
||||
> > **Die Gegenprobe kostet eine Zeile:** Ist der berechnete CVaR nicht deutlich größer als
|
||||
> > der mittlere Verlust, stimmt $\alpha$ nicht.
|
||||
|
||||
---
|
||||
|
||||
## Transaktionskosten über die $L_1$-Norm {#sec:cvar-transaktionskosten-ueber-die-l-1-norm}
|
||||
|
||||
Sei $\mathbf{w}_{\text{alt}}$ das bestehende Portfolio und $\mathbf{w}$ das neue Ziel. Der
|
||||
**Umschlag** (*turnover*){idx:Turnover (Umschlag)} ist:
|
||||
|
||||
$$
|
||||
\text{Turnover} = \sum_{i=1}^N \lvert w_i - w_{\text{alt},i}\rvert
|
||||
= \lVert \mathbf{w}-\mathbf{w}_{\text{alt}}\rVert_1
|
||||
$$
|
||||
|
||||

|
||||
|
||||
> **📐 Formel-Lesehilfe**
|
||||
> Die $L_1$-Norm ist die Summe der **Beträge**. Sie misst, wie viel Prozent des Portfolios
|
||||
> insgesamt bewegt werden — Käufe und Verkäufe zusammen.
|
||||
>
|
||||
> **Ohne Formel gesagt:** „Wenn du 5 % von A verkaufst und 5 % von B kaufst, hast du 10 %
|
||||
> Umschlag und zahlst darauf Gebühren.“
|
||||
>
|
||||
> **Warum $L_1$ und nicht $L_2$?** Die $L_1$-Norm ist konvex (also optimierbar) und
|
||||
> erzeugt zusätzlich **dünn besetzte Änderungen**: Sie bevorzugt wenige große Umschichtungen
|
||||
> gegenüber vielen kleinen. Das entspricht genau dem, was man in der Praxis will — nicht 50
|
||||
> Kleinstorders mit je 3 € Mindestgebühr.
|
||||
|
||||
In CVXPY schreibt man einfach `cp.norm1(w - w_alt)`; intern wird das in lineare
|
||||
Hilfsvariablen zerlegt.
|
||||
|
||||
---
|
||||
|
||||
## Implementierung: CVaR-Portfolio mit Reibung {#sec:cvar-implementierung-cvar-portfolio-mit-reibung}
|
||||
|
||||
> **⚠️ Einheiten konsistent halten**
|
||||
>
|
||||
> Ein häufiger Fehler verrechnet eine **annualisierte** Rendite gegen einen **täglichen** CVaR:
|
||||
> ```python
|
||||
> mu = returns_df.mean().values * 252 # ANNUALISIERT
|
||||
> cvar = gamma + (1/(S*(1-alpha))) * cp.sum(u) # TAEGLICH
|
||||
> objective = cp.Maximize(mu @ w - lambda_risk * cvar - trans_costs)
|
||||
> ```
|
||||
> Der Risikoterm ist dadurch faktisch um Faktor 252 zu schwach gewichtet:
|
||||
> `lambda_risk = 1.5` bedeutet dann effektiv eine Risikoaversion von $1{,}5/252 \approx 0{,}006$.
|
||||
> Das Modell ist nicht falsch im Sinne von unlösbar — aber der Parameter bedeutet nicht,
|
||||
> was er zu bedeuten scheint, und lässt sich daher nicht sinnvoll einstellen.
|
||||
>
|
||||
> **Dieses Programm rechnet deshalb durchgehend auf Tagesbasis** und annualisiert erst in
|
||||
> der Ausgabe.
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# CVaR_Portfolio.py
|
||||
"""
|
||||
Kapitel CVaR: CVaR-Optimierung mit L1-Transaktionskosten via CVXPY.
|
||||
|
||||
Eigenschaften:
|
||||
* Spaltenreihenfolge erzwungen
|
||||
* Einheiten konsistent (alles taeglich, Annualisierung nur in der Ausgabe)
|
||||
* Szenario-Nebenbedingungen VEKTORISIERT statt in einer Python-Schleife
|
||||
(eine Matrixbedingung statt S einzelner Constraints - deutlich schneller)
|
||||
* Vergleich CVaR- gegen Varianz-Optimierung
|
||||
* Nachrechnung von VaR/CVaR aus den realisierten Szenarien
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import cvxpy as cp
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import yfinance as yf
|
||||
|
||||
TICKER = ["AAPL", "MSFT", "NVDA", "AMZN", "JNJ", "PFE", "JPM", "GS", "XOM", "CVX"]
|
||||
ALPHA = 0.95 # Konfidenzniveau: schlechteste 5 % der Tage
|
||||
MAX_GEWICHT = 0.25
|
||||
GEBUEHRENSATZ = 0.002 # 0,2 % je Einheit Turnover (Spread + Brokerage)
|
||||
RISIKOAVERSION = 1.5 # bezogen auf TAEGLICHE Groessen
|
||||
HANDELSTAGE = 252
|
||||
|
||||
|
||||
def lade_renditen():
|
||||
ende = pd.Timestamp.today().normalize()
|
||||
start = ende - pd.DateOffset(years=2)
|
||||
roh = yf.download(TICKER, start=start, end=ende, auto_adjust=True, progress=False)
|
||||
if roh.empty:
|
||||
raise SystemExit("Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).")
|
||||
|
||||
if isinstance(roh.columns, pd.MultiIndex):
|
||||
kurse = roh["Close"][TICKER].dropna() # erzwingt eigene Spaltenreihenfolge
|
||||
else:
|
||||
kurse = roh[["Close"]].dropna()
|
||||
kurse.columns = TICKER
|
||||
assert list(kurse.columns) == TICKER, "Spaltenreihenfolge weicht ab!"
|
||||
return kurse.pct_change().dropna()
|
||||
|
||||
|
||||
def optimiere_cvar(R, w_alt, vektorisiert=True):
|
||||
"""
|
||||
Maximiere: taegliche Rendite - lambda * CVaR - Transaktionskosten
|
||||
Alle Groessen TAEGLICH.
|
||||
"""
|
||||
S, N = R.shape
|
||||
mu_taeglich = R.mean(axis=0)
|
||||
|
||||
w = cp.Variable(N, nonneg=True)
|
||||
gamma = cp.Variable() # wird im Optimum zum VaR
|
||||
u = cp.Variable(S, nonneg=True) # Ueberschuss ueber die Schwelle
|
||||
|
||||
cvar = gamma + (1.0 / (S * (1.0 - ALPHA))) * cp.sum(u)
|
||||
turnover = cp.norm1(w - w_alt)
|
||||
kosten = GEBUEHRENSATZ * turnover
|
||||
|
||||
ziel = cp.Maximize(mu_taeglich @ w - RISIKOAVERSION * cvar - kosten)
|
||||
|
||||
bedingungen = [cp.sum(w) == 1, w <= MAX_GEWICHT]
|
||||
if vektorisiert:
|
||||
# EINE Matrixbedingung statt S einzelner - deutlich schneller
|
||||
bedingungen.append(u >= -(R @ w) - gamma)
|
||||
else:
|
||||
for s in range(S): # Alternative: S einzelne Constraints (langsamer)
|
||||
bedingungen.append(u[s] >= -R[s] @ w - gamma)
|
||||
|
||||
problem = cp.Problem(ziel, bedingungen)
|
||||
problem.solve()
|
||||
if problem.status not in ("optimal", "optimal_inaccurate"):
|
||||
raise SystemExit(f"CVaR-Problem nicht loesbar: {problem.status}")
|
||||
return w.value, float(gamma.value), float(cvar.value), float(turnover.value)
|
||||
|
||||
|
||||
def optimiere_varianz(R, w_alt):
|
||||
"""Klassisches Mean-Variance zum Vergleich - ebenfalls taeglich gerechnet.
|
||||
|
||||
ACHTUNG, DCP-Falle: Die Standardabweichung ist hier NICHT als
|
||||
cp.sqrt(cp.quad_form(w, sigma)) formulierbar. cp.sqrt ist konkav und
|
||||
verlangt ein konkaves Argument; quad_form ist konvex - CVXPY lehnt den
|
||||
Ausdruck mit einem DCPError ab, und zwar voellig zu Recht (Anhang
|
||||
Fehlerdiagnose). cp.psd_wrap() hilft dagegen nicht: Es behebt eine
|
||||
NUMERISCHE Beanstandung an sigma, keine Regelverletzung im Aufbau.
|
||||
|
||||
Der Standardweg ist die Cholesky-Zerlegung sigma = L L^T. Damit gilt
|
||||
w' sigma w = ||L^T w||^2, also ist die Standardabweichung die 2-Norm
|
||||
eines AFFINEN Ausdrucks - konvex und damit regelkonform.
|
||||
"""
|
||||
N = R.shape[1]
|
||||
mu_taeglich = R.mean(axis=0)
|
||||
sigma = np.cov(R, rowvar=False, ddof=1)
|
||||
# Der winzige Diagonalzuschlag faengt den Fall ab, dass sigma numerisch
|
||||
# nur halbdefinit ist (mehr Titel als Handelstage, doppelte Spalten).
|
||||
L = np.linalg.cholesky(sigma + 1e-12 * np.eye(N))
|
||||
|
||||
w = cp.Variable(N, nonneg=True)
|
||||
ziel = cp.Maximize(mu_taeglich @ w
|
||||
- RISIKOAVERSION * cp.norm2(L.T @ w)
|
||||
- GEBUEHRENSATZ * cp.norm1(w - w_alt))
|
||||
problem = cp.Problem(ziel, [cp.sum(w) == 1, w <= MAX_GEWICHT])
|
||||
problem.solve()
|
||||
if problem.status not in ("optimal", "optimal_inaccurate"):
|
||||
raise SystemExit(f"Varianz-Problem nicht loesbar: {problem.status}")
|
||||
return w.value
|
||||
|
||||
|
||||
def realisierte_kennzahlen(w, R):
|
||||
"""VaR und CVaR direkt aus den Szenarien - unabhaengige Gegenprobe."""
|
||||
portfoliorenditen = R @ w
|
||||
verluste = -portfoliorenditen
|
||||
var = float(np.quantile(verluste, ALPHA))
|
||||
cvar = float(verluste[verluste >= var].mean())
|
||||
return var, cvar, float(portfoliorenditen.mean()), float(portfoliorenditen.std(ddof=1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
renditen = lade_renditen()
|
||||
R = renditen.values
|
||||
S, N = R.shape
|
||||
w_alt = np.ones(N) / N # Ausgangslage: Gleichgewichtung
|
||||
|
||||
t0 = time.perf_counter()
|
||||
w_cvar, var_modell, cvar_modell, turnover = optimiere_cvar(R, w_alt, vektorisiert=True)
|
||||
dauer_vektor = time.perf_counter() - t0
|
||||
|
||||
w_var = optimiere_varianz(R, w_alt)
|
||||
|
||||
print("=" * 90)
|
||||
print(" CVaR-PORTFOLIO-OPTIMIERUNG MIT TRANSAKTIONSKOSTEN")
|
||||
print("=" * 90)
|
||||
print(f"Datenbasis: {S} Handelstage, {N} Titel | Konfidenzniveau "
|
||||
f"{ALPHA*100:.0f} % | Loesungszeit {dauer_vektor:.2f} s\n")
|
||||
|
||||
# --- Gegenprobe: Modellwerte gegen realisierte Szenariowerte ---------
|
||||
var_real, cvar_real, mu_real, sd_real = realisierte_kennzahlen(w_cvar, R)
|
||||
print("--- Gegenprobe: stimmen Modell und Szenarien ueberein? ---")
|
||||
print(f" VaR aus dem Modell (gamma): {var_modell*100:7.4f} % | "
|
||||
f"aus den Szenarien: {var_real*100:7.4f} %")
|
||||
print(f" CVaR aus dem Modell: {cvar_modell*100:7.4f} % | "
|
||||
f"aus den Szenarien: {cvar_real*100:7.4f} %")
|
||||
assert abs(cvar_modell - cvar_real) < 1e-4, "CVaR stimmt nicht mit den Szenarien!"
|
||||
print(" -> Der Rockafellar-Uryasev-Trick liefert exakt den empirischen CVaR.")
|
||||
|
||||
# --- Kennzahlen beider Portfolios ------------------------------------
|
||||
print(f"\n{'Portfolio':<24} {'Rendite p.a.':>13} {'Vola p.a.':>11} "
|
||||
f"{'VaR 95% (Tag)':>15} {'CVaR 95% (Tag)':>16} {'Turnover':>10}")
|
||||
print("-" * 90)
|
||||
for name, w in [("CVaR-optimiert", w_cvar), ("Varianz-optimiert", w_var),
|
||||
("Gleichgewichtung", w_alt)]:
|
||||
v, c, m, s = realisierte_kennzahlen(w, R)
|
||||
to = float(np.abs(w - w_alt).sum())
|
||||
print(f"{name:<24} {m*HANDELSTAGE*100:>12.2f} % "
|
||||
f"{s*np.sqrt(HANDELSTAGE)*100:>10.2f} % {v*100:>14.3f} % "
|
||||
f"{c*100:>15.3f} % {to*100:>9.1f} %")
|
||||
|
||||
print("\nHinweis zur Annualisierung: Renditen werden mit 252 skaliert,")
|
||||
print("Volatilitaeten mit sqrt(252). Fuer VaR/CVaR ist eine solche Skalierung")
|
||||
print("nur unter starken Annahmen (Unabhaengigkeit, kein Drift) zulaessig -")
|
||||
print("sie werden hier deshalb bewusst als TAGESwerte ausgewiesen.")
|
||||
|
||||
# --- Allokationstabelle ------------------------------------------------
|
||||
print("\n--- Allokation ---")
|
||||
tabelle = pd.DataFrame({
|
||||
"Ticker": TICKER,
|
||||
"vorher": [f"{v*100:5.1f} %" for v in w_alt],
|
||||
"CVaR-opt.": [f"{v*100:5.1f} %" for v in w_cvar],
|
||||
"Handel": [f"{(w_cvar[i]-w_alt[i])*100:+6.1f} %" for i in range(N)],
|
||||
"Varianz-opt.": [f"{v*100:5.1f} %" for v in w_var],
|
||||
})
|
||||
print(tabelle.to_string(index=False))
|
||||
print(f"\nTurnover {turnover*100:.1f} % -> Transaktionskosten "
|
||||
f"{GEBUEHRENSATZ*turnover*100:.3f} % des Portfoliowerts")
|
||||
|
||||
# --- Laufzeitvergleich vektorisiert vs. Schleife ----------------------
|
||||
if S <= 600: # bei sehr vielen Szenarien zu langsam
|
||||
t0 = time.perf_counter()
|
||||
optimiere_cvar(R, w_alt, vektorisiert=False)
|
||||
dauer_schleife = time.perf_counter() - t0
|
||||
print(f"\n--- Laufzeit: {S} Nebenbedingungen aufbauen ---")
|
||||
print(f" vektorisiert (u >= -(R @ w) - gamma): {dauer_vektor:6.2f} s")
|
||||
print(f" Schleife ueber Szenarien (V01): {dauer_schleife:6.2f} s "
|
||||
f"({dauer_schleife/dauer_vektor:.1f}x langsamer)")
|
||||
print("=" * 90)
|
||||
```
|
||||
|
||||
> **💻 Code-Durchgang: Vektorisierung**
|
||||
>
|
||||
> Die entscheidende Zeile ist
|
||||
> ```python
|
||||
> bedingungen.append(u >= -(R @ w) - gamma) # EINE Matrixbedingung
|
||||
> ```
|
||||
> statt
|
||||
> ```python
|
||||
> for s in range(S):
|
||||
> bedingungen.append(u[s] >= -R[s] @ w - gamma) # S einzelne Bedingungen
|
||||
> ```
|
||||
>
|
||||
> Beide beschreiben **dasselbe Modell**. Der Unterschied liegt im Aufbau: CVXPY muss im
|
||||
> zweiten Fall 500 einzelne Ausdrucksbäume erzeugen, prüfen und zusammensetzen. Bei einem
|
||||
> Backtest mit 60 Rebalancings ({ref:kap:handelsmaschine}) summiert sich das erheblich.
|
||||
>
|
||||
> **Die allgemeine Regel:** Wenn Sie in einer Modellierungssprache eine Python-Schleife über
|
||||
> Datenzeilen schreiben, prüfen Sie, ob sich dasselbe als Matrixoperation ausdrücken lässt.
|
||||
> Der Solver rechnet ohnehin mit Matrizen — der Umweg über einzelne Ausdrücke kostet nur
|
||||
> Aufbauzeit.
|
||||
|
||||
> **⚠️ Die Standardabweichung ist in CVXPY keine Wurzel**
|
||||
>
|
||||
> Im Vergleichsmodell `optimiere_varianz()` steht die Standardabweichung als
|
||||
>
|
||||
> ```python
|
||||
> L = np.linalg.cholesky(sigma + 1e-12 * np.eye(N))
|
||||
> risiko = cp.norm2(L.T @ w) # = sqrt(w' sigma w)
|
||||
> ```
|
||||
>
|
||||
> und **nicht** als das Naheliegende, `cp.sqrt(cp.quad_form(w, sigma))`. Der naheliegende
|
||||
> Ausdruck ist nicht DCP und wird von CVXPY mit einem `DCPError` abgelehnt: `cp.sqrt` ist
|
||||
> konkav und verlangt deshalb ein **konkaves** Argument, `quad_form` ist aber konvex. Die
|
||||
> Regel wird verletzt, obwohl die Funktion als ganze mathematisch völlig harmlos ist —
|
||||
> $\sqrt{w^\top \Sigma w}$ ist konvex, DCP kann es nur nicht *sehen*.
|
||||
>
|
||||
> Wichtig ist die Fehlerdiagnose dahinter, weil sie leicht in die Irre geht:
|
||||
> `cp.psd_wrap(sigma)` sieht nach der Lösung aus und ist keine. Es unterdrückt die
|
||||
> **numerische** Beanstandung, dass $\Sigma$ nicht als positiv semidefinit erkannt wird —
|
||||
> gegen die **strukturelle** Regelverletzung im Aufbau hilft es nicht.
|
||||
>
|
||||
> Der Ausweg ist immer derselbe: Zerlegen Sie $\Sigma = LL^\top$ (Cholesky). Dann ist
|
||||
> $w^\top \Sigma w = \lVert L^\top w\rVert_2^2$, und die Standardabweichung wird zur 2-Norm
|
||||
> eines **affinen** Ausdrucks — konvex, regelkonform und für den Solver sogar die bessere
|
||||
> Formulierung, weil sie direkt ein Kegelproblem ist. Der winzige Diagonalzuschlag
|
||||
> $10^{-12}$ fängt den Fall ab, dass $\Sigma$ numerisch nur halbdefinit ist (mehr Titel als
|
||||
> Handelstage, duplizierte Spalten). Die vollständige Fehlertabelle steht in
|
||||
> {ref:anhang:fehlerdiagnose}.
|
||||
|
||||
> **💡 Zur Annualisierung von VaR und CVaR**
|
||||
> Ein häufiger Fehler skaliert den täglichen CVaR mit $\sqrt{252}$. Diese
|
||||
> **Wurzel-Zeit-Regel** gilt streng nur für **Standardabweichungen** unabhängig identisch
|
||||
> verteilter Größen ohne Drift. Der CVaR ist ein Erwartungswert über einen Verteilungsrand
|
||||
> — für ihn ist die Regel eine grobe Näherung, die bei fetten Rändern und Autokorrelation
|
||||
> systematisch danebenliegt.
|
||||
>
|
||||
> **Dieses Buch weist VaR und CVaR deshalb als Tageswerte aus** und benennt die
|
||||
> Skalierungsproblematik ausdrücklich. Wer Mehrtageshorizonte braucht, simuliert sie
|
||||
> (Monte-Carlo aus {ref:kap:unsicherheit}) statt zu skalieren.
|
||||
|
||||
---
|
||||
|
||||
## Übungsaufgaben {#sec:cvar-uebungsaufgaben}
|
||||
|
||||
> Lösungen: {ref:sec:loesungen-cvar}.
|
||||
|
||||
**Aufgabe ⭐ — VaR und CVaR ablesen.**
|
||||
Zehn Tagesverluste (in %): $-1{,}2$, $0{,}3$, $2{,}1$, $-0{,}8$, $5{,}4$, $1{,}1$,
|
||||
$-2{,}0$, $0{,}6$, $8{,}9$, $-0{,}4$ (positiv = Verlust). Bestimmen Sie $\text{VaR}_{80\%}$
|
||||
und $\text{CVaR}_{80\%}$ von Hand.
|
||||
|
||||
**Aufgabe ⭐ — Warum kohärent?**
|
||||
Erklären Sie in eigenen Worten, warum ein Risikomaß subadditiv sein sollte. Was bedeutet
|
||||
es wirtschaftlich, wenn diese Eigenschaft verletzt ist?
|
||||
|
||||
**Aufgabe ⭐⭐ — Rockafellar-Uryasev nachvollziehen.**
|
||||
Zeigen Sie für die drei Szenarien mit Verlusten $(1, 4, 9)$ und $\alpha = 2/3$:
|
||||
(a) Was ist der empirische CVaR?
|
||||
(b) Werten Sie $\gamma + \frac{1}{S(1-\alpha)}\sum_s \max(\text{Verlust}_s-\gamma, 0)$ für
|
||||
$\gamma \in \{0, 1, 4, 5, 9\}$ aus.
|
||||
(c) Bei welchem $\gamma$ ist der Ausdruck minimal, und stimmt das Minimum mit (a) überein?
|
||||
|
||||
**Aufgabe ⭐⭐ — Einheiten prüfen.**
|
||||
Angenommen, ein Modell verrechnet Jahresrendite gegen Tages-CVaR (siehe die Warnung in
|
||||
{ref:sec:cvar-implementierung-cvar-portfolio-mit-reibung}). Berechnen Sie: Welchem „effektiven“ täglichen $\lambda$ entspräche
|
||||
`lambda_risk = 1.5` dadurch? Welchen Wert müsste man stattdessen setzen, um dieselbe
|
||||
Wirkung wie `RISIKOAVERSION = 1.5` im konsistenten Tagesmodell zu erzielen?
|
||||
|
||||
**Aufgabe ⭐⭐⭐ — Risikoaversion kalibrieren.**
|
||||
Variieren Sie `RISIKOAVERSION` von 0 bis 20 und tragen Sie Rendite, Volatilität, CVaR und
|
||||
Turnover gegeneinander auf.
|
||||
(a) Wie sieht die „Effizienzgrenze“ im Rendite-CVaR-Raum aus?
|
||||
(b) Bei welchem Wert entspricht das CVaR-Portfolio ungefähr dem Varianz-Portfolio?
|
||||
(c) Welchen Wert würden Sie einem konservativen Stiftungsfonds empfehlen — und wie
|
||||
begründen Sie ihn ohne Fachjargon?
|
||||
|
||||
**Aufgabe ⭐⭐⭐ — Turnover-Grenze statt Strafe.**
|
||||
Ersetzen Sie den Kostenterm durch eine **harte Grenze**
|
||||
$\lVert\mathbf{w}-\mathbf{w}_{\text{alt}}\rVert_1 \le \tau$ und variieren Sie
|
||||
$\tau \in \{0{,}05;\ 0{,}1;\ 0{,}25;\ 0{,}5;\ 1{,}0\}$.
|
||||
(a) Wie verändert sich die erreichbare Rendite?
|
||||
(b) Was ist der Vorteil einer Grenze gegenüber einer Strafe — und was der Nachteil?
|
||||
(c) Wann würden Sie was einsetzen?
|
||||
|
||||
---
|
||||
|
||||
## Finde den Denkfehler {#sec:cvar-denkfehler}
|
||||
|
||||
> **🐛 Finde den Denkfehler 13.1: Das Risikobudget, das durch Diversifikation stieg**
|
||||
>
|
||||
> Eine Bank vergibt Risikobudgets je Abteilung. Zwei Kreditabteilungen halten je eine
|
||||
> Unternehmensanleihe über 100 € Nominal mit **4 % Ausfallwahrscheinlichkeit** (bei
|
||||
> Ausfall Totalverlust, sonst 2 € Kupon). Der Risikocontroller misst für jede einzeln:
|
||||
>
|
||||
> $$\text{VaR}_{95}(\text{A}) = -2{,}00\,€, \qquad \text{VaR}_{95}(\text{B}) = -2{,}00\,€$$
|
||||
>
|
||||
> Ein *negativer* Verlust, also ein Gewinn. Auf dem 95-%-Niveau erscheint jede Anleihe
|
||||
> risikolos, beide Abteilungen bekommen grünes Licht. Der Controller notiert als
|
||||
> Gesamtrisiko die Summe: $-4{,}00\,€$.
|
||||
>
|
||||
> Dann werden die Positionen in einem gemeinsamen Buch zusammengelegt. Der VaR des
|
||||
> Gesamtbuchs beträgt **+98,00 €**.
|
||||
>
|
||||
> **Ihre Aufgabe:**
|
||||
>
|
||||
> (a) Wie kann die Zusammenlegung zweier **unabhängiger** Positionen das gemessene Risiko
|
||||
> von $-4\,€$ auf $+98\,€$ treiben? Rechnen Sie die entscheidende Wahrscheinlichkeit aus.
|
||||
>
|
||||
> (b) Warum meldet der VaR für eine einzelne Anleihe einen Gewinn, obwohl sie mit 4 %
|
||||
> Wahrscheinlichkeit vollständig ausfällt?
|
||||
>
|
||||
> (c) Welche Eigenschaft eines Risikomaßes wird hier verletzt? Was bedeutet das konkret für
|
||||
> ein Unternehmen, das Risikobudgets auf Abteilungen verteilt und wieder zusammenzählt?
|
||||
>
|
||||
> (d) Der CVaR liefert 79,59 € und 79,66 € einzeln, aber nur 101,33 € zusammen — statt der
|
||||
> Summe von 159,24 €. Warum kann ihm das Verhalten des VaR nicht passieren?
|
||||
>
|
||||
> *Die Zahlen stammen aus Teil 2 von `VaR_CVaR_Demo.py` oben. Auflösung:*
|
||||
> *{ref:sec:loesungen-cvar}.*
|
||||
|
||||
> **⚠️ Eine Falle beim Nachrechnen**
|
||||
>
|
||||
> Wenn Sie den CVaR selbst nachrechnen wollen: Der naheliegende Weg
|
||||
> `verluste[verluste >= var].mean()` ist **falsch**, sobald die Verteilung Atome hat — und
|
||||
> hier hat sie genau zwei mögliche Werte. Der VaR liegt dann selbst auf einem Atom, und der
|
||||
> Vergleich `>=` erfasst weit mehr Wahrscheinlichkeitsmasse als die vorgesehenen 5 %.
|
||||
>
|
||||
> Deshalb verwendet `cvar_rockafellar()` oben die Optimierungsformel statt der naiven
|
||||
> Mittelung. Wer stattdessen exakt abzählt, muss die schlechtesten
|
||||
> $\lceil (1-\alpha)\,N \rceil$ Szenarien nehmen — nicht alle, die eine Schwelle
|
||||
> überschreiten.
|
||||
>
|
||||
> Diese Falle ist besonders unangenehm, weil das falsche Ergebnis plausibel aussieht. Sie
|
||||
> fällt nur auf, wenn man wie hier eine **unabhängige Gegenprobe** hat: Ein CVaR, der die
|
||||
> Subadditivität verletzt, kann nicht stimmen.
|
||||
|
||||
> **🎯 Merksatz**
|
||||
> Ein Risikomaß, dessen Werte man nicht addieren darf, ist als Steuerungsgröße unbrauchbar —
|
||||
> denn genau das tut jede Organisation mit Kennzahlen: Sie verteilt sie auf Einheiten und
|
||||
> zählt sie wieder zusammen. Der CVaR ist **kohärent** und erlaubt das; der VaR nicht. Aus
|
||||
> diesem Grund hat die Bankenaufsicht mit Basel III vom VaR auf den *Expected Shortfall*
|
||||
> umgestellt — dieselbe Größe, die hier CVaR heißt.
|
||||
|
||||
---
|
||||
|
||||
## Micro-Quiz {#sec:cvar-quiz}
|
||||
|
||||
> **❓ Micro-Quiz 13: Drei Fragen zum Selbstcheck**
|
||||
>
|
||||
> Genau eine Antwort ist jeweils richtig. Auflösung in
|
||||
> [Anhang A](90_Anhang_Loesungen.md#quiz-loesung-cvar).
|
||||
>
|
||||
> **1. Zwei Anlagen haben denselben VaR(95) von 3 %. Was wissen Sie über ihre
|
||||
> Extremverluste?**
|
||||
> (a) Sie sind gleich hoch — der VaR misst genau das.
|
||||
> (b) **Nichts.** Der VaR ist ein Quantil: Er markiert die Schwelle und sagt nichts
|
||||
> darüber, was dahinter liegt. Die eine kann dort −3 %, die andere −40 % haben.
|
||||
> (c) Die Anlage mit der höheren Schwankung hat auch die höheren Extremverluste.
|
||||
>
|
||||
> **2. Warum lässt sich der CVaR als lineares Programm minimieren, der VaR aber nicht?**
|
||||
> (a) Der VaR ist nicht differenzierbar.
|
||||
> (b) Der CVaR lässt sich nach Rockafellar/Uryasev über eine Hilfsvariable und
|
||||
> Schlupfterme als konvexes — sogar lineares — Problem schreiben. Die VaR-Minimierung ist
|
||||
> dagegen nicht konvex und hat viele lokale Optima.
|
||||
> (c) Der CVaR ist immer größer als der VaR.
|
||||
>
|
||||
> **3. Sie sollen das Risiko von drei Handelsbüchern zu einer Gesamtkennzahl
|
||||
> zusammenfassen. Welches Maß nehmen Sie?**
|
||||
> (a) VaR — er ist in der Praxis verbreiteter und leichter zu erklären.
|
||||
> (b) CVaR, weil er **subadditiv** ist: Die Gesamtkennzahl kann nie größer werden als die
|
||||
> Summe der Einzelkennzahlen, Diversifikation wird also korrekt belohnt.
|
||||
> (c) Beide liefern dasselbe, solange die Bücher unabhängig sind.
|
||||
|
||||
---
|
||||
|
||||
## Selbsttest {#sec:cvar-selbsttest}
|
||||
|
||||
> Antworten: [Anhang A](90_Anhang_Loesungen.md#selbsttest-loesung-cvar).
|
||||
|
||||
1. Was misst der CVaR, was der VaR nicht misst?
|
||||
2. Warum ist der CVaR konvex, aber nicht streng konvex?
|
||||
3. Welche Rolle spielt die Hilfsvariable $\gamma$ bei Rockafellar/Uryasev?
|
||||
4. Warum bevorzugt die $L_1$-Strafe wenige große statt vieler kleiner Umschichtungen?
|
||||
5. Warum sollte man tägliche und annualisierte Größen nicht in einer Zielfunktion mischen?
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung {#sec:cvar-zusammenfassung}
|
||||
|
||||
* **Reale Renditen haben fette Ränder.** Ein 5-Sigma-Tag ist unter Normalverteilung ein
|
||||
Jahrtausendereignis — an echten Märkten passiert er alle paar Jahre.
|
||||
* **Der VaR ist ein Quantil und schaut nicht dahinter.** Zwei Anlagen mit identischem VaR
|
||||
können Extremverluste von −3 % und −40 % haben. Der VaR sagt, **wie oft** es schiefgeht;
|
||||
der CVaR, **wie schlimm** es dann ist.
|
||||
* **Der VaR ist nicht kohärent:** Er kann Diversifikation als Risikoerhöhung ausweisen.
|
||||
Deshalb die regulatorische Umstellung auf den Expected Shortfall. Praktische Folge: Man
|
||||
darf VaR-Kennzahlen **nicht über Einheiten addieren** — und genau das tut jede
|
||||
Organisation mit Kennzahlen.
|
||||
* **Das bessere Maß ist hier zugleich das leichter optimierbare** — ein seltener Glücksfall.
|
||||
Der CVaR wird zum LP, die VaR-Minimierung bleibt nicht-konvex.
|
||||
* **Rockafellar/Uryasev** machen den CVaR über eine Hilfsvariable $\gamma$ linear
|
||||
optimierbar, ohne den VaR vorher zu kennen.
|
||||
* **Transaktionskosten gehören ins Modell**, nicht in eine nachgelagerte Rechnung — sonst
|
||||
schichtet der Optimierer bei jedem Rauschen um.
|
||||
* **Einheiten konsistent halten.** Tägliche Renditen zu täglichem Risiko; annualisiert wird
|
||||
erst in der Ausgabe.
|
||||
* **Vektorisieren Sie Szenario-Bedingungen** — das spart bei wiederholten Läufen erheblich
|
||||
Zeit.
|
||||
|
||||
**Ausblick.** {ref:kap:handelsmaschine} fügt alles zusammen: Datenpipeline, Signal, Optimierung,
|
||||
Rebalancing und ein Walk-Forward-Backtest ohne Lookahead-Bias.
|
||||
|
||||
---
|
||||
|
||||
*Weiter mit:* [{ref:kap:handelsmaschine} — Die vollständige Handelsmaschine](43_Handelsmaschine.md)
|
||||
1050
Operations_Research_mit_Python_Version_04/43_Handelsmaschine.md
Normal file
1050
Operations_Research_mit_Python_Version_04/43_Handelsmaschine.md
Normal file
File diff suppressed because it is too large
Load diff
1992
Operations_Research_mit_Python_Version_04/50_Praxis.md
Normal file
1992
Operations_Research_mit_Python_Version_04/50_Praxis.md
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
370
Operations_Research_mit_Python_Version_04/59_Projektwerkstatt.md
Normal file
370
Operations_Research_mit_Python_Version_04/59_Projektwerkstatt.md
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
# Projektwerkstatt — elf eigene Anwendungen
|
||||
|
||||
> **📌 Auf einen Blick**
|
||||
>
|
||||
> **Worum geht es?** Um den Schritt, auf den der ganze Kurs zuläuft: **eine eigene Anwendung
|
||||
> bauen.** Elf vollständig ausgearbeitete Projektaufträge, jeder mit Datenquellen,
|
||||
> Modellskizze, Abnahmekriterien und Stolperfallen. P1–P8 bauen je ein Modell; **P9–P11
|
||||
> setzen dort an, wo eines schon steht** — wenn es zu langsam wird, wenn zwei Ziele
|
||||
> gegeneinanderstehen, wenn es jemand anderes benutzen soll.
|
||||
>
|
||||
> **Voraussetzungen:** je Projekt angegeben.
|
||||
>
|
||||
> **Zeitbedarf:** 10–25 Stunden je Projekt.
|
||||
|
||||
---
|
||||
|
||||
## Wie Sie ein Projekt bearbeiten
|
||||
|
||||
Halten Sie sich an diese Reihenfolge — sie ist aus vielen gescheiterten und einigen
|
||||
gelungenen Projekten destilliert:
|
||||
|
||||
| Phase | Dauer | Ergebnis | Häufigster Fehler |
|
||||
| --- | --- | --- | --- |
|
||||
| **1. Zerlegen** | 10 % | Ausgefüllte Bausteine-Vorlage ({ref:sec:einfuehrung-die-vier-universellen-bausteine-jedes-or}) auf **Papier** | Sofort programmieren |
|
||||
| **2. Kleinstinstanz** | 15 % | Modell mit 3–5 Elementen, Lösung **von Hand** geprüft | Gleich mit echten Daten anfangen |
|
||||
| **3. Daten** | 20 % | Echte Daten geladen, validiert, dokumentiert | Datenqualität unterschätzen |
|
||||
| **4. Skalieren** | 20 % | Volles Modell läuft im Zeitlimit | Zu viele harte Bedingungen |
|
||||
| **5. Erklärbar machen** | 20 % | Report, den ein Fachanwender versteht | Ganz weglassen |
|
||||
| **6. Abnahme** | 15 % | Kriterien geprüft, Grenzen dokumentiert | „Läuft ja“ als Abnahme |
|
||||
|
||||
> **🎯 Die wichtigste Regel**
|
||||
> **Phase 2 ist nicht optional.** Ein Modell, das Sie an drei Mitarbeitenden und vier
|
||||
> Schichten nicht von Hand nachrechnen können, werden Sie an 200 Mitarbeitenden nie
|
||||
> debuggen. Jede Stunde in Phase 2 spart drei in Phase 4.
|
||||
|
||||
---
|
||||
|
||||
## P1 — Vertretungsplaner für eine Schule
|
||||
|
||||
> **Schwierigkeit:** ⭐⭐ · **Kapitel:** {ref:kap:einfuehrung}, {ref:kap:milp}, {ref:kap:cpsat}, {ref:kap:praxisfallen} · **Zeit:** ca. 15 Stunden
|
||||
|
||||
**Ausgangslage.** Morgens um 7:15 Uhr meldet sich die dritte Lehrkraft krank. Die
|
||||
stellvertretende Schulleitung hat 20 Minuten, um einen Vertretungsplan zu erstellen, der
|
||||
Qualifikationen, Arbeitszeiten, bereits geleistete Vertretungen und persönliche Wünsche
|
||||
berücksichtigt.
|
||||
|
||||
**Daten.** Stundenplan (CSV: Klasse, Tag, Stunde, Fach, Lehrkraft, Raum) —
|
||||
in diesem Projektordner erzeugen die Skripte `stundenplan*.py` genau solche Dateien und
|
||||
eignen sich als Datenquelle. Zusätzlich: Qualifikationsmatrix, Deputate,
|
||||
Abwesenheitsmeldungen.
|
||||
|
||||
**Modellskizze.**
|
||||
* Variablen: $x_{p,s} \in \{0,1\}$ — Person $p$ übernimmt Slot $s$.
|
||||
* Hart: genau eine Person je Slot; Qualifikation; keine Doppelbelegung; Höchstzahl
|
||||
Vertretungsstunden; Ruhezeiten.
|
||||
* Weich: Vorbelastung, Freistundenlöcher, Fachnähe, Wünsche, Fairness über die Woche.
|
||||
* Solver: CP-SAT.
|
||||
|
||||
**Abnahmekriterien.**
|
||||
- [ ] Läuft in unter 10 Sekunden für eine ganze Schule (60 Lehrkräfte, 30 Slots).
|
||||
- [ ] Liefert **immer** einen Plan — auch wenn nicht alle Slots besetzbar sind (Relaxation).
|
||||
- [ ] Weist je Zuweisung eine Begründung aus.
|
||||
- [ ] Ausgabe als CSV **und** als lesbare Tagestabelle.
|
||||
- [ ] Vergleich gegen die manuelle Lösung: Wie viele Wünsche werden erfüllt?
|
||||
|
||||
**Stolperfallen.**
|
||||
* Zu viele harte Regeln → `INFEASIBLE` an genau dem Tag, an dem man das System braucht.
|
||||
* Fairness nur über einen Tag statt über die Woche → dieselbe Person trifft es ständig.
|
||||
* Akzeptanz: Ohne Begründungsanzeige wird der Plan überschrieben.
|
||||
|
||||
**Erweiterungen.** Mehrtagesplanung; Raumkonflikte; Springerstunden; Schnittstelle zum
|
||||
Schulverwaltungsprogramm.
|
||||
|
||||
---
|
||||
|
||||
## P2 — Schichtplanung für ein Pflegeteam
|
||||
|
||||
> **Schwierigkeit:** ⭐⭐⭐ · **Kapitel:** {ref:kap:milp}, {ref:kap:cpsat}, {ref:kap:praxisfallen} · **Zeit:** ca. 20 Stunden
|
||||
|
||||
**Ausgangslage.** 25 Pflegekräfte, drei Schichten täglich, 28-Tage-Zyklus. Es gelten
|
||||
Arbeitszeitgesetz, Tarifvertrag, Qualifikationsmix und individuelle Wünsche.
|
||||
|
||||
**Modellskizze.**
|
||||
* Variablen: $x_{p,t,s} \in \{0,1\}$ — Person, Tag, Schicht.
|
||||
* Hart: Mindestbesetzung je Schicht **und** Qualifikationsmix (mindestens eine examinierte
|
||||
Kraft); nach Nachtschicht ≥ 2 freie Tage; höchstens 6 Arbeitstage in Folge; monatliche
|
||||
Sollstunden ± 10 %.
|
||||
* Weich: Wunschfrei, gleichmäßige Wochenendverteilung, ungeteilte Dienste, stabile
|
||||
Schichtfolgen (nicht Früh–Nacht–Früh).
|
||||
* Solver: CP-SAT mit Intervall- und Cumulative-Constraints.
|
||||
|
||||
**Abnahmekriterien.**
|
||||
- [ ] Alle gesetzlichen Regeln nachweislich eingehalten (per `assert` geprüft).
|
||||
- [ ] Wunscherfüllungsquote wird ausgewiesen und ist fair verteilt (Gini-Koeffizient{idx:Gini-Koeffizient}).
|
||||
- [ ] Läuft in unter 60 Sekunden.
|
||||
- [ ] Bei Unlösbarkeit: Notfallplan plus benannte Ursache.
|
||||
|
||||
**Stolperfallen.** Der 28-Tage-Zyklus erzeugt viele Variablen (25 × 28 × 3 = 2100) —
|
||||
Symmetriebrechung und gute Suchheuristiken werden wichtig. Fairness über einen Monat ist
|
||||
etwas anderes als Fairness über eine Woche.
|
||||
|
||||
---
|
||||
|
||||
## P3 — Tourenplanung für einen Lieferdienst
|
||||
|
||||
> **Schwierigkeit:** ⭐⭐ · **Kapitel:** {ref:kap:graphen} · **Zeit:** ca. 12 Stunden
|
||||
|
||||
**Ausgangslage.** Ein regionaler Lieferdienst fährt täglich 40–80 Adressen mit 4 Fahrzeugen
|
||||
an. Kunden haben Zeitfenster, Fahrzeuge Kapazitäten, Fahrer Arbeitszeiten.
|
||||
|
||||
**Daten.** Adressen aus einer CSV; Entfernungen über OpenStreetMap (`osmnx`, `openrouteservice`)
|
||||
oder als Luftlinie mit Umwegfaktor 1,3 als Näherung.
|
||||
|
||||
**Modellskizze.** OR-Tools Routing-Bibliothek mit Kapazitäts- und Zeitdimension;
|
||||
Metaheuristik `GUIDED_LOCAL_SEARCH`, Zeitlimit 30 Sekunden.
|
||||
|
||||
**Abnahmekriterien.**
|
||||
- [ ] Alle Kunden werden innerhalb ihrer Zeitfenster beliefert.
|
||||
- [ ] Vergleich gegen die bisherige manuelle Tourenplanung: Ersparnis in km und Minuten.
|
||||
- [ ] Kartendarstellung der Touren (`folium`).
|
||||
- [ ] Robust gegen Ausfall eines Fahrzeugs (Neuplanung in unter 30 s).
|
||||
|
||||
**Stolperfallen.** Luftlinie unterschätzt Fahrzeiten systematisch — mit realistischem
|
||||
Umwegfaktor arbeiten oder echte Routing-Distanzen holen. Zeitfenster, die physisch nicht
|
||||
erreichbar sind, führen zu „keine Lösung“ ohne Erklärung: Vorabprüfung einbauen (siehe
|
||||
{ref:kap:graphen}).
|
||||
|
||||
---
|
||||
|
||||
## P4 — Standort- und Lagernetzplanung
|
||||
|
||||
> **Schwierigkeit:** ⭐⭐⭐ · **Kapitel:** {ref:kap:milp}, {ref:kap:graphen} · **Zeit:** ca. 15 Stunden
|
||||
|
||||
**Ausgangslage.** Ein Handelsunternehmen prüft, welche von 12 möglichen Lagerstandorten
|
||||
eröffnet werden sollen, um 60 Filialen zu versorgen.
|
||||
|
||||
**Modellskizze.** Kombiniertes Standort- und Transportproblem:
|
||||
* $y_j \in \{0,1\}$ — Lager $j$ eröffnen (Fixkosten).
|
||||
* $x_{ij} \ge 0$ — Menge von Lager $j$ zu Filiale $i$.
|
||||
* Kopplung: $x_{ij} \le M\,y_j$; Kapazität je Lager; Bedarfsdeckung je Filiale.
|
||||
* Ziel: Fixkosten + Transportkosten minimieren.
|
||||
|
||||
**Abnahmekriterien.**
|
||||
- [ ] Sensitivitätsanalyse: Wie verändert sich die Lösung bei ±20 % Transportkosten?
|
||||
- [ ] Was-wäre-wenn: Standort X wird politisch vorgegeben — was kostet das?
|
||||
- [ ] Kartendarstellung mit Zuordnungslinien.
|
||||
- [ ] Amortisationsrechnung über 10 Jahre.
|
||||
|
||||
**Stolperfallen.** Big-M zu groß wählen ({ref:kap:milp}) — hier ist die Lagerkapazität die
|
||||
natürliche Wahl. Fixkosten sind einmalig, Transportkosten laufend: **Barwerte rechnen**,
|
||||
nicht einfach addieren.
|
||||
|
||||
---
|
||||
|
||||
## P5 — Produktionsplanung mit Rüstkosten
|
||||
|
||||
> **Schwierigkeit:** ⭐⭐⭐ · **Kapitel:** {ref:kap:lp}, {ref:kap:milp}, {ref:kap:cpsat} · **Zeit:** ca. 18 Stunden
|
||||
|
||||
**Ausgangslage.** Eine Fertigung produziert 15 Varianten auf 4 Maschinen. Jeder
|
||||
Produktwechsel kostet Rüstzeit; die Rüstzeit hängt von der Reihenfolge ab.
|
||||
|
||||
**Modellskizze.** Los- und Reihenfolgeplanung:
|
||||
* Variablen: Produktionsmengen, Rüstentscheidungen, Reihenfolge (Intervallvariablen).
|
||||
* Hart: Bedarfsdeckung je Periode, Maschinenkapazität, Mindestlosgrößen.
|
||||
* Ziel: Rüst- + Lager- + Fehlmengenkosten minimieren.
|
||||
* Solver: CP-SAT mit `AddNoOverlap` und reihenfolgeabhängigen Übergangszeiten.
|
||||
|
||||
**Abnahmekriterien.**
|
||||
- [ ] Gantt-Diagramm der Maschinenbelegung.
|
||||
- [ ] Vergleich gegen die aktuelle Praxis (Ersparnis in Rüststunden).
|
||||
- [ ] Schattenpreise: Welche Maschine ist der Engpass, was wäre eine zusätzliche Schicht wert?
|
||||
|
||||
---
|
||||
|
||||
## P6 — Portfolio-Rebalancer für ein Privatdepot
|
||||
|
||||
> **Schwierigkeit:** ⭐⭐⭐ · **Kapitel:** {ref:kap:finanzdaten} bis {ref:kap:handelsmaschine} · **Zeit:** ca. 20 Stunden
|
||||
>
|
||||
> ⚠️ **Keine Anlageberatung.** Dieses Projekt dient dem Methodenverständnis. Setzen Sie
|
||||
> kein echtes Geld auf ein selbstgebautes Modell, dessen Grenzen Sie nicht vollständig
|
||||
> verstehen.
|
||||
|
||||
**Ausgangslage.** Ein Privatdepot aus 10–15 ETFs und Einzeltiteln soll quartalsweise
|
||||
zurückgeführt werden — unter Berücksichtigung von Ordergebühren, Mindestordergrößen und der
|
||||
Steuerfreibetragsnutzung.
|
||||
|
||||
**Modellskizze.**
|
||||
* Variablen: Zielgewichte $w_i$; Binärvariablen für „Position wird gehandelt“.
|
||||
* Hart: Vollinvestition, keine Leerverkäufe, Positionsobergrenzen, Mindestordergröße
|
||||
(semikontinuierlich, {ref:kap:milp}), höchstens $K$ Transaktionen je Rebalancing.
|
||||
* Ziel: erwartete Rendite − Risikoterm − Transaktionskosten − Steuerwirkung.
|
||||
* Solver: CVXPY (konvexer Teil) oder MIQP für die Kardinalität.
|
||||
|
||||
**Abnahmekriterien.**
|
||||
- [ ] Toleranzband: Es wird nur gehandelt, wenn die Abweichung > 5 Prozentpunkte beträgt.
|
||||
- [ ] Alle Kosten (Ordergebühr, Spread, Steuer) sind explizit ausgewiesen.
|
||||
- [ ] Backtest über 5 Jahre mit Lookahead-Selbsttest.
|
||||
- [ ] Vergleich gegen: nie rebalancen, jährlich rebalancen, Gleichgewichtung.
|
||||
- [ ] Bericht, den ein Nicht-Fachmann versteht.
|
||||
|
||||
**Stolperfallen.** Steuern sind pfadabhängig (FIFO, Freibetrag) und passen nicht sauber in
|
||||
ein einperiodiges Modell — Näherung wählen und die Näherung **dokumentieren**. Und: Wenn
|
||||
Ihre Strategie die Gleichgewichtung nicht schlägt, ist das ein Ergebnis, kein Misserfolg.
|
||||
|
||||
---
|
||||
|
||||
## P7 — Risikoreport mit CVaR und Stresstests
|
||||
|
||||
> **Schwierigkeit:** ⭐⭐ · **Kapitel:** {ref:kap:unsicherheit}, {ref:kap:finanzdaten}, {ref:kap:cvar} · **Zeit:** ca. 12 Stunden
|
||||
|
||||
**Ausgangslage.** Für ein bestehendes Portfolio soll ein monatlicher Risikobericht entstehen.
|
||||
|
||||
**Inhalte des Berichts.**
|
||||
* VaR und CVaR auf 95 % und 99 %, historisch **und** Monte-Carlo-simuliert.
|
||||
* Risikobeiträge je Position (*marginal CVaR*): Wer treibt das Risiko?
|
||||
* Stresstests: Was passiert bei −20 % Aktienmarkt, +200 Basispunkten Zins,
|
||||
Korrelationsanstieg auf 0,9?
|
||||
* Historische Szenarien nachspielen (2008, März 2020).
|
||||
* Konzentrationskennzahlen (Herfindahl-Index{idx:Herfindahl-Index}, effektive Titelzahl).
|
||||
|
||||
**Abnahmekriterien.**
|
||||
- [ ] Bericht als PDF, automatisch erzeugt.
|
||||
- [ ] Alle Kennzahlen mit zwei unabhängigen Methoden berechnet und verglichen.
|
||||
- [ ] Klartext-Zusammenfassung: „Im schlechtesten Prozent der Monate verlieren Sie
|
||||
typischerweise X €.“
|
||||
|
||||
**Stolperfallen.** Die Wurzel-Zeit-Regel bei CVaR ({ref:kap:cvar}) — nicht blind anwenden.
|
||||
Korrelationen sind in Krisen andere als im Mittel: Stresstest mit erhöhten Korrelationen
|
||||
rechnen.
|
||||
|
||||
---
|
||||
|
||||
## P9 — Wenn der Solver aussteigt: Tourenplanung in Echtgröße
|
||||
|
||||
> **Schwierigkeit:** ⭐⭐⭐ · **Kapitel:** {ref:kap:graphen}, {ref:kap:metaheuristiken} · **Zeit:** ca. 18 Stunden
|
||||
|
||||
**Ausgangslage.** P3 hat funktioniert — mit 30 Kunden. Der Auftraggeber kommt mit 400
|
||||
zurück, und derselbe Code liefert nach zwanzig Minuten noch keine Lösung. Das ist keine
|
||||
Panne, sondern der erwartbare Umschlagpunkt.
|
||||
|
||||
**Modellskizze.** Zwei Verfahren am **selben** Problem, verglichen unter **gleichem
|
||||
Zeitbudget**:
|
||||
* Exakt: MILP oder CP-SAT wie in P3, mit Zeitlimit und protokolliertem Gap.
|
||||
* Heuristisch: Startlösung mit einer Faustregel, dann Verbesserung durch Simulated
|
||||
Annealing oder Large Neighborhood Search ({ref:kap:metaheuristiken}).
|
||||
* Messgröße ist nicht „wer gewinnt", sondern **ab welcher Instanzgröße** sich das Blatt
|
||||
wendet.
|
||||
|
||||
**Abnahmekriterien.**
|
||||
- [ ] Eine Tabelle über mindestens vier Instanzgrößen: exakt gegen heuristisch, jeweils
|
||||
Zielwert und Laufzeit.
|
||||
- [ ] Der Umschlagpunkt ist auf ±50 Kunden eingegrenzt und **benannt**.
|
||||
- [ ] Bei kleinen Instanzen wird gegen die bewiesen optimale Lösung geprüft — sonst weiß
|
||||
niemand, wie gut die Heuristik wirklich ist.
|
||||
- [ ] Fester Seed, reproduzierbarer Lauf; das Zugbudget ist fix, nicht die Uhrzeit.
|
||||
|
||||
> ⚠️ **Die Falle, in die hier fast jeder tappt:** die Heuristik nur auf großen Instanzen zu
|
||||
> testen, wo man das Optimum nicht kennt. Dann sieht jede Lösung gut aus.
|
||||
|
||||
---
|
||||
|
||||
## P10 — Zwei Ziele, eine Entscheidung: Kosten gegen CO₂
|
||||
|
||||
> **Schwierigkeit:** ⭐⭐ · **Kapitel:** {ref:kap:milp}, {ref:kap:mehrziel} · **Zeit:** ca. 14 Stunden
|
||||
|
||||
**Ausgangslage.** Die Geschäftsführung will „günstiger **und** grüner". Beides zugleich
|
||||
gibt es nicht — und die übliche Antwort, beide Ziele mit Gewichten zu verrechnen, verdeckt
|
||||
genau die Frage, um die es geht.
|
||||
|
||||
**Modellskizze.** Ein Transport-, Beschaffungs- oder Produktionsmodell mit zwei Zielen:
|
||||
* Erst beide Ziele **einzeln** optimieren — das gibt die Eckpunkte und damit den Rahmen.
|
||||
* Dann die Front über das $\varepsilon$-Constraint-Verfahren abfahren
|
||||
({ref:kap:mehrziel}): ein Ziel minimieren, das andere als Nebenbedingung schrittweise
|
||||
verschärfen.
|
||||
* Ergebnis ist **keine Lösung, sondern eine Kurve** — plus die Angabe, was jeder eingesparte
|
||||
Kilogramm CO₂ an Mehrkosten bedeutet.
|
||||
|
||||
**Abnahmekriterien.**
|
||||
- [ ] Die Pareto-Front ist berechnet und gezeichnet, nicht nur beschrieben.
|
||||
- [ ] Für mindestens zwei Punkte ist der Aufpreis je eingesparter Einheit ausgerechnet.
|
||||
- [ ] Es ist geprüft und dokumentiert, welche Punkte der Front eine **gewichtete Summe
|
||||
niemals** finden würde — und warum.
|
||||
- [ ] Die Entscheidungsvorlage nennt drei Punkte zur Auswahl, nicht dreißig.
|
||||
|
||||
---
|
||||
|
||||
## P11 — Vom Skript zum Dienst: das Modell übergeben
|
||||
|
||||
> **Schwierigkeit:** ⭐⭐⭐ · **Kapitel:** {ref:kap:praxisfallen}, {ref:kap:testing} · **Zeit:** ca. 16 Stunden
|
||||
|
||||
**Ausgangslage.** Ihr Modell rechnet. Jetzt soll es jemand anderes benutzen — jemand, der
|
||||
weder Python noch Ihr Notebook kennt, und der es auch dann noch braucht, wenn Sie im Urlaub
|
||||
sind. Nehmen Sie **eines Ihrer eigenen** Projekte P1–P10 als Grundlage; dies ist kein neues
|
||||
Modell, sondern der Schritt danach.
|
||||
|
||||
**Modellskizze.** Vier Schichten, jede einzeln abnehmbar:
|
||||
* **Domäne:** Eingabedaten als validierte Objekte (Pydantic), nicht als lose Dictionaries —
|
||||
ein falscher Wert soll beim Einlesen auffallen, nicht im Solver ({ref:kap:praxisfallen}).
|
||||
* **Kern:** Modellaufbau und Lösung hinter einer Funktion, die ein `Loesung`-Objekt
|
||||
zurückgibt und **alle** Statusfälle behandelt.
|
||||
* **Tests:** eine Suite, die auch die Fälle abdeckt, in denen es *keine* Lösung gibt
|
||||
({ref:kap:testing}).
|
||||
* **Schnittstelle:** ein HTTP-Dienst, der ein JSON entgegennimmt und eines zurückgibt.
|
||||
|
||||
**Abnahmekriterien.**
|
||||
- [ ] Eine ungültige Eingabe wird **vor** dem Solver abgewiesen, mit einer Meldung, die den
|
||||
Fehler benennt.
|
||||
- [ ] Die Testsuite läuft grün und enthält mindestens einen INFEASIBLE-Fall.
|
||||
- [ ] Ein Mutationstest zeigt, dass die Tests eine absichtlich eingebaute Modelländerung
|
||||
auch wirklich bemerken.
|
||||
- [ ] Der Dienst antwortet auf eine Beispielanfrage per `curl` — dokumentiert samt Aufruf.
|
||||
- [ ] Es gibt eine Betriebsseite: Was tun, wenn der Solver `INFEASIBLE` meldet? Der
|
||||
Notfallplan gehört dazu, nicht die Fehlermeldung ({ref:anhang:fehlerdiagnose}).
|
||||
|
||||
> **🎯 Der Prüfstein**
|
||||
> Geben Sie das Projekt einer Person, die nicht dabei war, zusammen mit dem README — und
|
||||
> sagen Sie nichts. Was sie nicht allein zum Laufen bringt, ist nicht fertig.
|
||||
|
||||
---
|
||||
|
||||
## P8 — Freies Projekt aus Ihrem eigenen Umfeld
|
||||
|
||||
> **Schwierigkeit:** offen · **Zeit:** 10–25 Stunden
|
||||
|
||||
**Der Auftrag.** Nehmen Sie die Notiz aus der Aufgabe *Eigenes Problem zerlegen* ({ref:kap:einfuehrung}) hervor — das Problem aus Ihrem
|
||||
eigenen Alltag oder Beruf. Bauen Sie es.
|
||||
|
||||
**Bewährte Kandidaten aus früheren Kursen:**
|
||||
|
||||
| Problem | Verfahren | Kapitel |
|
||||
| --- | --- | --- |
|
||||
| Sitzordnung für eine Hochzeit (Sympathien/Antipathien) | CP-SAT | {ref:kap:cpsat} |
|
||||
| Trainingsplan eines Sportvereins (Hallen, Trainer, Altersgruppen) | CP-SAT | {ref:kap:cpsat} |
|
||||
| Budgetaufteilung auf Projekte mit Abhängigkeiten | MILP | {ref:kap:milp} |
|
||||
| Schnittoptimierung für Zuschnitt (Holz, Blech, Stoff) | MILP / Spaltengenerierung | {ref:kap:dekomposition} |
|
||||
| Speiseplan unter Nährwert- und Budgetgrenzen | LP | {ref:kap:lp} |
|
||||
| Prüfungsplanung (keine Kollisionen, Erholungspausen) | CP-SAT | {ref:kap:cpsat} |
|
||||
| Ladeplanung für E-Fahrzeugflotte (Lastspitzen vermeiden) | MILP + Cumulative | {ref:kap:milp}, {ref:kap:cpsat} |
|
||||
| Bewässerungsplan im Kleingarten (Wasser, Wetterprognose) | Stochastisch | {ref:kap:unsicherheit} |
|
||||
|
||||
**Abnahmekriterien (für jedes freie Projekt).**
|
||||
- [ ] Die Bausteine-Vorlage ist ausgefüllt und liegt dem Projekt bei.
|
||||
- [ ] Eine Kleinstinstanz ist von Hand nachgerechnet und stimmt mit dem Solver überein.
|
||||
- [ ] Alle Nebenbedingungen werden nach dem Lösen per `assert` geprüft.
|
||||
- [ ] Es gibt einen Vergleich gegen die bisherige (manuelle) Lösung — mit Zahlen.
|
||||
- [ ] Das Ergebnis ist erklärbar: Warum diese Lösung, warum nicht die naheliegende
|
||||
Alternative, was würde sie verbessern?
|
||||
- [ ] Die Grenzen des Modells sind dokumentiert: Was bildet es **nicht** ab?
|
||||
|
||||
---
|
||||
|
||||
## Eine Bitte zum Schluss
|
||||
|
||||
Wenn Sie ein Projekt fertig haben, machen Sie zwei Dinge:
|
||||
|
||||
**Erstens: Zeigen Sie es jemandem, der nichts von Optimierung versteht.** Wenn diese Person
|
||||
nach fünf Minuten sagen kann, was das Programm tut und warum sie dem Ergebnis trauen sollte,
|
||||
haben Sie es richtig gemacht. Wenn nicht, fehlt die Erklärbarkeit — nicht das Verständnis
|
||||
Ihres Gegenübers.
|
||||
|
||||
**Zweitens: Schreiben Sie auf, was schiefgegangen ist.** Jeder, der optimiert, produziert
|
||||
Fehler wie die in diesem Buch besprochenen (siehe z. B.
|
||||
{ref:sec:lp-die-vorzeichenfalle-bei-schattenpreisen} oder {ref:anhang:fehlerdiagnose}).
|
||||
Wer sie dokumentiert, macht sie kein zweites Mal — und hilft dem Nächsten.
|
||||
|
||||
Viel Erfolg.
|
||||
|
||||
---
|
||||
|
||||
*Weiter mit:* [{ref:anhang:loesungen} — Lösungen](90_Anhang_Loesungen.md)
|
||||
3083
Operations_Research_mit_Python_Version_04/90_Anhang_Loesungen.md
Normal file
3083
Operations_Research_mit_Python_Version_04/90_Anhang_Loesungen.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,436 @@
|
|||
# Anhang: Katalog der Modellierungsmuster {#anhang:modellierungsmuster}
|
||||
|
||||
> **Wofür dieser Anhang gedacht ist:** Sie sitzen vor einem konkreten Problem und wissen
|
||||
> nicht, wie Sie eine bestimmte Regel in ein Modell bekommen. Suchen Sie hier das Muster,
|
||||
> das zu Ihrer Formulierung passt. Jeder Eintrag nennt die Regel in Alltagssprache, die
|
||||
> mathematische Formulierung, den Code und die Fallstricke.
|
||||
|
||||
---
|
||||
|
||||
## Übersicht
|
||||
|
||||
| # | Muster | Umgangssprachlich | Wo im Buch |
|
||||
| --- | --- | --- | --- |
|
||||
| **Logische Schalter** |
|
||||
| B1 | Aktivierungsschalter | „Wenn genutzt, dann Fixkosten“ | {ref:kap:milp} |
|
||||
| B2 | Semikontinuierlich | „Entweder 0 oder mindestens L“ | {ref:kap:milp} |
|
||||
| B3 | Implikation | „Wenn A, dann auch B“ | {ref:kap:milp} |
|
||||
| B4 | Entweder-Oder | „A oder B, aber nicht beides“ | {ref:kap:milp} |
|
||||
| B5 | Exklusiv-Oder | „Genau eines von N“ | {ref:kap:cpsat} |
|
||||
| B6 | Kardinalität | „Höchstens K von N“ | {ref:kap:milp} |
|
||||
| B7 | Bedingte Kopplung | „Wenn A und B, dann C“ | {ref:kap:milp} |
|
||||
| **Mengen und Grenzen** |
|
||||
| B8 | Weiche Grenze | „Möglichst nicht über X“ | {ref:kap:cpsat} |
|
||||
| B9 | Gestaffelte Preise | „Erste 100 Stück billiger“ | — |
|
||||
| B10 | Absolutbetrag | „Abweichung nach oben wie unten“ | {ref:kap:cvar} |
|
||||
| B11 | Min/Max in der Zielfunktion | „Den Schlechtesten verbessern“ | {ref:kap:cpsat} |
|
||||
| B12 | Verhältnis-Bedingung | „Anteil mindestens 30 %“ | {ref:kap:markowitz} |
|
||||
| B25 | Mindestabnahme im Zeitraum | „Entweder gar nicht oder 500 im Jahr“ | {ref:kap:bruecke} |
|
||||
| B27 | Budgetlimit | „Mehr als 2 Mio. gibt es nicht“ | {ref:kap:milp} |
|
||||
| **Zeit und Reihenfolge** |
|
||||
| B13 | Vorrangbeziehung | „B erst nach A“ | {ref:kap:cpsat} |
|
||||
| B14 | Nichtüberlappung | „Eine Maschine, ein Job“ | {ref:kap:cpsat} |
|
||||
| B15 | Kumulative Ressource | „Höchstens 3 gleichzeitig“ | {ref:kap:cpsat} |
|
||||
| B16 | Gleitendes Fenster | „Höchstens 5 Tage in Folge“ | {ref:kap:cpsat} |
|
||||
| B17 | Umrüstkosten | „Wechsel kostet extra“ | {ref:kap:cpsat} |
|
||||
| B26 | Rüstzeit als Kapazität | „Umbauen kostet Maschinenstunden“ | {ref:kap:supplychain} |
|
||||
| **Robustheit und Diagnose** |
|
||||
| B18 | Schlupf gegen Unlösbarkeit | „Regel notfalls brechen“ | {ref:kap:praxisfallen} |
|
||||
| B19 | Hierarchische Ziele | „Erst A, dann B optimieren“ | {ref:kap:praxisfallen} |
|
||||
| B20 | Symmetriebrechung | „Gleiche Objekte nicht doppelt zählen“ | {ref:kap:dekomposition} |
|
||||
| B21 | Worst-Case-Abzug | „Gegen Schätzfehler absichern“ | {ref:kap:unsicherheit} |
|
||||
| **Netzwerke** |
|
||||
| B22 | Flusserhaltung | „Was reinkommt, geht raus“ | {ref:kap:graphen} |
|
||||
| B23 | Zuordnung 1:1 | „Jeder genau eine Aufgabe“ | {ref:kap:graphen} |
|
||||
| B24 | Subtour-Eliminierung | „Keine isolierten Kreise“ | {ref:kap:graphen} |
|
||||
|
||||
> Die Spalte „Wo im Buch“ nennt das Kapitel, in dem das Muster **im Zusammenhang**
|
||||
> vorkommt — nicht die einzige Stelle, an der es taugt. Die Nummern entstehen beim Bauen;
|
||||
> im Quelltext dieses Anhangs steht keine einzige.
|
||||
|
||||
---
|
||||
|
||||
## Logische Schalter
|
||||
|
||||
### B1 — Aktivierungsschalter (Fixkosten)
|
||||
|
||||
**Regel.**{idx:Aktivierungsschalter (Modellierungsmuster)} „Wenn überhaupt etwas produziert wird, fallen Rüstkosten $F$ an.“
|
||||
|
||||
$$x \le M\,y,\qquad y\in\{0,1\},\ x\ge0$$
|
||||
Zielfunktion: $\dots + F\,y$
|
||||
|
||||
```python
|
||||
modell.Add(x <= M * y) # CP-SAT
|
||||
# LP/MILP: Zeile x - M*y <= 0
|
||||
```
|
||||
|
||||
> ⚠️ **$M$ so klein wie möglich** — idealerweise die ohnehin vorhandene Kapazitätsgrenze
|
||||
> von $x$. Zu großes $M$ macht die LP-Relaxation wertlos ({ref:sec:milp-modellierungstricks-big-m-und-logische-bedingungen}).
|
||||
|
||||
### B2 — Semikontinuierliche Variable
|
||||
|
||||
**Regel.**{idx:Semikontinuierliche Variable} „Entweder gar nicht oder mindestens $L$ (und höchstens $U$).“
|
||||
|
||||
$$L\,y \le x \le U\,y,\qquad y\in\{0,1\}$$
|
||||
|
||||
Typisch für Mindestordergrößen, Mindestlosgrößen, Mindestabnahmemengen.
|
||||
|
||||
### B3 — Implikation
|
||||
|
||||
**Regel.**{idx:Implikation (Modellierungsmuster)} „Wenn A gewählt wird, muss auch B gewählt werden.“
|
||||
|
||||
$$y_A \le y_B$$
|
||||
|
||||
```python
|
||||
modell.AddImplication(y_a, y_b) # CP-SAT, gleichwertig und lesbarer
|
||||
```
|
||||
|
||||
Für die Umkehrung („nur wenn“) einfach vertauschen. Für Äquivalenz: $y_A = y_B$.
|
||||
|
||||
### B4 — Entweder-Oder (disjunktive Bedingung)
|
||||
|
||||
**Regel.**{idx:Entweder-Oder-Bedingung} „Es muss $f(\mathbf{x}) \le b_1$ **oder** $g(\mathbf{x}) \le b_2$ gelten.“
|
||||
|
||||
$$f(\mathbf{x}) \le b_1 + M(1-y),\qquad g(\mathbf{x}) \le b_2 + M y$$
|
||||
|
||||
```python
|
||||
# CP-SAT: viel eleganter ueber Reifizierung
|
||||
modell.Add(f_ausdruck <= b1).OnlyEnforceIf(y)
|
||||
modell.Add(g_ausdruck <= b2).OnlyEnforceIf(y.Not())
|
||||
```
|
||||
|
||||
### B5 — Exklusiv-Oder
|
||||
|
||||
**Regel.**{idx:Exklusiv-Oder} „Genau eine der Optionen wird gewählt.“
|
||||
|
||||
$$\sum_{j=1}^N y_j = 1$$
|
||||
|
||||
```python
|
||||
modell.AddExactlyOne(y) # bzw. AddAtMostOne / AddBoolOr
|
||||
```
|
||||
|
||||
### B6 — Kardinalität
|
||||
|
||||
**Regel.**{idx:Kardinalitätsbeschränkung} „Höchstens (mindestens, genau) $K$ von $N$.“
|
||||
|
||||
$$\sum_j y_j \le K \qquad(\ \ge K,\ = K\ )$$
|
||||
|
||||
### B7 — Bedingte Kopplung (Konjunktion)
|
||||
|
||||
**Regel.**{idx:Bedingte Kopplung (Konjunktion)} „Wenn A **und** B, dann auch C.“
|
||||
|
||||
$$y_A + y_B - 1 \le y_C$$
|
||||
|
||||
Prüfen Sie die vier Fälle: nur bei $y_A = y_B = 1$ erzwingt die Ungleichung $y_C \ge 1$.
|
||||
|
||||
Für „Wenn A **oder** B, dann C“: $y_A \le y_C$ **und** $y_B \le y_C$.
|
||||
|
||||
---
|
||||
|
||||
## Mengen und Grenzen
|
||||
|
||||
### B8 — Weiche Grenze mit Strafkosten
|
||||
|
||||
**Regel.**{idx:Weiche Grenze mit Strafkosten} „Möglichst nicht über $b$ — wenn doch, kostet es.“
|
||||
|
||||
$$f(\mathbf{x}) \le b + s,\qquad s \ge 0$$
|
||||
Zielfunktion: $\dots + c_{\text{Strafe}}\cdot s$
|
||||
|
||||
Das ist das wichtigste Muster überhaupt für praxistaugliche Modelle (siehe B18).
|
||||
|
||||
### B9 — Gestaffelte Preise (stückweise linear)
|
||||
|
||||
**Regel.**{idx:Gestaffelte Preise (stückweise linear)} „Die ersten 100 Stück kosten 5 €, danach 8 €.“
|
||||
|
||||
Variablen je Stufe: $x = x_1 + x_2$ mit $0 \le x_1 \le 100$, $x_2 \ge 0$;
|
||||
Kosten $5x_1 + 8x_2$.
|
||||
|
||||
> ⚠️ Das funktioniert **nur bei steigenden** Preisen (konvexe Kostenfunktion) ohne
|
||||
> Binärvariablen — der Optimierer füllt dann automatisch erst die billige Stufe. Bei
|
||||
> **fallenden** Preisen (Mengenrabatt, konkav) braucht man Binärvariablen je Stufe, sonst
|
||||
> „schummelt“ das Modell.
|
||||
|
||||
### B10 — Absolutbetrag / Abweichung
|
||||
|
||||
**Regel.**{idx:Absolutbetrag (Modellierungsmuster)} „Die Abweichung vom Zielwert soll klein sein, egal in welche Richtung.“
|
||||
|
||||
$$|x - z| \le d \quad\Longleftrightarrow\quad x - z \le d\ \ \text{und}\ \ z - x \le d$$
|
||||
|
||||
In der **Zielfunktion** (Minimierung von $|x-z|$) genügt:
|
||||
$$\min d \quad\text{u. d. N.}\quad x-z \le d,\quad z-x \le d$$
|
||||
|
||||
```python
|
||||
turnover = cp.norm1(w - w_alt) # CVXPY macht das automatisch
|
||||
```
|
||||
|
||||
### B11 — Min/Max in der Zielfunktion
|
||||
|
||||
**Regel.**{idx:Maximin-Fairness} „Der am schlechtesten gestellte Beteiligte soll möglichst gut dastehen“
|
||||
(Maximin/Fairness).
|
||||
|
||||
$$\max t \quad\text{u. d. N.}\quad t \le f_i(\mathbf{x})\ \ \forall i$$
|
||||
|
||||
```python
|
||||
modell.AddMaxEquality(max_var, liste) # CP-SAT
|
||||
modell.AddMinEquality(min_var, liste)
|
||||
# Fairness ueber die Spannweite:
|
||||
modell.Add(spannweite == max_var - min_var) # dann minimieren
|
||||
```
|
||||
|
||||
### B12 — Verhältnis-Bedingung
|
||||
|
||||
**Regel.**{idx:Verhältnis-Bedingung} „Der Anteil von Gruppe G soll mindestens 30 % betragen.“
|
||||
|
||||
$$\frac{\sum_{i\in G} x_i}{\sum_i x_i} \ge 0{,}3
|
||||
\quad\Longleftrightarrow\quad
|
||||
\sum_{i\in G} x_i \ge 0{,}3\sum_i x_i$$
|
||||
|
||||
> ⚠️ **Brüche immer wegmultiplizieren** — ein Quotient von Variablen ist nichtlinear und
|
||||
> meist nicht konvex. Nach dem Umstellen ist die Bedingung linear.
|
||||
|
||||
### B25 — Mindestabnahmemenge über einen Zeitraum
|
||||
|
||||
**Regel.**{idx:Mindestabnahmemenge} „Entweder wir arbeiten mit diesem Lieferanten gar nicht, oder wir nehmen
|
||||
ihm im Jahr mindestens 500 Einheiten ab.“
|
||||
|
||||
Ein **Schalter für den ganzen Zeitraum**, nicht je Periode:
|
||||
|
||||
$$Q\,y \le \sum_{t} x_{t} \le U\,y, \qquad y \in \{0,1\},\ x_t \ge 0$$
|
||||
|
||||
```python
|
||||
# y_s ist EINE Variable je Lieferant - nicht eine je Lieferant UND Periode
|
||||
y = {s: modell.NewBoolVar(f"vertrag_{s}") for s in lieferanten}
|
||||
for s in lieferanten:
|
||||
jahresmenge = sum(x[s, t] for t in perioden)
|
||||
modell.Add(jahresmenge >= MINDESTMENGE[s] * y[s])
|
||||
modell.Add(jahresmenge <= JAHRESKAPAZITAET[s] * y[s]) # koppelt x an y
|
||||
```
|
||||
|
||||
> ⚠️ **Die häufigste Verwechslung:** $L\,y_t \le x_t$ **je Periode** (B2) ist ein *anderes*
|
||||
> Modell — es verlangt in jeder einzelnen Periode eine Mindestmenge und ist erheblich
|
||||
> strenger. Wer den Jahresvertrag so formuliert, erzeugt ein unlösbares Modell und sucht
|
||||
> den Fehler dann in den Daten.
|
||||
|
||||
> ⚠️ **Die obere Kopplung nicht vergessen.** Ohne $\sum_t x_t \le U\,y$ kann das Modell
|
||||
> $y = 0$ setzen und trotzdem einkaufen — der Vertrag gilt dann als nicht geschlossen, die
|
||||
> Ware fließt aber. Das ist der *Trickle Flow* aus
|
||||
> {ref:sec:milp-modellierungstricks-big-m-und-logische-bedingungen}, nur andersherum.
|
||||
> $U$ ist die Jahreskapazität des Lieferanten, keine runde Zahl.
|
||||
|
||||
### B27 — Budgetlimit
|
||||
|
||||
**Regel.**{idx:Budgetlimit} „Alle Maßnahmen zusammen dürfen 2 Mio. € nicht überschreiten.“
|
||||
|
||||
$$\sum_i c_i x_i \le B$$
|
||||
|
||||
Mit $x_i \in \{0,1\}$ ist das ein **Rucksackproblem**{idx:Rucksackproblem} — dasselbe
|
||||
Muster, das {ref:kap:milp} an `Rucksack.py` vorrechnet und
|
||||
{ref:kap:dekomposition} als Pricing-Teilproblem wiederverwendet.
|
||||
|
||||
```python
|
||||
modell.Add(sum(KOSTEN[i] * x[i] for i in massnahmen) <= BUDGET)
|
||||
# Mehrere Toepfe: je Topf eine Zeile - NICHT die Summe ueber alle Toepfe
|
||||
for topf, grenze in BUDGETS.items():
|
||||
modell.Add(sum(KOSTEN[i] * x[i] for i in massnahmen
|
||||
if TOPF[i] == topf) <= grenze)
|
||||
```
|
||||
|
||||
> 💡 **Der Schattenpreis des Budgets ist die Zahl, nach der die Geschäftsführung fragt:**
|
||||
> „Was bringt der nächste Euro?“ Bei einem **LP** ist er direkt ablesbar. Bei
|
||||
> **Ganzzahligkeit gibt es ihn nicht** — die Dualwerte der Relaxation sind keine gültige
|
||||
> Antwort. Rechnen Sie stattdessen mit erhöhtem Budget neu und vergleichen Sie die
|
||||
> Zielwerte ({ref:sec:lp-entartung}).
|
||||
|
||||
> ⚠️ **Die LP-Relaxation verspricht zu viel.** Sie darf die letzte Maßnahme anteilig kaufen
|
||||
> und liefert deshalb eine Schranke, die spürbar über dem tatsächlich Erreichbaren liegen
|
||||
> kann. Wer sie als Prognose berichtet, verspricht Geld, das nicht kommt.
|
||||
|
||||
---
|
||||
|
||||
## Zeit und Reihenfolge
|
||||
|
||||
### B13 — Vorrangbeziehung
|
||||
|
||||
**Regel.**{idx:Vorrangbeziehung} „Arbeitsgang B darf erst beginnen, wenn A fertig ist.“
|
||||
|
||||
$$\text{start}_B \ge \text{ende}_A$$
|
||||
|
||||
Mit Mindestwartezeit $w$: $\text{start}_B \ge \text{ende}_A + w$.
|
||||
|
||||
### B14 — Nichtüberlappung
|
||||
|
||||
**Regel.**{idx:Nichtüberlappung} „Eine Maschine bearbeitet nur einen Job gleichzeitig.“
|
||||
|
||||
```python
|
||||
modell.AddNoOverlap([intervall_1, intervall_2, ...])
|
||||
```
|
||||
|
||||
In MILP bräuchte man je Paar eine Disjunktion (B4) — bei $k$ Jobs sind das
|
||||
$\binom{k}{2}$ Konstruktionen. **Nehmen Sie hier CP-SAT.**
|
||||
|
||||
### B15 — Kumulative Ressource
|
||||
|
||||
**Regel.**{idx:Kumulative Ressource} „Zu keinem Zeitpunkt dürfen mehr als 3 Arbeiten gleichzeitig laufen“ bzw. „die
|
||||
Stromlast darf 500 kW nie übersteigen“.
|
||||
|
||||
```python
|
||||
modell.AddCumulative(intervalle, bedarfe, kapazitaet)
|
||||
```
|
||||
|
||||
### B16 — Gleitendes Fenster
|
||||
|
||||
**Regel.**{idx:Gleitendes Fenster} „Höchstens 5 Arbeitstage in Folge“ / „mindestens 2 freie Tage je Woche“.
|
||||
|
||||
$$\sum_{u=t}^{t+5} x_u \le 5 \qquad \forall t$$
|
||||
|
||||
```python
|
||||
for t in range(len(tage) - 5):
|
||||
modell.Add(sum(x[p, u] for u in range(t, t + 6)) <= 5)
|
||||
```
|
||||
|
||||
### B17 — Umrüst- bzw. Wechselkosten
|
||||
|
||||
**Regel.**{idx:Umrüstkosten (Modellierungsmuster)} „Ein Produktwechsel kostet Rüstzeit.“
|
||||
|
||||
Hilfsvariable $z_t \in \{0,1\}$ = „in Periode $t$ wird gewechselt“:
|
||||
|
||||
$$z_t \ge y_{j,t} - y_{j,t-1} \qquad \forall j, t$$
|
||||
|
||||
Zielfunktion: $\dots + c_{\text{Ruest}}\sum_t z_t$. Für **reihenfolgeabhängige**
|
||||
Rüstzeiten: `AddCircuit` mit Übergangsmatrix.
|
||||
|
||||
### B26 — Rüstzeit als Kapazitätsverbrauch
|
||||
|
||||
**Regel.**{idx:Rüstzeit} „Das Umrüsten kostet nicht nur Geld, es kostet **Maschinenstunden** — und
|
||||
die fehlen dann für die Produktion.“
|
||||
|
||||
B17 verbucht den Wechsel in der *Zielfunktion*. Sobald die Maschine ausgelastet ist, gehört
|
||||
er zusätzlich in die *Kapazitätszeile*:
|
||||
|
||||
$$\sum_j a_j\,x_{j,t} \;+\; \sum_j r_j\,y_{j,t} \;\le\; C_t \qquad \forall t$$
|
||||
|
||||
mit $a_j$ Stückzeit, $r_j$ Rüstzeit, $y_{j,t}\in\{0,1\}$ = „Produkt $j$ läuft in Periode
|
||||
$t$“ (gekoppelt über B1: $x_{j,t} \le M\,y_{j,t}$).
|
||||
|
||||
```python
|
||||
for t in perioden:
|
||||
modell.Add(sum(STUECKZEIT[j] * x[j, t] for j in produkte)
|
||||
+ sum(RUESTZEIT[j] * y[j, t] for j in produkte) <= KAPAZITAET[t])
|
||||
```
|
||||
|
||||
> ⚠️ **Wer die Rüstzeit nur als Kosten führt, erhält Pläne, die in der Halle nicht
|
||||
> laufen.** Das Modell verteilt die Produktion dann auf viele kleine Lose, weil ein
|
||||
> zusätzlicher Wechsel zwar etwas kostet, aber keine Zeit verbraucht — die Rechnung geht
|
||||
> auf dem Papier auf und in der Schicht nicht.
|
||||
|
||||
**Reihenfolgeabhängig** ($r_{ij}$ statt $r_j$ — von Weiß auf Schwarz ist schneller als
|
||||
umgekehrt) ist es kein Kapazitätsproblem mehr, sondern ein Rundreiseproblem: `AddCircuit`
|
||||
mit der Übergangsmatrix als Kantengewicht (B24, {ref:kap:cpsat}).
|
||||
|
||||
---
|
||||
|
||||
## Robustheit und Diagnose
|
||||
|
||||
### B18 — Schlupfvariablen gegen Unlösbarkeit
|
||||
|
||||
**Das wichtigste Muster für den Produktivbetrieb.**
|
||||
|
||||
**Regel.**{idx:Schlupfvariable!gegen Unlösbarkeit} „Diese Bedingung soll gelten — aber lieber ein schlechter Plan als gar keiner.“
|
||||
|
||||
$$\sum_p x_{p,s} + u_s = 1,\qquad u_s\in\{0,1\}$$
|
||||
Zielfunktion: $\dots + 10\,000\cdot u_s$
|
||||
|
||||
Die Strafe muss **hoch genug** sein, dass der Solver sie nur im Notfall in Kauf nimmt, aber
|
||||
**endlich**, damit es überhaupt eine Lösung gibt. Faustregel: eine Größenordnung über der
|
||||
Summe aller weichen Ziele.
|
||||
|
||||
### B19 — Hierarchische Ziele (lexikografisch)
|
||||
|
||||
**Regel.**{idx:Lexikografische Optimierung} „Erst die Besetzung sicherstellen, dann die Fairness optimieren.“
|
||||
|
||||
**Variante A — Gewichtung:** Strafen um Größenordnungen staffeln
|
||||
($10\,000 \gg 100 \gg 1$). Einfach, aber bei extremen Skalenunterschieden numerisch heikel.
|
||||
|
||||
**Variante B — Zweistufig lösen (sauberer):**
|
||||
```python
|
||||
# Stufe 1: nur das Hauptziel
|
||||
modell.Minimize(unbesetzte_schichten)
|
||||
loeser.Solve(modell)
|
||||
bestwert = loeser.ObjectiveValue()
|
||||
|
||||
# Stufe 2: Hauptziel fixieren, Nebenziel optimieren
|
||||
modell.Add(unbesetzte_schichten <= int(bestwert))
|
||||
modell.Minimize(unfairness)
|
||||
loeser.Solve(modell)
|
||||
```
|
||||
|
||||
### B20 — Symmetriebrechung
|
||||
|
||||
**Regel.**{idx:Symmetriebrechung} „Drei identische Maschinen — der Solver soll nicht alle Vertauschungen
|
||||
durchprobieren.“
|
||||
|
||||
$$\text{start}_1 \le \text{start}_2 \le \text{start}_3
|
||||
\qquad\text{bzw.}\qquad \sum_j x_{1j} \ge \sum_j x_{2j}$$
|
||||
|
||||
Ohne Symmetriebrechung durchsucht Branch-and-Bound $k!$ gleichwertige Lösungen. **Eine
|
||||
einzige Ordnungsbedingung kann die Laufzeit um Größenordnungen senken.**
|
||||
|
||||
### B21 — Worst-Case-Abzug (robuste Formulierung)
|
||||
|
||||
**Regel.**{idx:Robuste Optimierung!Worst-Case-Abzug} „Rechne nicht mit dem geschätzten Wert, sondern mit dem ungünstigsten
|
||||
plausiblen.“
|
||||
|
||||
Bei Box-Unsicherheit $\mu_i \in [\hat\mu_i - \delta_i, \hat\mu_i + \delta_i]$ und
|
||||
$w \ge 0$:
|
||||
$$\hat{\boldsymbol{\mu}}^\top\mathbf{w} \;\longrightarrow\;
|
||||
\hat{\boldsymbol{\mu}}^\top\mathbf{w} - \boldsymbol{\delta}^\top\mathbf{w}$$
|
||||
|
||||
Abgestuft (Bertsimas/Sim): nur die $\Gamma$ ungünstigsten gleichzeitig:
|
||||
```python
|
||||
abzug = cp.sum_largest(cp.multiply(delta, w), Gamma)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Netzwerke
|
||||
|
||||
### B22 — Flusserhaltung
|
||||
|
||||
$$\sum_{j:(i,j)\in E} x_{ij} - \sum_{k:(k,i)\in E} x_{ki} = b_i \qquad \forall i$$
|
||||
|
||||
{idx:Flusserhaltung}$b_i > 0$ Quelle, $b_i < 0$ Senke, $b_i = 0$ Umschlagknoten. **Voraussetzung:**
|
||||
$\sum_i b_i = 0$ — sonst unlösbar (Dummy-Knoten einführen).
|
||||
|
||||
### B23 — Zuordnung 1:1
|
||||
|
||||
{idx:Zuordnungsproblem (1:1)}
|
||||
|
||||
$$\sum_j x_{ij} = 1\ \forall i,\qquad \sum_i x_{ij} = 1\ \forall j,\qquad x_{ij} \ge 0$$
|
||||
|
||||
> 💡 **Ganzzahligkeit nicht fordern!** Die Matrix ist total unimodular; ein LP-Solver liefert
|
||||
> automatisch 0/1-Lösungen. Für reine Zuordnungen ist
|
||||
> `scipy.optimize.linear_sum_assignment` (Ungarischer Algorithmus, $O(n^3)$) noch deutlich
|
||||
> schneller.
|
||||
|
||||
### B24 — Subtour-Eliminierung
|
||||
|
||||
**MTZ (einfach, aber schwach):**{idx:MTZ-Formulierung}
|
||||
$$u_i - u_j + C x_{ij} \le C - d_j \qquad \forall i\ne j$$
|
||||
|
||||
**Besser in der Praxis:**{idx:Subtour!Eliminierung} `AddCircuit` in CP-SAT oder die Routing-Bibliothek von OR-Tools.
|
||||
|
||||
---
|
||||
|
||||
## Ein Wort zur Auswahl
|
||||
|
||||
Wenn mehrere Muster passen, entscheiden Sie nach dieser Reihenfolge:
|
||||
|
||||
1. **Gibt es ein globales Constraint dafür?** (`AddAllDifferent`, `AddNoOverlap`,
|
||||
`AddCumulative`, `AddCircuit`) → nehmen Sie es. Es propagiert stärker und ist lesbarer.
|
||||
2. **Kommt man ohne Big-M aus?** (z. B. B3 statt B4) → ja, dann so.
|
||||
3. **Muss es wirklich hart sein?** → sonst B8/B18.
|
||||
4. **Ist $M$ so klein wie möglich?** → prüfen.
|
||||
|
||||
---
|
||||
|
||||
*Weiter mit:* [{ref:anhang:fehlerdiagnose} — Fehlerdiagnose](92_Anhang_Fehlerdiagnose.md)
|
||||
|
|
@ -0,0 +1,796 @@
|
|||
# Anhang: Fehlerdiagnose-Handbuch {#anhang:fehlerdiagnose}
|
||||
|
||||
> **Wofür dieser Anhang gedacht ist:** Etwas funktioniert nicht. Suchen Sie unten das
|
||||
> Symptom, das Sie beobachten, und arbeiten Sie die Diagnoseschritte der Reihe nach ab.
|
||||
>
|
||||
> **Programme:**\
|
||||
> `Konfliktsuche.py`
|
||||
|
||||
---
|
||||
|
||||
## Symptom-Schnellübersicht
|
||||
|
||||
| Symptom | Abschnitt |
|
||||
| --- | --- |
|
||||
| Solver meldet `INFEASIBLE` | [C1](#c1-infeasible) |
|
||||
| `INFEASIBLE`, aber keine einzelne Bedingung ist schuld | [C1](#c1-infeasible), Deletion Filter |
|
||||
| Solver meldet `UNBOUNDED` | [C2](#c2-unbounded) |
|
||||
| Solver läuft ewig / Timeout | [C3](#c3-zu-langsam) |
|
||||
| Ergebnis ist offensichtlich unsinnig | [C4](#c4-unsinniges-ergebnis) |
|
||||
| Ergebnis ändert sich bei kleinsten Datenänderungen stark | [C5](#c5-instabile-lösung) |
|
||||
| Schattenpreise sehen falsch aus | [C6](#c6-falsche-dualwerte) |
|
||||
| Zwei Solver liefern verschiedene Ergebnisse | [C7](#c7-widersprüchliche-solver) |
|
||||
| CVXPY wirft `DCPError` | [C8](#c8-dcperror) |
|
||||
| `ImportError` bei ortools/highspy | [C9](#c9-importfehler) |
|
||||
| Backtest sieht zu gut aus | [C10](#c10-verdächtig-guter-backtest) |
|
||||
| Ergebnisse sind falsch beschriftet | [C11](#c11-vertauschte-spalten) |
|
||||
|
||||
---
|
||||
|
||||
## C1 — INFEASIBLE
|
||||
|
||||
**Bedeutung.**{idx:Infeasibility} Es gibt **keinen einzigen Punkt**, der alle Bedingungen gleichzeitig erfüllt.
|
||||
Das ist eine Aussage über Ihr Modell, nicht über den Solver.
|
||||
|
||||
### Diagnose in fünf Schritten
|
||||
|
||||
**Schritt 1 — Trivialprüfungen von Hand.**
|
||||
Rechnen Sie die offensichtlichen Bilanzen nach, **bevor** Sie den Solver befragen:
|
||||
|
||||
```python
|
||||
assert summe_angebot >= summe_bedarf, "Angebot deckt Bedarf nicht"
|
||||
assert anzahl_personal * max_schichten >= anzahl_schichten, "zu wenig Personal"
|
||||
for slot, fach in enumerate(faecher):
|
||||
qualifiziert = [p for p in personal if fach in qualifikation[p]]
|
||||
assert qualifiziert, f"Fuer Slot {slot} ({fach}) gibt es niemanden"
|
||||
```
|
||||
Erfahrungsgemäß findet dieser Schritt **die Mehrzahl** aller Fälle.
|
||||
|
||||
**Schritt 2 — Bedingungen einzeln abschalten.**
|
||||
Kommentieren Sie Bedingungsgruppen nacheinander aus. Wird das Modell lösbar, ist die zuletzt
|
||||
entfernte Gruppe (mit-)verantwortlich.
|
||||
|
||||
**Schritt 3 — Schlupfvariablen einbauen (das wirksamste Mittel).**
|
||||
```python
|
||||
# statt: modell.Add(bedingung)
|
||||
schlupf = modell.NewIntVar(0, obergrenze, f"schlupf_{name}")
|
||||
modell.Add(linke_seite <= rechte_seite + schlupf)
|
||||
strafen.append(10_000 * schlupf)
|
||||
```
|
||||
Nach dem Lösen zeigen die Schlupfvariablen mit Wert > 0 **präzise, welche Bedingung** wo und
|
||||
um wie viel verletzt werden musste. Das ist zugleich die produktionstaugliche Lösung
|
||||
(Muster B18).
|
||||
|
||||
**Schritt 4 — Zeitfenster und Erreichbarkeit prüfen.**
|
||||
Bei Scheduling und Routing: Ist jeder Termin überhaupt physisch erreichbar?
|
||||
```python
|
||||
for kunde, (frueh, spaet) in enumerate(zeitfenster):
|
||||
assert distanz[depot][kunde] <= spaet, f"Kunde {kunde} nicht rechtzeitig erreichbar"
|
||||
```
|
||||
|
||||
**Schritt 5 — Rundungs- und Einheitenfehler.**
|
||||
Sind Kapazitäten in Stunden, Verbräuche aber in Minuten? Rechnen CP-SAT-Modelle mit ganzen
|
||||
Zahlen, wo Sie Nachkommastellen brauchen? Ein klassischer Fall: `sum(w) == 1` mit
|
||||
Gleitkommazahlen — nutzen Sie eine Toleranz oder rechnen Sie in Ganzzahlen (Promille).
|
||||
|
||||
### Wenn die fünf Schritte nicht reichen: den Konflikt einkreisen
|
||||
|
||||
**Warum Schritt 2 so oft ins Leere läuft.** Bedingungen einzeln abzuschalten funktioniert
|
||||
nur, solange es *einen* Schuldigen gibt. Überlagern sich zwei unabhängige Widersprüche,
|
||||
bleibt das Modell nach jeder einzelnen Abschaltung unlösbar — die Suche endet mit null
|
||||
Treffern, obwohl beide Widersprüche unverändert im Modell stehen. Genau dann steht man
|
||||
vor einem Modell mit tausenden Restriktionen und einem Statuscode.
|
||||
|
||||
**Der Deletion Filter**{idx:Deletion Filter} dreht die Frage um. Er fragt nicht „ist
|
||||
*diese* Bedingung schuld?“, sondern „wird *diese* Bedingung für den Widerspruch
|
||||
überhaupt gebraucht?“ — und das ist eine Frage, die sich beantworten lässt:
|
||||
|
||||
> Nimm eine Bedingung versuchsweise heraus. Bleibt der Rest unlösbar, wurde sie nicht
|
||||
> gebraucht: weg damit, endgültig. Wird der Rest lösbar, war sie beteiligt: sie bleibt.
|
||||
|
||||
Nach genau einem Durchlauf über alle $n$ Bedingungen ist die übrig gebliebene Menge
|
||||
**unreduzierbar** — entfernt man aus ihr irgendeine Bedingung, ist der Rest lösbar. Das
|
||||
ist ein *Irreducible Infeasible Subset*{idx:IIS (Irreducible Infeasible Subset)}, kurz
|
||||
IIS. Kommerzielle Solver bieten das als fertige Funktion an (`computeIIS` bei Gurobi, der
|
||||
Conflict Refiner bei CPLEX); im Open-Source-Werkzeugkasten dieses Buchs gibt es sie nicht
|
||||
— sie ist aber in zwanzig Zeilen selbst geschrieben, und sie kostet $n$ Aufrufe des
|
||||
Solvers statt $2^n$ durchprobierter Teilmengen.
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Konfliktsuche.py
|
||||
"""
|
||||
Anhang Fehlerdiagnose: Den Konflikt finden, der INFEASIBLE verursacht.
|
||||
|
||||
Ein Deletion Filter (auch: IIS, Irreducible Infeasible Subset) isoliert aus
|
||||
einem unloesbaren Modell eine kleinste widerspruechliche Teilmenge von
|
||||
Bedingungen: Nimmt man aus ihr auch nur eine einzige Bedingung heraus, ist
|
||||
der Rest wieder loesbar.
|
||||
|
||||
Das Programm zeigt sechs Dinge:
|
||||
1. Der Solver meldet INFEASIBLE - und sonst nichts.
|
||||
2. Die naive Suche ("jede Bedingung einmal weglassen") findet hier gar nichts.
|
||||
3. Der Deletion Filter findet einen Konflikt in n Solveraufrufen.
|
||||
4. Ein Konflikt ist selten einer: nach der Reparatur folgt der naechste.
|
||||
5. "Den" kleinsten Konflikt gibt es nicht - dieses Modell enthaelt neun.
|
||||
6. Welchen davon man zu sehen bekommt, steuert die Pruefreihenfolge.
|
||||
|
||||
Abgrenzung zu Infeasibility_Diagnose.py (Kapitel Praxisfallen): Dort geht es
|
||||
um die Relaxation - das Modell soll trotz Widerspruch eine brauchbare Antwort
|
||||
liefern. Hier geht es um die Diagnose - welche Bedingungen widersprechen sich
|
||||
ueberhaupt. Beides zusammen ergibt den Umgang mit INFEASIBLE in Produktion.
|
||||
|
||||
Benoetigt: numpy, scipy
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.optimize import linprog
|
||||
|
||||
PRODUKTE = ["Rahmen", "Gehaeuse", "Deckel", "Traeger", "Halter"]
|
||||
|
||||
# Jede Bedingung traegt einen sprechenden Namen - das ist keine Kosmetik,
|
||||
# sondern die Voraussetzung dafuer, dass der Befund lesbar wird.
|
||||
# (Name, Koeffizienten je Produkt, Richtung, rechte Seite)
|
||||
BEDINGUNGEN: list[tuple[str, list[float], str, float]] = [
|
||||
("Kapazitaet Montage", [3, 2, 1, 4, 2], "<=", 400),
|
||||
("Kapazitaet Lackieren", [2, 3, 0, 1, 0], "<=", 150),
|
||||
("Kapazitaet Pruefung", [1, 1, 1, 1, 1], "<=", 300),
|
||||
("Liefervertrag Rahmen", [1, 0, 0, 0, 0], ">=", 40),
|
||||
("Liefervertrag Gehaeuse", [0, 1, 0, 0, 0], ">=", 30),
|
||||
("Liefervertrag Deckel", [0, 0, 1, 0, 0], ">=", 20),
|
||||
("Liefervertrag Traeger", [0, 0, 0, 1, 0], ">=", 25),
|
||||
("Marktgrenze Rahmen", [1, 0, 0, 0, 0], "<=", 120),
|
||||
("Marktgrenze Gehaeuse", [0, 1, 0, 0, 0], "<=", 90),
|
||||
("Marktgrenze Deckel", [0, 0, 1, 0, 0], "<=", 150),
|
||||
("Marktgrenze Halter", [0, 0, 0, 0, 1], "<=", 200),
|
||||
("Mindestumsatz", [90, 70, 30, 60, 20], ">=", 12000),
|
||||
("Sortimentsbreite", [0, 0, 1, 1, 1], ">=", 100),
|
||||
("Lackierbudget Schicht 2", [0, 1, 0, 1, 0], "<=", 55),
|
||||
]
|
||||
|
||||
# Welche Bedingungen koennte ein Planer im Ernstfall wirklich veraendern?
|
||||
# Kapazitaeten lassen sich durch Sonderschichten dehnen, Liefervertraege und
|
||||
# Marktgrenzen nicht.
|
||||
VERHANDELBAR = {"Kapazitaet Montage", "Kapazitaet Lackieren",
|
||||
"Kapazitaet Pruefung", "Lackierbudget Schicht 2"}
|
||||
|
||||
aufrufe = 0 # zaehlt jeden Solveraufruf mit - der Preis des Verfahrens
|
||||
|
||||
|
||||
def ist_loesbar(auswahl: list[int]) -> bool:
|
||||
"""Gibt es einen Punkt, der ALLE Bedingungen aus 'auswahl' erfuellt?
|
||||
|
||||
Es wird nur Zulaessigkeit geprueft, keine Zielfunktion optimiert: die
|
||||
Zielfunktion ist konstant null. INFEASIBLE haengt nie an der Zielfunktion.
|
||||
"""
|
||||
global aufrufe
|
||||
aufrufe += 1
|
||||
matrix, rechte_seite = [], []
|
||||
for i in auswahl:
|
||||
_, koeffizienten, richtung, grenze = BEDINGUNGEN[i]
|
||||
zeile = np.array(koeffizienten, dtype=float)
|
||||
# linprog kennt nur "<=", also ">=" durch Negation umdrehen
|
||||
matrix.append(zeile if richtung == "<=" else -zeile)
|
||||
rechte_seite.append(grenze if richtung == "<=" else -grenze)
|
||||
ergebnis = linprog(np.zeros(len(PRODUKTE)),
|
||||
A_ub=np.array(matrix), b_ub=np.array(rechte_seite),
|
||||
bounds=(0, None), method="highs")
|
||||
return ergebnis.status == 0
|
||||
|
||||
|
||||
def deletion_filter(auswahl: list[int]) -> list[int]:
|
||||
"""Verkleinert eine unloesbare Menge zu einer kleinsten unloesbaren Menge.
|
||||
|
||||
Der Kern des Verfahrens ist eine einzige Regel: Nimm eine Bedingung
|
||||
versuchsweise heraus. Bleibt der Rest unloesbar, wurde sie fuer den
|
||||
Widerspruch nicht gebraucht - sie darf endgueltig weg. Wird der Rest
|
||||
loesbar, war sie beteiligt und muss bleiben.
|
||||
|
||||
Nach genau einem Durchlauf ueber alle Bedingungen ist das Ergebnis
|
||||
unreduzierbar: n Solveraufrufe statt 2^n Teilmengen.
|
||||
"""
|
||||
rest = list(auswahl)
|
||||
for i in list(auswahl):
|
||||
probe = [j for j in rest if j != i]
|
||||
if not ist_loesbar(probe):
|
||||
rest = probe
|
||||
return rest
|
||||
|
||||
|
||||
def namen(auswahl: list[int]) -> list[str]:
|
||||
return [BEDINGUNGEN[i][0] for i in auswahl]
|
||||
|
||||
|
||||
def teil_1_der_befund(alle: list[int]) -> None:
|
||||
print("=" * 68)
|
||||
print("1. Was der Solver sagt")
|
||||
print("=" * 68)
|
||||
print(f"Modell: {len(PRODUKTE)} Variablen, {len(BEDINGUNGEN)} Bedingungen")
|
||||
status = "OPTIMAL" if ist_loesbar(alle) else "INFEASIBLE"
|
||||
print(f"Status: {status}")
|
||||
print("\nMehr ist es nicht. Der Solver nennt keine Ursache, weil es die eine")
|
||||
print("Ursache nicht gibt: Unloesbarkeit ist eine Eigenschaft von Mengen von")
|
||||
print("Bedingungen, nicht von einzelnen Bedingungen.")
|
||||
|
||||
|
||||
def teil_2_die_naive_suche(alle: list[int]) -> None:
|
||||
print("\n" + "=" * 68)
|
||||
print("2. Die naheliegende Idee - und warum sie hier scheitert")
|
||||
print("=" * 68)
|
||||
print("Jede Bedingung einmal weglassen und schauen, ob es dann geht:\n")
|
||||
treffer = [i for i in alle if ist_loesbar([j for j in alle if j != i])]
|
||||
for i in alle:
|
||||
print(f" ohne {BEDINGUNGEN[i][0]:<26} {'loesbar' if i in treffer else 'weiter unloesbar'}")
|
||||
print(f"\nGefundene Schuldige: {len(treffer)}")
|
||||
print("Keine einzelne Bedingung ist schuld. Genau das ist der Normalfall -")
|
||||
print("und der Grund, warum diese Suche in der Praxis so oft im Nichts endet.")
|
||||
|
||||
|
||||
def teil_3_der_filter(alle: list[int]) -> list[int]:
|
||||
global aufrufe
|
||||
print("\n" + "=" * 68)
|
||||
print("3. Der Deletion Filter")
|
||||
print("=" * 68)
|
||||
vorher = aufrufe
|
||||
konflikt = deletion_filter(alle)
|
||||
kosten = aufrufe - vorher
|
||||
print(f"{kosten} Solveraufrufe -> Konflikt aus {len(konflikt)} von "
|
||||
f"{len(BEDINGUNGEN)} Bedingungen:\n")
|
||||
for name in namen(konflikt):
|
||||
print(f" * {name}")
|
||||
print("\nProbe auf Unreduzierbarkeit - jede einzelne davon weglassen:")
|
||||
for i in konflikt:
|
||||
rest_loesbar = ist_loesbar([j for j in konflikt if j != i])
|
||||
print(f" ohne {BEDINGUNGEN[i][0]:<26} {'loesbar' if rest_loesbar else 'UNLOESBAR - nicht minimal!'}")
|
||||
print(f"\nAufwand: {kosten} Aufrufe. Alle Teilmengen durchzuprobieren waeren "
|
||||
f"2^{len(BEDINGUNGEN)} = {2 ** len(BEDINGUNGEN):,} gewesen.".replace(",", "."))
|
||||
return konflikt
|
||||
|
||||
|
||||
def teil_4_der_naechste_konflikt(alle: list[int], konflikt: list[int]) -> None:
|
||||
print("\n" + "=" * 68)
|
||||
print("4. Ein Konflikt ist selten einer")
|
||||
print("=" * 68)
|
||||
entfernt = konflikt[0]
|
||||
rest = [i for i in alle if i != entfernt]
|
||||
print(f"Angenommen, '{BEDINGUNGEN[entfernt][0]}' laesst sich verhandeln")
|
||||
print("und wird aus dem Modell genommen. Dann ist das Modell ...")
|
||||
if ist_loesbar(rest):
|
||||
print("... loesbar. Fertig.")
|
||||
return
|
||||
print("... immer noch unloesbar. Der Filter erneut:\n")
|
||||
zweiter = deletion_filter(rest)
|
||||
for name in namen(zweiter):
|
||||
print(f" * {name}")
|
||||
gemeinsam = set(zweiter) & set(konflikt)
|
||||
print(f"\nUeberschneidung mit dem ersten Konflikt: {len(gemeinsam)} Bedingungen")
|
||||
print("Ein zweiter, unabhaengiger Widerspruch, den der erste verdeckt hat.")
|
||||
print("Deshalb ist die Konfliktsuche eine Schleife, kein einzelner Aufruf:")
|
||||
print("reparieren, neu suchen, bis das Modell loesbar ist.")
|
||||
|
||||
|
||||
def teil_5_die_reihenfolge(alle: list[int]) -> None:
|
||||
print("\n" + "=" * 68)
|
||||
print("5. Es gibt nicht DEN Konflikt")
|
||||
print("=" * 68)
|
||||
print("Derselbe Filter, nur eine andere Pruefreihenfolge - 200-mal gewuerfelt:\n")
|
||||
zufall = np.random.default_rng(0)
|
||||
haeufigkeit: dict[tuple[int, ...], int] = {}
|
||||
for _ in range(200):
|
||||
ordnung = [int(i) for i in zufall.permutation(alle)]
|
||||
schluessel = tuple(sorted(deletion_filter(ordnung)))
|
||||
haeufigkeit[schluessel] = haeufigkeit.get(schluessel, 0) + 1
|
||||
for schluessel, anzahl in sorted(haeufigkeit.items(), key=lambda p: -p[1]):
|
||||
print(f" {anzahl:3d}x {len(schluessel)} Bedingungen: "
|
||||
f"{', '.join(namen(list(schluessel)))}")
|
||||
print(f"\n{len(haeufigkeit)} verschiedene minimale Konflikte in EINEM Modell.")
|
||||
print("Der Filter liefert *einen* kleinsten Konflikt, nicht *den* kleinsten -")
|
||||
print("den gibt es nicht. Alle oben sind gleichermassen korrekt.")
|
||||
|
||||
|
||||
def teil_6_den_befund_steuern(alle: list[int]) -> None:
|
||||
print("\n" + "=" * 68)
|
||||
print("6. Den Befund brauchbar machen")
|
||||
print("=" * 68)
|
||||
print("Der Filter wirft heraus, was er zuerst in die Hand bekommt. Wer die")
|
||||
print("unveraenderlichen Bedingungen zuerst pruefen laesst, bekommt sie eher")
|
||||
print("aus dem Befund heraus - und sieht dafuer mehr Stellschrauben.\n")
|
||||
fest = [i for i in alle if BEDINGUNGEN[i][0] not in VERHANDELBAR]
|
||||
frei = [i for i in alle if BEDINGUNGEN[i][0] in VERHANDELBAR]
|
||||
for titel, ordnung in (("unveraenderliche zuerst", fest + frei),
|
||||
("veraenderliche zuerst ", frei + fest)):
|
||||
konflikt = sorted(deletion_filter(ordnung))
|
||||
stellschrauben = [i for i in konflikt if BEDINGUNGEN[i][0] in VERHANDELBAR]
|
||||
print(f" {titel} -> {len(konflikt)} Bedingungen, davon "
|
||||
f"{len(stellschrauben)} veraenderlich")
|
||||
print(f" {', '.join(namen(konflikt))}")
|
||||
print("\nBeide Befunde sind wahr. Nur einer davon nennt dem Planer etwas,")
|
||||
print("das er tatsaechlich tun kann.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
alle = list(range(len(BEDINGUNGEN)))
|
||||
teil_1_der_befund(alle)
|
||||
teil_2_die_naive_suche(alle)
|
||||
konflikt = teil_3_der_filter(alle)
|
||||
teil_4_der_naechste_konflikt(alle, konflikt)
|
||||
teil_5_die_reihenfolge(alle)
|
||||
teil_6_den_befund_steuern(alle)
|
||||
print("\n" + "=" * 68)
|
||||
print(f"Insgesamt {aufrufe} Solveraufrufe fuer die gesamte Diagnose.")
|
||||
print("=" * 68)
|
||||
```
|
||||
|
||||
Die Ausgabe:
|
||||
|
||||
```text
|
||||
====================================================================
|
||||
1. Was der Solver sagt
|
||||
====================================================================
|
||||
Modell: 5 Variablen, 14 Bedingungen
|
||||
Status: INFEASIBLE
|
||||
|
||||
Mehr ist es nicht. Der Solver nennt keine Ursache, weil es die eine
|
||||
Ursache nicht gibt: Unloesbarkeit ist eine Eigenschaft von Mengen von
|
||||
Bedingungen, nicht von einzelnen Bedingungen.
|
||||
|
||||
====================================================================
|
||||
2. Die naheliegende Idee - und warum sie hier scheitert
|
||||
====================================================================
|
||||
Jede Bedingung einmal weglassen und schauen, ob es dann geht:
|
||||
|
||||
ohne Kapazitaet Montage weiter unloesbar
|
||||
ohne Kapazitaet Lackieren weiter unloesbar
|
||||
ohne Kapazitaet Pruefung weiter unloesbar
|
||||
ohne Liefervertrag Rahmen weiter unloesbar
|
||||
ohne Liefervertrag Gehaeuse weiter unloesbar
|
||||
ohne Liefervertrag Deckel weiter unloesbar
|
||||
ohne Liefervertrag Traeger weiter unloesbar
|
||||
ohne Marktgrenze Rahmen weiter unloesbar
|
||||
ohne Marktgrenze Gehaeuse weiter unloesbar
|
||||
ohne Marktgrenze Deckel weiter unloesbar
|
||||
ohne Marktgrenze Halter weiter unloesbar
|
||||
ohne Mindestumsatz weiter unloesbar
|
||||
ohne Sortimentsbreite weiter unloesbar
|
||||
ohne Lackierbudget Schicht 2 weiter unloesbar
|
||||
|
||||
Gefundene Schuldige: 0
|
||||
Keine einzelne Bedingung ist schuld. Genau das ist der Normalfall -
|
||||
und der Grund, warum diese Suche in der Praxis so oft im Nichts endet.
|
||||
|
||||
====================================================================
|
||||
3. Der Deletion Filter
|
||||
====================================================================
|
||||
14 Solveraufrufe -> Konflikt aus 3 von 14 Bedingungen:
|
||||
|
||||
* Kapazitaet Lackieren
|
||||
* Liefervertrag Rahmen
|
||||
* Liefervertrag Gehaeuse
|
||||
|
||||
Probe auf Unreduzierbarkeit - jede einzelne davon weglassen:
|
||||
ohne Kapazitaet Lackieren loesbar
|
||||
ohne Liefervertrag Rahmen loesbar
|
||||
ohne Liefervertrag Gehaeuse loesbar
|
||||
|
||||
Aufwand: 14 Aufrufe. Alle Teilmengen durchzuprobieren waeren 2^14 = 16.384 gewesen.
|
||||
|
||||
====================================================================
|
||||
4. Ein Konflikt ist selten einer
|
||||
====================================================================
|
||||
Angenommen, 'Kapazitaet Lackieren' laesst sich verhandeln
|
||||
und wird aus dem Modell genommen. Dann ist das Modell ...
|
||||
... immer noch unloesbar. Der Filter erneut:
|
||||
|
||||
* Kapazitaet Montage
|
||||
* Liefervertrag Traeger
|
||||
* Mindestumsatz
|
||||
* Lackierbudget Schicht 2
|
||||
|
||||
Ueberschneidung mit dem ersten Konflikt: 0 Bedingungen
|
||||
Ein zweiter, unabhaengiger Widerspruch, den der erste verdeckt hat.
|
||||
Deshalb ist die Konfliktsuche eine Schleife, kein einzelner Aufruf:
|
||||
reparieren, neu suchen, bis das Modell loesbar ist.
|
||||
|
||||
====================================================================
|
||||
5. Es gibt nicht DEN Konflikt
|
||||
====================================================================
|
||||
Derselbe Filter, nur eine andere Pruefreihenfolge - 200-mal gewuerfelt:
|
||||
|
||||
67x 3 Bedingungen: Kapazitaet Lackieren, Liefervertrag Rahmen, Liefervertrag Gehaeuse
|
||||
32x 4 Bedingungen: Kapazitaet Montage, Kapazitaet Lackieren, Marktgrenze Deckel, Mindestumsatz
|
||||
20x 4 Bedingungen: Kapazitaet Montage, Liefervertrag Traeger, Mindestumsatz, Sortimentsbreite
|
||||
19x 4 Bedingungen: Kapazitaet Montage, Liefervertrag Traeger, Mindestumsatz, Lackierbudget Schicht 2
|
||||
19x 4 Bedingungen: Kapazitaet Montage, Liefervertrag Traeger, Marktgrenze Gehaeuse, Mindestumsatz
|
||||
17x 5 Bedingungen: Kapazitaet Lackieren, Kapazitaet Pruefung, Liefervertrag Gehaeuse, Marktgrenze Deckel, Mindestumsatz
|
||||
11x 4 Bedingungen: Kapazitaet Montage, Liefervertrag Deckel, Liefervertrag Traeger, Mindestumsatz
|
||||
8x 4 Bedingungen: Kapazitaet Montage, Liefervertrag Rahmen, Liefervertrag Traeger, Mindestumsatz
|
||||
7x 4 Bedingungen: Kapazitaet Montage, Kapazitaet Lackieren, Liefervertrag Traeger, Mindestumsatz
|
||||
|
||||
9 verschiedene minimale Konflikte in EINEM Modell.
|
||||
Der Filter liefert *einen* kleinsten Konflikt, nicht *den* kleinsten -
|
||||
den gibt es nicht. Alle oben sind gleichermassen korrekt.
|
||||
|
||||
====================================================================
|
||||
6. Den Befund brauchbar machen
|
||||
====================================================================
|
||||
Der Filter wirft heraus, was er zuerst in die Hand bekommt. Wer die
|
||||
unveraenderlichen Bedingungen zuerst pruefen laesst, bekommt sie eher
|
||||
aus dem Befund heraus - und sieht dafuer mehr Stellschrauben.
|
||||
|
||||
unveraenderliche zuerst -> 4 Bedingungen, davon 2 veraenderlich
|
||||
Kapazitaet Montage, Kapazitaet Lackieren, Marktgrenze Deckel, Mindestumsatz
|
||||
veraenderliche zuerst -> 3 Bedingungen, davon 1 veraenderlich
|
||||
Kapazitaet Lackieren, Liefervertrag Rahmen, Liefervertrag Gehaeuse
|
||||
|
||||
Beide Befunde sind wahr. Nur einer davon nennt dem Planer etwas,
|
||||
das er tatsaechlich tun kann.
|
||||
|
||||
====================================================================
|
||||
Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.
|
||||
====================================================================
|
||||
```
|
||||
|
||||
**Vier Dinge sind daran wichtig.**
|
||||
|
||||
1. **Die Zulässigkeitsprüfung braucht keine Zielfunktion.** `linprog` bekommt einen
|
||||
Nullvektor als Ziel. Unlösbarkeit hängt nie an der Zielfunktion — wer beim Diagnostizieren
|
||||
die echte Zielfunktion mitschleppt, bezahlt Rechenzeit für nichts.
|
||||
2. **Gefiltert werden nur die aufgeführten Bedingungen, nicht die Variablenschranken.** Die
|
||||
Nichtnegativität steht in `bounds` und bleibt in jedem Teilmodell stehen. Sitzt Ihr
|
||||
Widerspruch in den Schranken (`x >= 5` als Bound statt als Zeile), findet ihn der
|
||||
Filter nicht — schreiben Sie solche Grenzen dann als benannte Bedingung.
|
||||
3. **Sprechende Namen sind kein Luxus.** Der Befund ist genau so brauchbar wie die Namen,
|
||||
die darin vorkommen. `constraint_47, constraint_112, constraint_9` ist kein Befund.
|
||||
4. **Der Filter liefert *einen* kleinsten Konflikt, nicht *den* kleinsten.** Dieses Modell
|
||||
mit vierzehn Bedingungen enthält neun verschiedene minimale Konflikte; welchen man zu
|
||||
sehen bekommt, entscheidet allein die Prüfreihenfolge. Das ist kein Mangel des
|
||||
Verfahrens, sondern eine Eigenschaft des Problems.
|
||||
|
||||
**Aus Punkt 4 folgt der eigentliche Praxisgriff.** Weil der Filter bevorzugt das
|
||||
hinauswirft, was er zuerst in die Hand bekommt, überlegen Sie vor dem Lauf, welche
|
||||
Bedingungen Sie im Ernstfall tatsächlich ändern könnten — und lassen Sie die
|
||||
*unveränderlichen* zuerst prüfen. Dann bleiben eher die Stellschrauben im Befund stehen.
|
||||
Im Beispiel oben liefert die Reihenfolge „Verträge und Marktgrenzen zuerst“ einen Befund
|
||||
mit zwei veränderlichen Kapazitäten, die umgekehrte Reihenfolge einen mit nur einer.
|
||||
Beide Befunde sind wahr; nur der erste nennt dem Planer etwas, das er tun kann.
|
||||
|
||||
> **⚠️ Was der Filter kostet**
|
||||
> Ein Konflikt kostet $n$ Solverläufe. Bei 14 Bedingungen sind das 14 — bei 50 000
|
||||
> Restriktionen mit je zehn Sekunden Lösungszeit wären es knapp sechs Tage. Für große
|
||||
> Modelle filtert man deshalb nicht einzeln, sondern **gruppenweise**: erst über
|
||||
> Bedingungs*blöcke* (alle Kapazitäten, alle Verträge, alle Zeitfenster) laufen, dann den
|
||||
> Filter nur noch innerhalb des einen Blocks anwenden, der übrig bleibt. Das ist derselbe
|
||||
> Algorithmus auf einer gröberen Ebene und senkt die Zahl der Läufe um Größenordnungen.
|
||||
|
||||
**Das Verhältnis zur Relaxation.** Der Deletion Filter beantwortet die Frage *„was
|
||||
widerspricht sich?“*; die Schlupfvariablen aus Schritt 3 beantworten die Frage *„was tun
|
||||
wir jetzt?“*. Beide gehören in ein Produktionssystem, aber an verschiedene Stellen: der
|
||||
Filter in die Entwicklung und in die Fehlersuche, die Relaxation in den Betrieb
|
||||
(`Infeasibility_Diagnose.py`, {ref:sec:praxisfallen-die-fuenf-typischen-praxisfallen}).
|
||||
Ein System, das im Ernstfall den Konflikt *benennt* und trotzdem einen Notfallplan
|
||||
*liefert*, hat beides.
|
||||
|
||||
> **🎯 Die eigentliche Lehre**
|
||||
> `INFEASIBLE` in Produktion ist ein **Entwurfsfehler**, kein Betriebsfehler. Ein System,
|
||||
> das im Ernstfall nur „geht nicht“ sagt, ist wertlos. Bauen Sie Schlupfvariablen von
|
||||
> vornherein ein ({ref:sec:praxisfallen-die-fuenf-typischen-praxisfallen}).
|
||||
|
||||
---
|
||||
|
||||
## C2 — UNBOUNDED
|
||||
|
||||
**Bedeutung.**{idx:Unbounded (Fehlerbild)} Die Zielfunktion lässt sich unbegrenzt verbessern.
|
||||
|
||||
**Praktisch immer eine dieser drei Ursachen:**
|
||||
|
||||
1. **Eine Kapazitätsbedingung fehlt.** Die häufigste Ursache. Prüfen Sie: Gibt es für jede
|
||||
Variable eine Obergrenze — entweder explizit oder implizit über eine Ressource?
|
||||
2. **Vorzeichenfehler.** Sie minimieren, wo Sie maximieren wollten (oder umgekehrt), und
|
||||
der Zielwert läuft in die falsche Richtung davon.
|
||||
3. **Freie Variablen ohne Nichtnegativität.** `bounds=[(None, None)]` statt `[(0, None)]`.
|
||||
|
||||
```python
|
||||
# Diagnose: kuenstliche Schranke einziehen und sehen, wohin es laeuft
|
||||
bounds = [(0, 1e6)] * n
|
||||
res = linprog(...)
|
||||
gross = [j for j in range(n) if res.x[j] > 1e5]
|
||||
print(f"Diese Variablen laufen an die Kunstschranke: {gross}")
|
||||
```
|
||||
Die so gefundenen Variablen sind die, denen eine echte Beschränkung fehlt.
|
||||
|
||||
---
|
||||
|
||||
## C3 — Zu langsam
|
||||
|
||||
### Diagnosereihenfolge
|
||||
|
||||
{idx:Laufzeitexplosion (Fehlerbild)}**1. Ist es überhaupt das Lösen?**
|
||||
```python
|
||||
import time
|
||||
t0 = time.perf_counter(); modell_bauen(); t1 = time.perf_counter()
|
||||
loesen(); t2 = time.perf_counter()
|
||||
print(f"Aufbau {t1-t0:.2f}s | Loesen {t2-t1:.2f}s")
|
||||
```
|
||||
Bei CVXPY und bei Schleifen über Szenarien ist oft der **Aufbau** der Engpass, nicht der
|
||||
Solver (siehe {ref:sec:cvar-implementierung-cvar-portfolio-mit-reibung}).
|
||||
|
||||
**2. Big-M zu groß?** Der häufigste Grund für explodierende MILP-Laufzeiten. Setzen Sie $M$
|
||||
auf die kleinste gültige Schranke ({ref:sec:milp-modellierungstricks-big-m-und-logische-bedingungen}).
|
||||
|
||||
**3. Symmetrie im Modell?** Identische Maschinen, austauschbare Mitarbeitende, gleichwertige
|
||||
Fahrzeuge — der Solver durchsucht alle Vertauschungen. Abhilfe: Ordnungsbedingungen
|
||||
(Muster B20).
|
||||
|
||||
**4. Falsche Solverfamilie?** Zuordnungsprobleme mit MILP, Routing mit CP-SAT von Hand
|
||||
nachgebaut, konvexe Probleme mit `scipy.optimize.minimize` — jeweils Größenordnungen
|
||||
langsamer als das passende Werkzeug ({ref:sec:oekosystem-wann-lohnt-sich-welche-ebene}).
|
||||
|
||||
**5. Wird vektorisiert?**
|
||||
```python
|
||||
# langsam: # schnell:
|
||||
for s in range(S): constraints.append(u >= -(R @ w) - gamma)
|
||||
constraints.append(u[s] >= -R[s] @ w - gamma)
|
||||
```
|
||||
|
||||
**6. Gap akzeptieren.**
|
||||
```python
|
||||
loeser.parameters.relative_gap_limit = 0.02 # CP-SAT
|
||||
loeser.parameters.max_time_in_seconds = 30.0
|
||||
```
|
||||
Bei ±10 % Datenunsicherheit sind die letzten 2 % Optimalität verlorene Zeit.
|
||||
|
||||
---
|
||||
|
||||
## C4 — Unsinniges Ergebnis
|
||||
|
||||
{idx:Unsinniges Ergebnis (Fehlerbild)}**Der Solver hat immer recht — bezogen auf das Modell, das Sie ihm gegeben haben.** Wenn das
|
||||
Ergebnis unsinnig ist, beschreibt Ihr Modell nicht das Problem, das Sie meinen.
|
||||
|
||||
### Checkliste
|
||||
|
||||
- [ ] **Einheiten.** Euro gegen Cent? Stunden gegen Minuten? Jahres- gegen Tagesgrößen?
|
||||
(Ein Beispiel dafür finden Sie in {ref:sec:cvar-implementierung-cvar-portfolio-mit-reibung}.)
|
||||
- [ ] **Vorzeichen.** Minimieren Sie, wo Sie maximieren wollten? Ist der Ertrag negativ
|
||||
eingetragen?
|
||||
- [ ] **Fehlende Bedingung.** Formulieren Sie in Worten, was am Ergebnis falsch ist —
|
||||
meistens ist genau das die vergessene Nebenbedingung.
|
||||
- [ ] **Variablenbedeutung.** Ist $x_{ij}$ „Person $i$ macht Aufgabe $j$“ oder umgekehrt?
|
||||
Bei asymmetrischen Matrizen fällt das nicht auf.
|
||||
- [ ] **Index-Reihenfolge.** Zeilen und Spalten vertauscht? Siehe C11.
|
||||
|
||||
### Die wirksamste Gegenmaßnahme
|
||||
|
||||
```python
|
||||
# Nach JEDEM Lösen: alle Regeln explizit nachprüfen
|
||||
assert abs(w.sum() - 1) < 1e-6, "Budget nicht eingehalten"
|
||||
assert w.min() > -1e-9, "negatives Gewicht"
|
||||
assert w.max() <= grenze + 1e-9, "Positionsgrenze verletzt"
|
||||
for sektor, idx in sektoren.items():
|
||||
assert w[idx].sum() <= limit[sektor] + 1e-9, f"Sektor {sektor} verletzt"
|
||||
```
|
||||
Diese Zeilen kosten Sekunden und fangen die Fehlerklasse ab, die der Solver **nicht** melden
|
||||
kann: dass Ihr Modell etwas anderes beschreibt, als Sie glauben.
|
||||
|
||||
---
|
||||
|
||||
## C5 — Instabile Lösung
|
||||
|
||||
**Symptom.**{idx:Instabile Lösung (Fehlerbild)} Ein zusätzlicher Handelstag, und das Portfolio sieht völlig anders aus.
|
||||
|
||||
**Ursachen und Abhilfen:**
|
||||
|
||||
| Ursache | Prüfung | Abhilfe |
|
||||
| --- | --- | --- |
|
||||
| Schlecht konditionierte Kovarianzmatrix | `np.linalg.cond(Sigma)` | Ledoit-Wolf-Shrinkage ({ref:kap:finanzdaten}) |
|
||||
| Zu wenige Beobachtungen | $T/N$ berechnen | Historie verlängern oder Universum verkleinern |
|
||||
| Alternativoptima | Zielfunktion bei mehreren Lösungen gleich? | Regularisierung: kleinen $L_2$-Term addieren |
|
||||
| Fehlende Turnover-Dämpfung | Turnover messen | $L_1$-Strafe ({ref:sec:cvar-transaktionskosten-ueber-die-l-1-norm}) |
|
||||
|
||||
```python
|
||||
eig = np.linalg.eigvalsh(Sigma)
|
||||
print(f"Kondition {eig.max()/eig.min():.0f} | kleinster EW {eig.min():.2e}")
|
||||
# Faustregel: Kondition > 1000 ist bedenklich, > 10000 kritisch
|
||||
```
|
||||
|
||||
### Derselbe Fehler außerhalb der Finanzwelt
|
||||
|
||||
Instabilität ist kein Portfolioproblem, sondern ein Skalierungsproblem — und es trifft
|
||||
jedes Modell, in dem Größen sehr verschiedener Ordnung nebeneinanderstehen. Euro-Beträge
|
||||
($10^7$) und Tonnen-Angaben ($10^{-3}$) in derselben Matrix erzeugen eine
|
||||
Konditionszahl{idx:Konditionszahl}, die aus einer Datenunsicherheit von 0,1 % eine
|
||||
Lösungsänderung von 100 % machen kann. Die Konditionszahl ist genau die Obergrenze dieses
|
||||
Verstärkungsfaktors.
|
||||
|
||||
```python
|
||||
print(f"Kondition der Restriktionsmatrix: {np.linalg.cond(A):.1e}")
|
||||
# Grobe Peilung: Wertebereiche der Koeffizienten anschauen
|
||||
print(f"kleinster / groesster Betrag: {np.abs(A[A != 0]).min():.1e} "
|
||||
f"/ {np.abs(A).max():.1e}")
|
||||
```
|
||||
Klaffen die Beträge um mehr als drei, vier Größenordnungen auseinander, rechnen Sie in
|
||||
anderen Einheiten (Tausend Euro statt Euro, Kilogramm statt Tonnen) oder skalieren Sie
|
||||
Zeilen und Spalten automatisch. `Skalierung_Kondition.py` zeigt beides samt
|
||||
Ruiz-Equilibrierung ({ref:sec:fundament-kondition}).
|
||||
|
||||
---
|
||||
|
||||
## C6 — Falsche Dualwerte
|
||||
|
||||
**Symptom.**{idx:Falsche Dualwerte (Fehlerbild)} Schattenpreise sind negativ, wo sie positiv sein sollten — oder null bei einer
|
||||
offensichtlich knappen Ressource.
|
||||
|
||||
### Die drei Prüfungen
|
||||
|
||||
**1. Vorzeichenkonvention.** Haben Sie zur Maximierung negiert?
|
||||
```python
|
||||
schattenpreise = -res.ineqlin.marginals # bei linprog nach Negation
|
||||
```
|
||||
Genau das ist die Vorzeichenfalle aus {ref:sec:lp-die-vorzeichenfalle-bei-schattenpreisen}.
|
||||
|
||||
**2. Komplementärer Schlupf.**
|
||||
```python
|
||||
for s, y in zip(res.slack, schattenpreise):
|
||||
assert abs(s * y) < 1e-6, "Komplementaerer Schlupf verletzt!"
|
||||
```
|
||||
Eine Ressource mit Reserve **muss** Schattenpreis 0 haben.
|
||||
|
||||
**3. Numerische Gegenprobe — der zuverlässigste Test.**
|
||||
```python
|
||||
b_plus = b.copy(); b_plus[i] += 1.0
|
||||
zuwachs = loese(b_plus) - loese(b)
|
||||
assert abs(zuwachs - schattenpreise[i]) < 1e-6
|
||||
```
|
||||
Dieser Test ist **unabhängig von jeder Vorzeichenkonvention**. Nutzen Sie ihn.
|
||||
|
||||
**4. Wenn alle drei Prüfungen bestehen und der Wert trotzdem seltsam ist: Entartung.**
|
||||
Die drei Prüfungen oben setzen voraus, dass es *einen* richtigen Schattenpreis gibt. Bei
|
||||
einem entarteten Optimum{idx:Entartung!und Dualwerte} — mehr aktive Nebenbedingungen als
|
||||
Variablen — gibt es den nicht. Der Wert ist dann nicht falsch, sondern **mehrdeutig**:
|
||||
zwei korrekte Solver liefern für dasselbe Optimum verschiedene Dualwerte, und die
|
||||
numerische Gegenprobe aus Schritt 3 schlägt fehl, weil der Zuwachs nach links und nach
|
||||
rechts unterschiedlich ausfällt.
|
||||
|
||||
```python
|
||||
aktiv = int(np.sum(res.slack < 1e-9)) # wie viele Bedingungen sind straff?
|
||||
if aktiv > len(res.x):
|
||||
print(f"Entartet: {aktiv} aktive Bedingungen bei {len(res.x)} Variablen.")
|
||||
print("Einen einzelnen Schattenpreis zu berichten waere hier irrefuehrend.")
|
||||
```
|
||||
Die belastbare Antwort ist dann keine Zahl, sondern eine **Spanne** über alle optimalen
|
||||
Dualwerte — `Toleranzen_und_Entartung.py` rechnet sie vor
|
||||
({ref:sec:lp-entartung}).
|
||||
|
||||
---
|
||||
|
||||
## C7 — Widersprüchliche Solver
|
||||
|
||||
**Symptom.**{idx:Widersprüchliche Solver (Fehlerbild)} `linprog` sagt 530, CVXPY sagt 529,8.
|
||||
|
||||
**Diagnose:**
|
||||
1. **Ist es nur Toleranz?** Unterschiede unter $10^{-6}$ relativ sind normal. Vergleichen
|
||||
Sie **nie** mit `==`, sondern mit `np.isclose()`.
|
||||
2. **Ist das Modell wirklich identisch?** Häufigster Fall: In einer Formulierung fehlt eine
|
||||
Bedingung oder das Vorzeichen einer Ungleichung ist gedreht.
|
||||
3. **Alternativoptima.** Verschiedene Lösungsvektoren bei **gleichem** Zielwert sind kein
|
||||
Widerspruch — das Problem hat mehrere Optima (semidefinite Matrix, parallele
|
||||
Zielfunktion).
|
||||
4. **Ist eines der Ergebnisse gar nicht optimal?** Status prüfen: `optimal_inaccurate`
|
||||
bedeutet, der Solver hat aufgegeben.
|
||||
|
||||
---
|
||||
|
||||
## C8 — DCPError
|
||||
|
||||
```
|
||||
cvxpy.error.DCPError: Problem does not follow DCP rules.
|
||||
```
|
||||
|
||||
**Bedeutung.**{idx:DCPError} CVXPY kann die Konvexität Ihres Ausdrucks nicht beweisen — es lehnt ab, statt
|
||||
ein Ergebnis ohne Garantie zu liefern. **Das ist ein Feature.**
|
||||
|
||||
| Problematischer Ausdruck | Warum | Lösung |
|
||||
| ------------------------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| `x * y` (beide Variablen) | bilinear | umformulieren oder MILP/NLP |
|
||||
| `a / x` | nicht konvex | `cp.inv_pos(x)` bei $x>0$ |
|
||||
| `cp.sqrt(cp.quad_form(w, S))` | `cp.sqrt` ist **konkav** und verlangt ein konkaves Argument — `quad_form` ist konvex. Der Ausdruck ist nie DCP, egal wie sauber $S$ ist | Cholesky $S = LL^\top$, dann `cp.norm2(L.T @ w)`: die 2-Norm eines **affinen** Ausdrucks (siehe `CVaR_Portfolio.py`) |
|
||||
| `cp.quad_form(w, S)` bei numerisch unsauberem $S$ | Eigenwerte knapp unter null durch Rundung — CVXPY erkennt $S$ nicht als PSD | `cp.psd_wrap(S)`, **wenn** $S$ nachweislich PSD ist (z. B. eine Kovarianzmatrix). Behebt nur diese numerische Beanstandung, keine Regelverletzung im Aufbau |
|
||||
| Quotient (z. B. Sharpe Ratio) | nicht konvex | Korn-Transformation ({ref:sec:markowitz-das-mean-variance-modell}) |
|
||||
| `cp.log(x)` maximieren | konkav — das ist **erlaubt** | in `cp.Maximize` verwenden |
|
||||
|
||||
```python
|
||||
print(problem.is_dcp()) # False?
|
||||
for c in problem.constraints:
|
||||
print(c.is_dcp(), c) # zeigt die Schuldige
|
||||
print(problem.objective.is_dcp())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## C9 — Importfehler
|
||||
|
||||
```
|
||||
ImportError: .../highspy/_core...so: undefined symbol: _ZN5Highs13releaseMemoryEv
|
||||
```
|
||||
|
||||
**Ursache.**{idx:Importfehler (ortools und highspy)} `ortools` und `highspy` bringen beide eine eigene HiGHS-Kopie mit; sie lassen
|
||||
sich auf vielen Systemen **nicht im selben Prozess** importieren (siehe {ref:sec:oekosystem-ein-system-vier-programmieransaetze}).
|
||||
Der Konflikt entsteht auch **indirekt**: `cvxpy` importiert ein installiertes `highspy`
|
||||
bei der Solver-Erkennung selbst mit — ein Skript, das erst `cvxpy` und dann `ortools`
|
||||
importiert, crasht daher mit derselben Meldung.
|
||||
|
||||
**Abhilfen (in dieser Reihenfolge):**
|
||||
1. Nur eines von beiden im selben Skript verwenden.
|
||||
2. Getrennte Prozesse (`subprocess`) — siehe `Ein_System_Vier_Ansaetze.py`.
|
||||
3. Auf `highspy` verzichten: HiGHS ist ohnehin Backend von `scipy.optimize.linprog` und
|
||||
CVXPY.
|
||||
4. Getrennte virtuelle Umgebungen.
|
||||
|
||||
---
|
||||
|
||||
## C10 — Verdächtig guter Backtest
|
||||
|
||||
**Faustregel:**{idx:Verdächtig guter Backtest (Fehlerbild)} Eine Sharpe Ratio über 2 bei einer einfachen Strategie ist fast immer ein
|
||||
Fehler, kein Fund.
|
||||
|
||||
### Prüfreihenfolge
|
||||
|
||||
**1. Lookahead-Selbsttest.**
|
||||
```python
|
||||
signal_vorher = berechne_signal(stichtag)
|
||||
kurse.loc[kurse.index > stichtag] *= 3.0
|
||||
signal_nachher = berechne_signal(stichtag)
|
||||
assert np.allclose(signal_vorher, signal_nachher), "LOOKAHEAD-BIAS!"
|
||||
```
|
||||
|
||||
**2. Zeitindizes prüfen.** `iloc[t]` gegen `iloc[t+1]` — die Gewichte von heute gehören auf
|
||||
die Rendite von morgen.
|
||||
|
||||
**3. Survivorship.** Wurde das Universum nach Kriterien gefiltert, die erst am Ende bekannt
|
||||
waren (`dropna()` über den gesamten Zeitraum!)?
|
||||
|
||||
**4. Kosten.** Sind Gebühren, Spread und Slippage verbucht?
|
||||
|
||||
**5. Wie viele Varianten haben Sie getestet?** Bei 20 Versuchen findet man auch in reinem
|
||||
Rauschen eine „signifikante“ Strategie ({ref:sec:handelsmaschine-die-fuenf-selbsttaeuschungen-des-backtestens}).
|
||||
|
||||
**6. Nach Teilzeiträumen aufschlüsseln.** Stammt die gesamte Überrendite aus einem einzigen
|
||||
Quartal?
|
||||
|
||||
---
|
||||
|
||||
## C11 — Vertauschte Spalten
|
||||
|
||||
**Symptom.**{idx:Vertauschte Spalten (Fehlerbild)} Ergebnisse sind plausibel, aber falsch beschriftet — oder eine Sektorgrenze
|
||||
greift auf die falschen Titel.
|
||||
|
||||
**Ursache.** `yfinance` und viele andere Datenquellen liefern Spalten **alphabetisch
|
||||
sortiert**, nicht in der Reihenfolge Ihrer Anfrage.
|
||||
|
||||
```python
|
||||
# IMMER nach dem Import:
|
||||
kurse = roh["Close"][tickers].dropna() # erzwingt die Reihenfolge
|
||||
assert list(kurse.columns) == tickers, (
|
||||
f"Reihenfolge weicht ab!\n erwartet: {tickers}\n erhalten: {list(kurse.columns)}")
|
||||
|
||||
# Und Gruppen NIE ueber Positionsindizes definieren:
|
||||
SEKTOREN = {"Technologie": ["AAPL", "MSFT", "NVDA", "AMZN"]} # gut
|
||||
tech_idx = [tickers.index(t) for t in SEKTOREN["Technologie"]] # bricht bei Tippfehler
|
||||
tech_indices = [0, 1, 2, 3] # SCHLECHT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Die goldene Diagnoseregel
|
||||
|
||||
> Wenn Sie nicht weiterkommen: **Verkleinern Sie das Problem, bis Sie es von Hand
|
||||
> nachrechnen können.** Drei Mitarbeitende, zwei Schichten. Zwei Aktien, drei Tage. Fast
|
||||
> jeder Modellierungsfehler wird an einer Instanz sichtbar, deren richtige Antwort Sie
|
||||
> kennen — und fast keiner wird an einer Instanz sichtbar, deren Antwort Sie nicht
|
||||
> unabhängig prüfen können.
|
||||
|
||||
---
|
||||
|
||||
*Weiter mit:* [{ref:anhang:spickzettel} — Spickzettel der Solver](93_Anhang_Spickzettel.md)
|
||||
|
|
@ -0,0 +1,414 @@
|
|||
# Anhang: Spickzettel der Solver {#anhang:spickzettel}
|
||||
|
||||
> **Wofür dieser Anhang gedacht ist:** Sie wissen, was Sie modellieren wollen, und suchen
|
||||
> nur noch, wie die gewählte Bibliothek es schreibt. Jede Seite hat denselben Aufbau —
|
||||
> Modell, Variablen, Nebenbedingungen, Lösen, **alle** Statusfälle, Lösung auslesen,
|
||||
> Stolpersteine. So lassen sich die Seiten nebeneinanderlegen.
|
||||
|
||||
**Alle Schnipsel lösen dasselbe Problem** — das Produktionsprogramm aus
|
||||
{ref:sec:oekosystem-schnellstart} mit dem bekannten Optimum **530** und den Schattenpreisen
|
||||
**12** und **1**:
|
||||
|
||||
$$\max\; 10x_1 + 15x_2 + 25x_3 \quad\text{u.d.N.}\quad
|
||||
x_1 + x_2 + 2x_3 \le 40,\;\; 2x_1 + 3x_2 + x_3 \le 50,\;\; x \ge 0$$
|
||||
|
||||
Damit ist jeder Schnipsel selbstprüfend: Kommt bei Ihnen etwas anderes als 530 heraus, liegt
|
||||
es an der Übertragung, nicht am Modell. (Die Ausnahme ist CP-SAT — ein rein stetiges LP ist
|
||||
dort das falsche Werkzeug; die Seite zeigt stattdessen die CP-SAT-eigenen Bausteine.)
|
||||
|
||||
> **Was dieser Anhang *nicht* ist.** Kein Vergleich und keine Empfehlung. Welche Bibliothek
|
||||
> für welche Aufgabe taugt, steht in {ref:sec:oekosystem-wann-lohnt-sich-welche-ebene};
|
||||
> denselben Fall in vier Bibliotheken *nebeneinander* zeigt `Ein_System_Vier_Ansaetze.py`,
|
||||
> die beiden Modellierungssprachen `Modellierungsschichten.py`. Hier geht es allein ums
|
||||
> Nachschlagen.
|
||||
|
||||
---
|
||||
|
||||
## D1 — SciPy: `linprog` und `milp`
|
||||
|
||||
**Wofür.** Die Einstiegsschicht: keine zusätzliche Installation, HiGHS als Unterbau, ideal
|
||||
für lineare und gemischt-ganzzahlige Probleme in Matrixform. **Wofür nicht:** alles, was
|
||||
sich nicht als Matrix schreiben lässt, und jede nichtlineare Zielfunktion.
|
||||
|
||||
### Lineares Programm
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
from scipy.optimize import linprog
|
||||
|
||||
# linprog MINIMIERT immer -> zum Maximieren die Zielfunktion negieren
|
||||
ergebnis = linprog(
|
||||
c=[-10.0, -15.0, -25.0], # Zielkoeffizienten
|
||||
A_ub=[[1, 1, 2], [2, 3, 1]], b_ub=[40, 50], # A_ub @ x <= b_ub
|
||||
A_eq=None, b_eq=None, # Gleichungen, falls vorhanden
|
||||
bounds=[(0, None)] * 3, # je Variable (unten, oben)
|
||||
method="highs")
|
||||
|
||||
if ergebnis.status == 0:
|
||||
print(f"optimal: {-ergebnis.fun:.2f}") # Vorzeichen zuruecknehmen!
|
||||
print(f"x = {np.round(ergebnis.x, 4)}")
|
||||
print(f"Schattenpreise: {-ergebnis.ineqlin.marginals}")
|
||||
elif ergebnis.status == 2:
|
||||
print("INFEASIBLE - kein zulaessiger Punkt")
|
||||
elif ergebnis.status == 3:
|
||||
print("UNBOUNDED - Zielfunktion unbeschraenkt")
|
||||
elif ergebnis.status == 1:
|
||||
print("Iterations- oder Zeitlimit erreicht")
|
||||
else:
|
||||
print(f"numerisches Problem (status {ergebnis.status}): {ergebnis.message}")
|
||||
```
|
||||
|
||||
### Ganzzahlig: `milp`
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
from scipy.optimize import milp, LinearConstraint, Bounds
|
||||
|
||||
# Ganzzahlig: dasselbe Problem, aber x muss ganzzahlig sein
|
||||
ergebnis = milp(
|
||||
c=[-10.0, -15.0, -25.0], # auch milp MINIMIERT
|
||||
constraints=LinearConstraint([[1, 1, 2], [2, 3, 1]], -np.inf, [40, 50]),
|
||||
integrality=[1, 1, 1], # 0 = stetig, 1 = ganzzahlig
|
||||
bounds=Bounds(0, np.inf))
|
||||
|
||||
if ergebnis.status == 0:
|
||||
print(f"optimal: {-ergebnis.fun:.2f} x = {np.round(ergebnis.x).astype(int)}")
|
||||
print(f"MIP-Gap: {ergebnis.mip_gap:.4f}")
|
||||
elif ergebnis.status == 1:
|
||||
print("Zeitlimit - beste gefundene Loesung nutzen, Gap pruefen")
|
||||
elif ergebnis.status == 2:
|
||||
print("INFEASIBLE")
|
||||
elif ergebnis.status == 3:
|
||||
print("UNBOUNDED")
|
||||
else:
|
||||
print(f"kein Ergebnis: {ergebnis.message}")
|
||||
```
|
||||
|
||||
### Die drei häufigsten Stolpersteine
|
||||
|
||||
1. **`linprog` minimiert immer.** Zum Maximieren `c` negieren — und beim Ausgeben des
|
||||
Zielwerts das Vorzeichen wieder zurücknehmen. Dieselbe Negation dreht auch die
|
||||
Schattenpreise ({ref:sec:lp-die-vorzeichenfalle-bei-schattenpreisen}).
|
||||
2. **`status == 0` prüfen, nicht `res.success` allein.** `success` ist bei
|
||||
`status == 1` (Limit erreicht) `False`, obwohl eine brauchbare Lösung vorliegen kann.
|
||||
3. **`bounds` gilt je Variable.** `bounds=(0, None)` setzt alle Variablen gleich;
|
||||
`bounds=[(0, None), (0, 10), ...]` einzeln. Wer die Liste vergisst, bekommt stillschweigend
|
||||
überall dieselbe Schranke.
|
||||
|
||||
---
|
||||
|
||||
## D2 — HiGHS über `highspy`
|
||||
|
||||
**Wofür.** Derselbe Solver wie unter SciPy, aber direkt gesteuert: Optionen, Warm-Starts,
|
||||
inkrementelles Ändern eines bestehenden Modells. **Wofür nicht:** schnelles Hinschreiben —
|
||||
die CSR-Matrixübergabe ist fehleranfällig.
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import highspy
|
||||
|
||||
h = highspy.Highs()
|
||||
h.setOptionValue("output_flag", False) # Solverprotokoll abschalten
|
||||
h.setOptionValue("time_limit", 60.0)
|
||||
h.setOptionValue("mip_rel_gap", 0.01) # 1 % Gap genuegt
|
||||
|
||||
# Variablen: Anzahl, Untergrenzen, Obergrenzen
|
||||
h.addVars(3, np.zeros(3), np.full(3, highspy.kHighsInf))
|
||||
h.changeObjectiveSense(highspy.ObjSense.kMaximize)
|
||||
for spalte, wert in enumerate([10.0, 15.0, 25.0]):
|
||||
h.changeColCost(spalte, wert)
|
||||
|
||||
# Zeilen im CSR-Format: starts[i] = Beginn von Zeile i in indices/values
|
||||
h.addRows(2, np.full(2, -highspy.kHighsInf), np.array([40.0, 50.0]), 6,
|
||||
np.array([0, 3], dtype=np.int32), # starts
|
||||
np.array([0, 1, 2, 0, 1, 2], dtype=np.int32), # Spaltenindizes
|
||||
np.array([1.0, 1.0, 2.0, 2.0, 3.0, 1.0])) # Koeffizienten
|
||||
# Ganzzahligkeit: h.changeColsIntegrality(...) mit highspy.HighsVarType.kInteger
|
||||
|
||||
h.run()
|
||||
status = h.getModelStatus()
|
||||
if status == highspy.HighsModelStatus.kOptimal:
|
||||
print(f"optimal: {h.getInfo().objective_function_value:.2f}")
|
||||
print(f"x = {np.round(h.getSolution().col_value[:3], 4)}")
|
||||
print(f"Schattenpreise: {np.round(h.getSolution().row_dual[:2], 4)}")
|
||||
elif status == highspy.HighsModelStatus.kInfeasible:
|
||||
print("INFEASIBLE")
|
||||
elif status == highspy.HighsModelStatus.kUnbounded:
|
||||
print("UNBOUNDED")
|
||||
elif status == highspy.HighsModelStatus.kTimeLimit:
|
||||
print(f"Zeitlimit, Gap {h.getInfo().mip_gap:.3f}")
|
||||
else:
|
||||
print("kein Optimum:", h.modelStatusToString(status))
|
||||
```
|
||||
|
||||
### Die drei häufigsten Stolpersteine
|
||||
|
||||
1. **Nicht zusammen mit `ortools` importieren.** Beide bringen eine eigene HiGHS-Kopie mit;
|
||||
im selben Prozess endet das in `undefined symbol` ({ref:anhang:fehlerdiagnose}, C9).
|
||||
Auch `cvxpy` zieht `highspy` bei der Solver-Erkennung mit hinein.
|
||||
2. **Das CSR-Format stimmt oder es stimmt still nicht.** `starts` hat so viele Einträge wie
|
||||
Zeilen, `indices` und `values` so viele wie Nichtnullen. Ein falscher `starts`-Eintrag
|
||||
erzeugt ein *anderes*, aber lösbares Modell — es fällt nur durch ein falsches Ergebnis auf.
|
||||
3. **`output_flag` abschalten**, sonst überschwemmt das Solverprotokoll jede Ausgabe.
|
||||
|
||||
---
|
||||
|
||||
## D3 — OR-Tools: `pywraplp`
|
||||
|
||||
**Wofür.** Bequeme algebraische Schreibweise für LP und MILP mit umschaltbarem Backend
|
||||
(`GLOP`, `SCIP`, `CBC`, `SAT`). **Wofür nicht:** Scheduling und kombinatorische
|
||||
Bedingungen — dafür ist CP-SAT (D4) da.
|
||||
|
||||
```python
|
||||
from ortools.linear_solver import pywraplp
|
||||
|
||||
# "GLOP" = LP, "SCIP" oder "CBC" = MILP, "SAT" = CP-SAT als MILP-Backend
|
||||
loeser = pywraplp.Solver.CreateSolver("GLOP")
|
||||
if loeser is None:
|
||||
raise SystemExit("Solver nicht verfuegbar")
|
||||
loeser.SetTimeLimit(60_000) # Millisekunden!
|
||||
|
||||
unendlich = loeser.infinity()
|
||||
x = [loeser.NumVar(0, unendlich, f"x{j}") for j in range(3)]
|
||||
# ganzzahlig: loeser.IntVar(0, unendlich, "n") | binaer: loeser.BoolVar("b")
|
||||
|
||||
loeser.Add(x[0] + x[1] + 2 * x[2] <= 40)
|
||||
loeser.Add(2 * x[0] + 3 * x[1] + x[2] <= 50)
|
||||
loeser.Maximize(10 * x[0] + 15 * x[1] + 25 * x[2])
|
||||
|
||||
status = loeser.Solve()
|
||||
if status == pywraplp.Solver.OPTIMAL:
|
||||
print(f"optimal: {loeser.Objective().Value():.2f}")
|
||||
print(f"x = {[round(v.solution_value(), 4) for v in x]}")
|
||||
elif status == pywraplp.Solver.FEASIBLE:
|
||||
print("zulaessig, nicht bewiesen optimal (Zeitlimit)")
|
||||
elif status == pywraplp.Solver.INFEASIBLE:
|
||||
print("INFEASIBLE")
|
||||
elif status == pywraplp.Solver.UNBOUNDED:
|
||||
print("UNBOUNDED")
|
||||
else:
|
||||
print("ABNORMAL / NOT_SOLVED - Modell oder Solver pruefen")
|
||||
```
|
||||
|
||||
### Die drei häufigsten Stolpersteine
|
||||
|
||||
1. **`CreateSolver` liefert `None`**, wenn der Backend-Name unbekannt oder nicht gebaut ist —
|
||||
immer prüfen, statt am `None` später zu scheitern.
|
||||
2. **`SetTimeLimit` erwartet Millisekunden**, nicht Sekunden. Ein `SetTimeLimit(60)` bricht
|
||||
nach einer sechzigstel Sekunde ab.
|
||||
3. **`FEASIBLE` ist kein `OPTIMAL`.** Bei Zeitlimit liefert der Solver eine gültige, aber
|
||||
möglicherweise schlechte Lösung — den Gap mitberichten, nicht die Zahl allein.
|
||||
|
||||
---
|
||||
|
||||
## D4 — CP-SAT: `cp_model`
|
||||
|
||||
**Wofür.** Scheduling, Zuordnung, Reihenfolgen, alles Kombinatorische mit globalen
|
||||
Bedingungen. **Wofür nicht:** stetige Größen — CP-SAT rechnet ausschließlich ganzzahlig.
|
||||
Wer Nachkommastellen braucht, skaliert (Cent statt Euro, Promille statt Anteil).
|
||||
|
||||
```python
|
||||
from ortools.sat.python import cp_model
|
||||
|
||||
modell = cp_model.CpModel()
|
||||
|
||||
# Variablen - CP-SAT rechnet ausschliesslich mit GANZEN Zahlen
|
||||
x = modell.NewIntVar(0, 100, "x") # untere, obere Schranke, Name
|
||||
y = modell.NewIntVar(0, 100, "y")
|
||||
b = modell.NewBoolVar("b") # 0/1
|
||||
|
||||
# Nebenbedingungen
|
||||
modell.Add(2 * x + 3 * y <= 50)
|
||||
modell.Add(x >= 5).OnlyEnforceIf(b) # gilt nur, wenn b wahr ist
|
||||
modell.AddAllDifferent([x, y]) # globale Bedingung
|
||||
modell.AddMaxEquality(z := modell.NewIntVar(0, 100, "z"), [x, y])
|
||||
|
||||
modell.Maximize(10 * x + 15 * y - 3 * z)
|
||||
|
||||
loeser = cp_model.CpSolver()
|
||||
loeser.parameters.max_time_in_seconds = 10.0
|
||||
loeser.parameters.num_workers = 1 # 1 = reproduzierbar
|
||||
loeser.parameters.random_seed = 1
|
||||
status = loeser.Solve(modell)
|
||||
|
||||
if status == cp_model.OPTIMAL:
|
||||
print(f"optimal: {loeser.ObjectiveValue():.0f} x={loeser.Value(x)} y={loeser.Value(y)}")
|
||||
elif status == cp_model.FEASIBLE:
|
||||
print(f"zulaessig, nicht bewiesen optimal - Gap-Schranke: {loeser.BestObjectiveBound()}")
|
||||
elif status == cp_model.INFEASIBLE:
|
||||
print("INFEASIBLE - Bedingungen widersprechen sich")
|
||||
elif status == cp_model.MODEL_INVALID:
|
||||
print("Modellfehler:", modell.Validate())
|
||||
else:
|
||||
print("UNKNOWN - Zeit abgelaufen, ohne eine Loesung zu finden")
|
||||
print(f"Laufzeit {loeser.WallTime():.3f}s, {loeser.NumBranches()} Verzweigungen")
|
||||
```
|
||||
|
||||
### Die drei häufigsten Stolpersteine
|
||||
|
||||
1. **Alles ist ganzzahlig.** `0.5 * x` gibt es nicht. Skalieren Sie das ganze Modell auf eine
|
||||
feinere Einheit, statt zu runden.
|
||||
2. **`UNKNOWN` heißt nicht `INFEASIBLE`.** Es heißt: Die Zeit war zu knapp, um überhaupt
|
||||
etwas zu finden. Die beiden zu verwechseln ist einer der teuersten Fehler in Produktion —
|
||||
das Modell wird für widersprüchlich erklärt, obwohl es lösbar ist.
|
||||
3. **Ohne `num_workers = 1` ist der Lauf nicht reproduzierbar.** Mehrere Suchstränge finden
|
||||
je nach Zeitverlauf verschiedene, gleich gute Lösungen. Für Tests und für abgedruckte
|
||||
Ausgaben Worker und Seed festnageln.
|
||||
|
||||
---
|
||||
|
||||
## D5 — CVXPY
|
||||
|
||||
**Wofür.** Konvexe Probleme: quadratische Ziele, Normen, CVaR, alles mit
|
||||
Regularisierungstermen. **Wofür nicht:** große kombinatorische Modelle — der Aufbau der
|
||||
Ausdrücke wird dann selbst zum Engpass.
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import cvxpy as cp
|
||||
|
||||
x = cp.Variable(3, nonneg=True) # nonneg=True statt x >= 0
|
||||
gewichte = cp.Variable(3)
|
||||
ganzzahlig = cp.Variable(3, integer=True) # macht daraus ein MIP
|
||||
|
||||
ziel = cp.Maximize(np.array([10.0, 15.0, 25.0]) @ x)
|
||||
bedingungen = [np.array([[1, 1, 2], [2, 3, 1]]) @ x <= np.array([40.0, 50.0])]
|
||||
|
||||
problem = cp.Problem(ziel, bedingungen)
|
||||
if not problem.is_dcp(): # VOR dem Loesen pruefen
|
||||
print("nicht DCP:", [c for c in bedingungen if not c.is_dcp()])
|
||||
problem.solve()
|
||||
|
||||
if problem.status == cp.OPTIMAL:
|
||||
print(f"optimal: {problem.value:.2f} x = {np.round(x.value, 4)}")
|
||||
print(f"Schattenpreis: {np.round(bedingungen[0].dual_value, 4)}")
|
||||
elif problem.status == cp.OPTIMAL_INACCURATE:
|
||||
print("Loesung numerisch unsicher - Skalierung pruefen, anderen Solver testen")
|
||||
elif problem.status == cp.INFEASIBLE:
|
||||
print("INFEASIBLE")
|
||||
elif problem.status == cp.UNBOUNDED:
|
||||
print("UNBOUNDED")
|
||||
else:
|
||||
print("Solverfehler:", problem.status)
|
||||
```
|
||||
|
||||
### Die drei häufigsten Stolpersteine
|
||||
|
||||
1. **DCP-Regeln vor dem Lösen prüfen.** `problem.is_dcp()` und dann die einzelnen
|
||||
Bedingungen — das nennt die Schuldige, statt einen `DCPError` ohne Ort zu werfen
|
||||
({ref:anhang:fehlerdiagnose}, C8).
|
||||
2. **`OPTIMAL_INACCURATE` ist kein Erfolg.** Der Solver hat aufgegeben und meldet das leise.
|
||||
Diesen Fall immer eigens behandeln.
|
||||
3. **Der Aufbau kann teurer sein als das Lösen.** Schleifen über Szenarien durch
|
||||
Vektorausdrücke ersetzen; `Parameter` statt Neuaufbau, wenn sich nur Zahlen ändern.
|
||||
|
||||
---
|
||||
|
||||
## D6 — Modellierungssprachen: Pyomo und Linopy
|
||||
|
||||
**Wofür.** Beide trennen *Modell* von *Solver*: dasselbe Modell läuft ohne Änderung unter
|
||||
HiGHS, CBC, Gurobi. Pyomo denkt in **Mengen und Indizes** wie eine mathematische
|
||||
Formulierung; Linopy denkt in **beschrifteten Arrays** und baut Nebenbedingungen als
|
||||
Matrixoperation statt in Python-Schleifen. **Wofür nicht:** ein Modell mit zehn
|
||||
Nebenbedingungen — dort ist der Aufwand höher als der Nutzen.
|
||||
|
||||
### Pyomo
|
||||
|
||||
```python
|
||||
import pyomo.environ as pyo
|
||||
|
||||
modell = pyo.ConcreteModel()
|
||||
modell.J = pyo.RangeSet(0, 2) # Indexmenge
|
||||
modell.x = pyo.Var(modell.J, domain=pyo.NonNegativeReals)
|
||||
# ganzzahlig: domain=pyo.NonNegativeIntegers | binaer: domain=pyo.Binary
|
||||
|
||||
ertrag = {0: 10.0, 1: 15.0, 2: 25.0}
|
||||
modell.ziel = pyo.Objective(expr=sum(ertrag[j] * modell.x[j] for j in modell.J),
|
||||
sense=pyo.maximize)
|
||||
modell.montage = pyo.Constraint(
|
||||
expr=modell.x[0] + modell.x[1] + 2 * modell.x[2] <= 40)
|
||||
modell.pruefung = pyo.Constraint(
|
||||
expr=2 * modell.x[0] + 3 * modell.x[1] + modell.x[2] <= 50)
|
||||
modell.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT) # fuer Schattenpreise
|
||||
|
||||
ergebnis = pyo.SolverFactory("appsi_highs").solve(modell)
|
||||
zustand = ergebnis.solver.termination_condition
|
||||
if zustand == pyo.TerminationCondition.optimal:
|
||||
print(f"optimal: {pyo.value(modell.ziel):.2f}")
|
||||
print(f"x = {[round(pyo.value(modell.x[j]), 4) for j in modell.J]}")
|
||||
print(f"Schattenpreis Montage: {modell.dual[modell.montage]:.4f}")
|
||||
elif zustand == pyo.TerminationCondition.infeasible:
|
||||
print("INFEASIBLE")
|
||||
elif zustand == pyo.TerminationCondition.unbounded:
|
||||
print("UNBOUNDED")
|
||||
elif zustand == pyo.TerminationCondition.maxTimeLimit:
|
||||
print("Zeitlimit")
|
||||
else:
|
||||
print("kein Optimum:", zustand)
|
||||
```
|
||||
|
||||
### Linopy
|
||||
|
||||
```python
|
||||
import linopy
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import xarray as xr
|
||||
|
||||
# Benannte Indizes statt blosser Listen - sonst heissen die Achsen "dim_0"
|
||||
produkt = pd.Index(["Rahmen", "Gehaeuse", "Deckel"], name="produkt")
|
||||
ressource = pd.Index(["Montage", "Pruefung"], name="ressource")
|
||||
|
||||
modell = linopy.Model()
|
||||
modell.add_variables(lower=0, coords=[produkt], name="menge")
|
||||
x = modell.variables["menge"]
|
||||
# ganzzahlig: integer=True | binaer: binary=True
|
||||
|
||||
ertrag = xr.DataArray([10.0, 15.0, 25.0], coords=[produkt])
|
||||
verbrauch = xr.DataArray([[1.0, 1.0, 2.0], [2.0, 3.0, 1.0]],
|
||||
coords=[ressource, produkt])
|
||||
vorrat = xr.DataArray([40.0, 50.0], coords=[ressource])
|
||||
|
||||
modell.add_objective((ertrag * x).sum(), sense="max")
|
||||
# EINE Zeile, so viele Nebenbedingungen wie 'ressource' Eintraege hat:
|
||||
modell.add_constraints((verbrauch * x).sum("produkt") <= vorrat, name="kapazitaet")
|
||||
|
||||
modell.solve(solver_name="highs", output_flag=False)
|
||||
|
||||
if modell.termination_condition == "optimal":
|
||||
print(f"optimal: {modell.objective.value:.2f}")
|
||||
print(x.solution.to_series().round(4).to_dict())
|
||||
print("Schattenpreise:",
|
||||
modell.constraints["kapazitaet"].dual.to_series().round(4).to_dict())
|
||||
elif modell.termination_condition == "infeasible":
|
||||
print("INFEASIBLE")
|
||||
elif modell.termination_condition == "unbounded":
|
||||
print("UNBOUNDED")
|
||||
else:
|
||||
print("kein Optimum:", modell.status, modell.termination_condition)
|
||||
```
|
||||
|
||||
### Die drei häufigsten Stolpersteine
|
||||
|
||||
1. **Der Solver ist ein eigenes Programm.** `SolverFactory("appsi_highs")` scheitert, wenn
|
||||
HiGHS nicht auffindbar ist — die Fehlermeldung nennt dann das Modell, nicht die fehlende
|
||||
Installation.
|
||||
2. **Schattenpreise kommen nur auf Anforderung.** Bei Pyomo braucht es
|
||||
`Suffix(direction=IMPORT)` *vor* dem Lösen; wer ihn vergisst, bekommt einen `KeyError`
|
||||
statt einer Warnung.
|
||||
3. **Bei Linopy die Indizes benennen** (`pd.Index(..., name="produkt")`). Ohne Namen heißen
|
||||
die Achsen `dim_0`, und jede spätere Zuordnung wird zum Ratespiel.
|
||||
|
||||
---
|
||||
|
||||
## Die gemeinsame Regel
|
||||
|
||||
Alle sechs Seiten haben denselben längsten Abschnitt: die **Statusauswertung**. Das ist kein
|
||||
Zufall. Ein Solveraufruf hat nie zwei Ausgänge, sondern mindestens fünf — optimal, zulässig
|
||||
ohne Beweis, unlösbar, unbeschränkt, abgebrochen. Code, der nur `if erfolgreich:` prüft,
|
||||
verwechselt früher oder später „keine Lösung gefunden“ mit „es gibt keine Lösung“, und diese
|
||||
Verwechslung merkt niemand, bis sie teuer wird.
|
||||
|
||||
---
|
||||
|
||||
*Weiter mit:* [{ref:anhang:glossar-literatur} — Glossar und Literatur](94_Anhang_Glossar_und_Literatur.md)
|
||||
|
|
@ -0,0 +1,373 @@
|
|||
# Anhang: Glossar und Literatur {#anhang:glossar-literatur}
|
||||
|
||||
---
|
||||
|
||||
# Glossar
|
||||
|
||||
**A**
|
||||
|
||||
* **Almgren-Chriss-Modell**{idx:Almgren-Chriss-Modell} — Standardmodell der optimalen Orderausführung. Es löst den
|
||||
Zielkonflikt zwischen Marktauswirkung bei schnellem Handeln und Volatilitätsrisiko bei
|
||||
langsamem Handeln. → {ref:sec:dynamische-programmierung-das-almgren-chriss-problem-optimale-orderausfuehrung}
|
||||
* **Alternativoptima**{idx:Alternativoptima} — Mehrere Lösungen mit identischem Zielfunktionswert. Tritt auf, wenn
|
||||
die Zielfunktion parallel zu einer Kante des Polyeders verläuft oder die Matrix nur
|
||||
semidefinit ist. → {ref:sec:qp-nlp-das-quadratische-programm-qp}
|
||||
|
||||
**B**
|
||||
|
||||
* **Bellman-Gleichung**{idx:Bellman-Gleichung} — Rekursionsgleichung der dynamischen Programmierung: Der Wert eines
|
||||
Zustands ist die Summe aus den unmittelbaren Kosten der besten Aktion und dem Wert des
|
||||
Folgezustands. → {ref:sec:dynamische-programmierung-das-bellmansche-optimalitaetsprinzip}
|
||||
* **Big-M-Methode**{idx:Big-M-Methode} — Modellierungstrick, der logische Bedingungen über eine hinreichend
|
||||
große Konstante $M$ an eine Binärvariable koppelt. $M$ sollte **so klein wie möglich**
|
||||
gewählt werden, da große Werte die Relaxation aufweichen und die Laufzeit verschlechtern.
|
||||
→ {ref:sec:milp-modellierungstricks-big-m-und-logische-bedingungen}
|
||||
* **Binärvariable**{idx:Binärvariable} — Entscheidungsvariable mit Wertebereich $\{0,1\}$; sie schaltet
|
||||
Fixkosten, Kapazitäten oder logische Alternativen an und aus. → {ref:sec:einfuehrung-die-vier-universellen-bausteine-jedes-or}
|
||||
* **Bipartites Matching**{idx:Bipartites Matching} — Zuordnung zwischen zwei disjunkten Mengen mit maximalem Nutzen
|
||||
oder minimalen Kosten; klassisch gelöst durch den Ungarischen Algorithmus. → {ref:sec:graphen-bipartites-matching-das-zuordnungsproblem}
|
||||
* **Branch-and-Bound**{idx:Branch-and-Bound} — Exaktes Verfahren für ganzzahlige Probleme: Der Suchraum wird
|
||||
rekursiv zerlegt (Branching), Zweige werden verworfen, sobald ihre Relaxation schlechter
|
||||
ist als die beste bekannte Lösung (Bounding). → {ref:sec:milp-branch-and-bound}
|
||||
* **Budgeted Uncertainty**{idx:Budgeted Uncertainty} — Robustheitsansatz nach Bertsimas/Sim: Höchstens $\Gamma$ von
|
||||
$n$ Parametern nehmen gleichzeitig ihren ungünstigsten Wert an. → {ref:sec:unsicherheit-robuste-optimierung-gegen-den-worst-case}
|
||||
|
||||
**C**
|
||||
|
||||
* **Calmar Ratio**{idx:Calmar Ratio} — Jahresrendite geteilt durch den Betrag des maximalen Drawdowns.
|
||||
Ergänzt die Sharpe Ratio um die Verlustperspektive. → {ref:sec:handelsmaschine-die-engine}
|
||||
* **Conditional Value at Risk (CVaR)**{idx:Conditional Value at Risk (CVaR)} — Erwarteter Verlust in den schlechtesten Szenarien
|
||||
jenseits des VaR. Kohärent, subadditiv und nach Rockafellar/Uryasev exakt als lineares
|
||||
Programm formulierbar. → {ref:sec:cvar-value-at-risk-und-conditional-value}
|
||||
* **Conflict Learning**{idx:CDCL!Conflict Learning} — Technik moderner SAT-Solver, aus jedem Widerspruch eine
|
||||
Sperrklausel abzuleiten, damit dieselbe Sackgasse nicht erneut betreten wird. → {ref:sec:cpsat-ein-anderes-denkmodell}
|
||||
* **Constraint Programming (CP)**{idx:Constraint Programming (CP)} — Paradigma, das nicht über Zielfunktionsgradienten,
|
||||
sondern über logische Verträglichkeit sucht: Constraints schränken Wertebereiche ein, bis
|
||||
eine zulässige Belegung gefunden ist. → {ref:sec:cpsat-ein-anderes-denkmodell}
|
||||
* **Constraint Propagation**{idx:Constraint Propagation} — Kernmechanismus von CP-Solvern: Aus einer Zuweisung werden
|
||||
unmögliche Werte anderer Variablen sofort entfernt, wodurch der Suchbaum schrumpft.
|
||||
→ {ref:sec:cpsat-ein-anderes-denkmodell}
|
||||
* **CP-SAT**{idx:CP-SAT} — Constraint-Programming-Solver von OR-Tools, der ein Modell in boolesche
|
||||
Erfüllbarkeit übersetzt und mit Propagation, Conflict Learning und paralleler Suche löst.
|
||||
→ {ref:sec:cpsat-ein-anderes-denkmodell}
|
||||
* **CVXPY**{idx:CVXPY} — Modellierungssprache für konvexe Optimierung: schreibt das Problem in
|
||||
mathematiknaher Notation, prüft die Konvexität und reicht es an einen Solver weiter.
|
||||
→ {ref:sec:oekosystem-die-werkzeuge-im-vergleich}
|
||||
|
||||
**D**
|
||||
|
||||
* **Dualitätstheorie**{idx:Dualitätstheorie} — Jedem Optimierungsproblem (Primal) steht ein Dualproblem gegenüber.
|
||||
Der starke Dualitätssatz besagt, dass beide im Optimum denselben Zielfunktionswert
|
||||
besitzen. → {ref:sec:lp-dualitaet-und-schattenpreise}
|
||||
* **Dynamische Programmierung (DP)**{idx:Dynamische Programmierung} — Lösungsprinzip für mehrstufige Entscheidungen: Das
|
||||
Problem wird in Zustände und Stufen zerlegt und rückwärts gelöst. → {ref:sec:dynamische-programmierung-das-bellmansche-optimalitaetsprinzip}
|
||||
|
||||
**E**
|
||||
|
||||
* **Efficient Frontier**{idx:Effizienzgrenze} — Kurve aller Portfolios, die zu gegebenem Risiko die höchste
|
||||
erwartete Rendite liefern. → {ref:sec:markowitz-das-mean-variance-modell}
|
||||
* **Entscheidungsvariable**{idx:Entscheidungsvariable} — Die vom Solver frei wählbare Größe eines Modells. Ihr
|
||||
Wertebereich (kontinuierlich, ganzzahlig, binär) bestimmt die Problemklasse. → {ref:sec:einfuehrung-die-vier-universellen-bausteine-jedes-or}
|
||||
* **Error-Maximizer-Effekt**{idx:Error-Maximizer-Effekt} — Eigenschaft der Mean-Variance-Optimierung, Schätzfehler zu
|
||||
verstärken statt auszugleichen: Sie sucht genau die Richtungen, deren Varianz am stärksten
|
||||
unterschätzt wurde. → {ref:sec:finanzdaten-das-schaetzfehler-problem}
|
||||
* **EVPI (Expected Value of Perfect Information)**{idx:EVPI} — Differenz zwischen den Kosten unter
|
||||
Unsicherheit und den Kosten bei perfektem Wissen; Obergrenze für den Wert jeder
|
||||
Prognoseverbesserung. → {ref:sec:unsicherheit-zweistufige-stochastische-programmierung}
|
||||
* **Explainable OR**{idx:Explainable OR} — Nachvollziehbarmachung von Solver-Ergebnissen über Schattenpreise,
|
||||
aktive Restriktionen und Kostenzerlegung. → {ref:sec:praxisfallen-die-fuenf-typischen-praxisfallen}
|
||||
|
||||
**F**
|
||||
|
||||
* **Fat Tails**{idx:Fat Tails} — Verteilungsränder, die dicker auslaufen als bei der Normalverteilung:
|
||||
Extremereignisse sind deutlich häufiger, als das Normalmodell vorhersagt. → {ref:sec:cvar-die-zwei-schwaechen-des-markowitz-modells}
|
||||
* **Fluch der Dimensionalität**{idx:Fluch der Dimensionalität} — Exponentielles Wachstum des Zustandsraums mit jeder
|
||||
zusätzlichen Zustandsdimension; begrenzt die dynamische Programmierung. → {ref:sec:dynamische-programmierung-der-fluch-der-dimensionalitaet}
|
||||
* **Fluch des Durchschnitts** (*Flaw of Averages*) — Systematischer Fehler beim Planen mit
|
||||
Erwartungswerten statt mit Verteilungen; folgt aus der Jensenschen Ungleichung.
|
||||
→ {ref:sec:unsicherheit-der-fluch-des-durchschnitts}
|
||||
* **Flusserhaltung**{idx:Flusserhaltung} — Bedingung, dass an jedem Knoten Abfluss minus Zufluss dem Saldo des
|
||||
Knotens entspricht — das Kirchhoff-Gesetz der Netzwerkoptimierung. → {ref:sec:graphen-graphen-als-modellsprache}
|
||||
* **Fundamentalsatz der linearen Optimierung**{idx:Fundamentalsatz der linearen Optimierung} — Das Optimum eines lösbaren LP wird stets in
|
||||
mindestens einer Ecke des zulässigen Polyeders angenommen. → {ref:sec:fundament-der-zulaessige-loesungsraum-und-das-polyeder}
|
||||
|
||||
**G**
|
||||
|
||||
* **Gemischt-ganzzahlige Optimierung (MILP)**{idx:MILP} — Lineares Modell mit mindestens einer
|
||||
ganzzahligen oder binären Variablen; NP-schwer. → {ref:sec:milp-warum-runden-fundamental-scheitert}
|
||||
* **Global Minimum Variance Portfolio (GMV)**{idx:GMV (Global Minimum Variance)} — Portfolio kleinstmöglicher Varianz. Es
|
||||
benötigt **keine Renditeprognose** und ist deshalb robuster gegen Schätzfehler.
|
||||
→ {ref:sec:markowitz-das-mean-variance-modell}
|
||||
* **Globale Constraints**{idx:Globale Constraints} — Vorgefertigte Bausteine wie `AllDifferent`, `NoOverlap` oder
|
||||
`Cumulative`, die häufige Strukturen kompakt ausdrücken und spezialisierte Propagatoren
|
||||
besitzen. → {ref:sec:cpsat-globale-constraints-die-bausteine-von-cp}
|
||||
|
||||
**H**
|
||||
|
||||
* **HiGHS**{idx:HiGHS} — Offener Hochleistungs-Solver für LP, MILP und QP; Backend von
|
||||
`scipy.optimize.linprog` und CVXPY, direkt ansprechbar über `highspy`. → {ref:sec:oekosystem-die-werkzeuge-im-vergleich}
|
||||
|
||||
**I**
|
||||
|
||||
* **Infeasibility**{idx:Infeasibility} — Zustand eines Modells ohne zulässige Lösung. In der Praxis über
|
||||
Schlupfvariablen mit hohem Strafgewicht aufzufangen. → {ref:sec:praxisfallen-die-fuenf-typischen-praxisfallen}
|
||||
* **Intervallvariable**{idx:Intervallvariable} — Variable mit Start, Dauer und Ende, die in CP-SAT eine Aktivität
|
||||
beschreibt und Überschneidungsverbote auf Ressourcen ermöglicht. → **{ref:sec:cpsat-globale-constraints-die-bausteine-von-cp}**
|
||||
|
||||
**J**
|
||||
|
||||
* **Jensensche Ungleichung**{idx:Jensensche Ungleichung} — Für konvexe Funktionen gilt
|
||||
$\mathbb{E}[f(X)] \ge f(\mathbb{E}[X])$; die formale Grundlage des Fluchs des
|
||||
Durchschnitts. → {ref:sec:unsicherheit-der-fluch-des-durchschnitts}
|
||||
|
||||
**K**
|
||||
|
||||
* **Kanonische Standardform**{idx:Kanonische Standardform} — Einheitliche Matrixschreibweise eines LP als
|
||||
$\min \mathbf{c}^\top\mathbf{x}$ u. d. N. $\mathbf{A}\mathbf{x} \le \mathbf{b}$,
|
||||
$\mathbf{x} \ge 0$. → {ref:sec:fundament-warum-ueberhaupt-vektoren-und-matrizen}
|
||||
* **Kardinalitätsbeschränkung**{idx:Kardinalitätsbeschränkung} — Obergrenze für die Anzahl gleichzeitig aktiver
|
||||
Entscheidungen; über die Summe der zugehörigen Binärvariablen formuliert. → {ref:sec:milp-modellierungstricks-big-m-und-logische-bedingungen}
|
||||
* **Karush-Kuhn-Tucker-Bedingungen (KKT)**{idx:KKT-Bedingungen} — Notwendige Optimalitätsbedingungen
|
||||
restringierter Probleme: Stationarität, primale und duale Zulässigkeit sowie
|
||||
komplementärer Schlupf. Bei konvexen Problemen zugleich hinreichend. → {ref:sec:qp-nlp-die-karush-kuhn-tucker-bedingungen}
|
||||
* **Kohärentes Risikomaß**{idx:Kohärentes Risikomaß} — Risikomaß mit den Eigenschaften Monotonie, Subadditivität,
|
||||
positive Homogenität und Translationsinvarianz. Der CVaR erfüllt sie, der VaR nicht.
|
||||
→ {ref:sec:cvar-value-at-risk-und-conditional-value}
|
||||
* **Kombinatorische Explosion**{idx:Kombinatorische Explosion} — Überproportionales Wachstum des Lösungsraums mit der
|
||||
Problemgröße. → {ref:sec:einfuehrung-warum-ausprobieren-scheitert-mit-eigener-rechnung}
|
||||
* **Komplementärer Schlupf**{idx:Komplementärer Schlupf} — Bedingung $s_i \cdot y_i = 0$: Eine Nebenbedingung ist
|
||||
entweder aktiv oder ihr Multiplikator verschwindet. → {ref:sec:lp-dualitaet-und-schattenpreise}
|
||||
* **Konditionszahl**{idx:Konditionszahl} — Verhältnis von größtem zu kleinstem Eigenwert; misst, wie stark sich
|
||||
kleine Datenänderungen auf die Inverse auswirken. → {ref:sec:finanzdaten-praxis-datenpipeline-mit-korrekter-spaltenreihenfolge}
|
||||
* **Konvexität**{idx:Konvexität} — Eigenschaft, bei der jede Verbindungsstrecke zweier Punkte innerhalb der
|
||||
Menge bzw. unterhalb des Funktionsgraphen liegt. Bei konvexen Problemen ist jedes lokale
|
||||
Optimum global. → {ref:sec:fundament-konvexitaet-die-grenze-zwischen-leicht-und}
|
||||
* **Korn-Transformation**{idx:Korn-Transformation} — Umformung der nicht-konvexen Sharpe-Ratio-Maximierung in ein
|
||||
konvexes QP durch Homogenisierung. → {ref:sec:markowitz-das-mean-variance-modell}
|
||||
* **Kovarianzmatrix**{idx:Kovarianzmatrix} — Matrix der paarweisen Kovarianzen. Steuert im Markowitz-Modell den
|
||||
Diversifikationseffekt; bei vielen Titeln und wenigen Beobachtungen notorisch schlecht
|
||||
konditioniert. → {ref:sec:finanzdaten-das-schaetzfehler-problem}
|
||||
|
||||
**L**
|
||||
|
||||
* **Lagrange-Multiplikator**{idx:Lagrange-Multiplikator} — Gewicht, mit dem eine Nebenbedingung in die Lagrange-Funktion
|
||||
eingeht; sein Optimalwert entspricht dem Schattenpreis. → {ref:sec:qp-nlp-die-karush-kuhn-tucker-bedingungen}
|
||||
* **Ledoit-Wolf-Shrinkage**{idx:Ledoit-Wolf-Shrinkage} — Schrumpfung der Stichprobenkovarianz in Richtung eines
|
||||
strukturierten Ziels; der optimale Faktor wird analytisch bestimmt. `scikit-learn`
|
||||
verwendet die skalierte Einheitsmatrix als Ziel. → {ref:sec:finanzdaten-ledoit-wolf-shrinkage}
|
||||
* **Lineare Programmierung (LP)**{idx:Lineare Programmierung (LP)} — Optimierung einer linearen Zielfunktion unter linearen
|
||||
Nebenbedingungen mit kontinuierlichen Variablen. → {ref:sec:lp-die-standardform-und-schlupfvariablen}
|
||||
* **Logarithmische Rendite**{idx:Logarithmische Rendite} — Stetige Rendite als Logarithmus des Preisverhältnisses;
|
||||
**über die Zeit** additiv. → {ref:sec:finanzdaten-diskrete-und-logarithmische-renditen}
|
||||
* **Lookahead-Bias**{idx:Lookahead-Bias} — Fehler, bei dem Informationen einfließen, die zum
|
||||
Entscheidungszeitpunkt nicht vorlagen. → {ref:sec:handelsmaschine-die-fuenf-selbsttaeuschungen-des-backtestens}
|
||||
|
||||
**M**
|
||||
|
||||
* **Market Impact**{idx:Marktauswirkung} — Preisverschlechterung, die eine eigene Order durch ihr Volumen
|
||||
auslöst; wächst überproportional mit der Handelsgeschwindigkeit. → {ref:sec:dynamische-programmierung-das-almgren-chriss-problem-optimale-orderausfuehrung}
|
||||
* **Maximum Drawdown**{idx:Maximum Drawdown} — Größter prozentualer Rückgang vom bisherigen Höchststand.
|
||||
→ {ref:sec:handelsmaschine-die-engine}
|
||||
* **Min-Cost-Flow-Problem (MCNFP)**{idx:Min-Cost-Flow} — Kostengünstigster Transport durch ein Netzwerk unter
|
||||
Kapazitäts- und Flusserhaltungsbedingungen; total unimodular, daher ganzzahlige
|
||||
LP-Lösungen. → {ref:sec:graphen-graphen-als-modellsprache}
|
||||
* **MIP-Gap**{idx:MIP-Gap} — Relativer Abstand zwischen bester gefundener Lösung und bester bekannter
|
||||
Schranke. → {ref:sec:praxisfallen-die-fuenf-typischen-praxisfallen}
|
||||
* **Moderne Portfoliotheorie (Markowitz)**{idx:Markowitz-Modell (Mean-Variance)} — Rahmenwerk, das ein Portfolio über das
|
||||
Zusammenspiel von erwarteter Rendite und Kovarianz bewertet. → {ref:sec:markowitz-das-mean-variance-modell}
|
||||
* **Monte-Carlo-Simulation**{idx:Monte-Carlo-Simulation} — Erzeugung vieler Zufallsszenarien, um Kennzahlen empirisch zu
|
||||
schätzen. **Bewertet**, optimiert aber nicht. → {ref:sec:unsicherheit-monte-carlo-simulation}
|
||||
* **MTZ-Formulierung**{idx:MTZ-Formulierung} — Miller-Tucker-Zemlin-Bedingungen, die über Rangvariablen
|
||||
Kurzzyklen ausschließen. → {ref:sec:graphen-das-vehicle-routing-problem-mit-zeitfenstern}
|
||||
|
||||
**N**
|
||||
|
||||
* **Nebenbedingung (Constraint)**{idx:Nebenbedingung} — Gleichung oder Ungleichung, die zulässige von
|
||||
unzulässigen Lösungen trennt. Harte müssen erfüllt sein, weiche werden über Strafterme
|
||||
lediglich bestraft. → {ref:sec:einfuehrung-die-vier-universellen-bausteine-jedes-or}
|
||||
* **Nichtlineare Programmierung (NLP)**{idx:Nichtlineare Programmierung (NLP)} — Problemklasse mit nichtlinearer Ziel- oder
|
||||
Nebenbedingungsfunktion. Ohne Konvexität liefern Verfahren wie SLSQP nur lokale Optima.
|
||||
→ {ref:sec:qp-nlp-nichtlineare-optimierung-mit-scipy-optimize-minimize}
|
||||
|
||||
**O**
|
||||
|
||||
* **Operations Research (OR)**{idx:Operations Research} — Disziplin, die reale Entscheidungsprobleme in mathematische
|
||||
Modelle überführt und mit exakten oder heuristischen Algorithmen löst. → {ref:sec:einfuehrung-was-ist-operations-research-wirklich}
|
||||
* **Optimalitätsprinzip**{idx:Optimalitätsprinzip} — Grundsatz von Bellman: Jede Teilpolitik einer optimalen Politik
|
||||
ist ihrerseits optimal für den erreichten Zustand. → {ref:sec:dynamische-programmierung-das-bellmansche-optimalitaetsprinzip}
|
||||
|
||||
**P**
|
||||
|
||||
* **Pivotisierung**{idx:Pivotisierung} — Basiswechsel im Simplex-Verfahren. → {ref:sec:lp-der-simplex-algorithmus-schritt-fuer-schritt}
|
||||
* **Polyeder**{idx:Polyeder} — Schnittmenge endlich vieler Halbräume; die geometrische Gestalt des
|
||||
zulässigen Bereichs eines LP. → {ref:sec:fundament-der-zulaessige-loesungsraum-und-das-polyeder}
|
||||
* **Positiv (semi-)definit**{idx:Positiv (semi-)definit} — Eigenschaft einer symmetrischen Matrix, deren Eigenwerte alle
|
||||
$> 0$ (definit) bzw. $\ge 0$ (semidefinit) sind. Definit ⟹ streng konvex, eindeutige
|
||||
Lösung; semidefinit ⟹ konvex, evtl. mehrere Lösungen. → {ref:sec:qp-nlp-das-quadratische-programm-qp}
|
||||
* **Präskriptive Analytik**{idx:Präskriptive Analytik} — Analysestufe, die vorschreibt, welche Handlung unter den
|
||||
gegebenen Bedingungen die beste ist. → {ref:sec:einfuehrung-was-ist-operations-research-wirklich}
|
||||
|
||||
**Q**
|
||||
|
||||
* **Quadratische Programmierung (QP)**{idx:Quadratische Programmierung (QP)} — Optimierung einer quadratischen Zielfunktion unter
|
||||
linearen Nebenbedingungen. → {ref:sec:qp-nlp-das-quadratische-programm-qp}
|
||||
|
||||
**R**
|
||||
|
||||
* **Rebalancing**{idx:Rebalancing} — Periodische Rückführung des Portfolios auf die Zielgewichte.
|
||||
→ **{ref:sec:handelsmaschine-rebalancing-termine-richtig-bestimmen}**
|
||||
* **Regime-Shift**{idx:Regime-Shift} — Strukturbruch in den Daten, nach dem historisch geschätzte Momente ihre
|
||||
Gültigkeit verlieren. → {ref:sec:praxisfallen-die-fuenf-typischen-praxisfallen}
|
||||
* **Relaxation**{idx:Relaxation} — Absichtliches Weglassen einschränkender Forderungen — typischerweise der
|
||||
Ganzzahligkeit —, um eine schnell berechenbare Schranke zu gewinnen. → {ref:sec:milp-branch-and-bound}
|
||||
* **Robuste Optimierung**{idx:Robuste Optimierung} — Auslegung auf den ungünstigsten Fall innerhalb einer
|
||||
Unsicherheitsmenge; verlangt keine Wahrscheinlichkeiten. → {ref:sec:unsicherheit-robuste-optimierung-gegen-den-worst-case}
|
||||
* **Rockafellar-Uryasev-Theorem**{idx:Rockafellar-Uryasev-Theorem} — Ergebnis, das die CVaR-Minimierung in eine konvexe
|
||||
Hilfsfunktion überführt und die Optimierung des Tail-Risikos mit linearen Solvern erlaubt.
|
||||
→ {ref:sec:cvar-value-at-risk-und-conditional-value}
|
||||
|
||||
**S**
|
||||
|
||||
* **Schattenpreis**{idx:Schattenpreis} — Optimalwert einer Dualvariablen: Um wie viel ändert sich der Zielwert,
|
||||
wenn die zugehörige Ressource um eine Einheit erweitert wird. → {ref:sec:lp-dualitaet-und-schattenpreise}
|
||||
* **Schätzfehler**{idx:Schätzfehler} — Abweichung geschätzter Momente von den wahren Werten. → {ref:sec:finanzdaten-das-schaetzfehler-problem}
|
||||
* **Schlupfvariable**{idx:Schlupfvariable} — Nichtnegative Hilfsvariable, die eine Ungleichung in eine Gleichung
|
||||
überführt; ihr Wert zeigt die ungenutzte Reserve. → {ref:sec:lp-die-standardform-und-schlupfvariablen}
|
||||
* **Schnittebenenverfahren (Cutting Planes)**{idx:Schnittebenen} — Zusätzliche gültige Ungleichungen, die
|
||||
gebrochene LP-Lösungen abschneiden, ohne ganzzahlige Punkte zu verlieren. → {ref:sec:milp-branch-and-bound}
|
||||
* **Sensitivitätsanalyse**{idx:Sensitivitätsanalyse} — Untersuchung, in welchem Bereich sich Koeffizienten und
|
||||
Kapazitäten ändern dürfen, ohne die Struktur der Optimallösung zu verändern. → {ref:sec:lp-praxisfall-sensitivitaetsanalyse-mit-korrekten-schattenpreisen}
|
||||
* **Sharpe Ratio**{idx:Sharpe-Ratio} — Überrendite über den risikofreien Zins je Einheit Volatilität.
|
||||
→ {ref:sec:markowitz-das-mean-variance-modell}
|
||||
* **Simplex-Algorithmus**{idx:Simplex-Algorithmus} — Verfahren von Dantzig, das von Ecke zu Ecke wandert und dabei
|
||||
den Zielfunktionswert monoton verbessert. → {ref:sec:lp-der-simplex-algorithmus-schritt-fuer-schritt}
|
||||
* **SLSQP**{idx:SLSQP} — *Sequential Least Squares Programming*; gradientenbasiertes Verfahren in
|
||||
`scipy.optimize.minimize` für nichtlineare Probleme. → {ref:sec:qp-nlp-nichtlineare-optimierung-mit-scipy-optimize-minimize}
|
||||
* **Subadditivität**{idx:Subadditivität} — Eigenschaft $\rho(A+B) \le \rho(A)+\rho(B)$: Diversifikation darf das
|
||||
Risiko nicht erhöhen. Der VaR verletzt sie. → {ref:sec:cvar-value-at-risk-und-conditional-value}
|
||||
* **Survivorship-Bias**{idx:Survivorship-Bias} — Verzerrung durch Auswahl nur der heute noch existierenden Titel.
|
||||
→ {ref:sec:handelsmaschine-die-fuenf-selbsttaeuschungen-des-backtestens}
|
||||
* **Symmetriebrechung**{idx:Symmetriebrechung} — Zusätzliche Ordnungsbedingungen, die verhindern, dass der Solver
|
||||
gleichwertige Vertauschungen mehrfach durchsucht. → {ref:anhang:modellierungsmuster}, Muster B20
|
||||
|
||||
**T**
|
||||
|
||||
* **Totale Unimodularität**{idx:Totale Unimodularität} — Eigenschaft einer Matrix, bei der jede quadratische
|
||||
Teilmatrix die Determinante $0$, $+1$ oder $-1$ hat. Folge: Alle Ecken des Polyeders sind
|
||||
ganzzahlig — Ganzzahligkeit muss nicht gefordert werden. → {ref:sec:graphen-bipartites-matching-das-zuordnungsproblem}
|
||||
* **Transaktionskosten**{idx:Transaktionskosten} — Beim Umschichten anfallende Kosten aus Gebühren, Spread und
|
||||
Market Impact; über eine $L_1$-Strafe modellierbar. → {ref:sec:cvar-transaktionskosten-ueber-die-l-1-norm}
|
||||
* **Turnover**{idx:Turnover (Umschlag)} — Summe der absoluten Gewichtsänderungen einer Umschichtung. → {ref:sec:cvar-transaktionskosten-ueber-die-l-1-norm}
|
||||
|
||||
**U**
|
||||
|
||||
* **Unsicherheitsmenge**{idx:Unsicherheitsmenge} — Vorab definierter Bereich möglicher Parameterwerte, gegen dessen
|
||||
ungünstigstes Element eine robuste Lösung abgesichert wird. → {ref:sec:unsicherheit-robuste-optimierung-gegen-den-worst-case}
|
||||
|
||||
**V**
|
||||
|
||||
* **Value at Risk (VaR)**{idx:Value at Risk (VaR)} — Verlustschwelle, die mit vorgegebener Wahrscheinlichkeit nicht
|
||||
überschritten wird. Sagt nichts über die Verlusthöhe dahinter und ist nicht subadditiv.
|
||||
→ {ref:sec:cvar-value-at-risk-und-conditional-value}
|
||||
* **Vehicle Routing Problem (VRP)**{idx:Vehicle Routing Problem (VRP)} — Verallgemeinerung des TSP auf mehrere Fahrzeuge mit
|
||||
Depot, Kapazitäten und — in der Variante VRPTW — Zeitfenstern. → {ref:sec:graphen-das-vehicle-routing-problem-mit-zeitfenstern}
|
||||
* **Volatilität**{idx:Volatilität} — Standardabweichung der Renditen, üblicherweise auf ein Jahr skaliert.
|
||||
→ **{ref:sec:finanzdaten-diskrete-und-logarithmische-renditen}**
|
||||
|
||||
**W**
|
||||
|
||||
* **Walk-Forward-Backtest**{idx:Walk-Forward-Backtest} — Rollierende Auswertung, bei der Parameter stets nur auf
|
||||
Vergangenheitsdaten geschätzt und auf dem folgenden Zeitraum getestet werden.
|
||||
→ **{ref:sec:handelsmaschine-die-engine}**
|
||||
* **Wurzel-Zeit-Regel**{idx:Wurzel-Zeit-Regel} — Skalierung der Volatilität mit $\sqrt{T}$. Gilt streng nur für
|
||||
Standardabweichungen unabhängiger Größen ohne Drift — für VaR/CVaR nur als grobe Näherung.
|
||||
→ {ref:sec:cvar-implementierung-cvar-portfolio-mit-reibung}
|
||||
|
||||
**Z**
|
||||
|
||||
* **Zielfunktion**{idx:Zielfunktion} — Der zu minimierende oder maximierende Ausdruck, der die
|
||||
Entscheidungsvariablen zu einer einzigen Bewertungszahl verdichtet. → {ref:sec:einfuehrung-die-vier-universellen-bausteine-jedes-or}
|
||||
* **Zulässiger Bereich (Feasible Region)**{idx:Zulässiger Bereich} — Menge aller Punkte, die sämtliche
|
||||
Nebenbedingungen gleichzeitig erfüllen. → {ref:sec:fundament-der-zulaessige-loesungsraum-und-das-polyeder}
|
||||
|
||||
---
|
||||
|
||||
# Literaturverzeichnis
|
||||
|
||||
## Mathematische Optimierung — Grundlagen
|
||||
|
||||
* **Bertsimas, D. & Tsitsiklis, J. N. (1997):** *Introduction to Linear Optimization.*
|
||||
Athena Scientific, Belmont.
|
||||
*Standardwerk zur geometrischen und algebraischen Theorie des Simplex-Verfahrens,
|
||||
Dualität, Sensitivitätsanalyse und Netzwerkflüssen.*
|
||||
* **Dantzig, G. B. (1963):** *Linear Programming and Extensions.* Princeton University Press.
|
||||
*Das historische Originalwerk des Erfinders des Simplex-Algorithmus.*
|
||||
* **Wolsey, L. A. (2020):** *Integer Programming* (2. Aufl.). Wiley, Hoboken.
|
||||
*Branch-and-Bound, Branch-and-Cut, Polyedertheorie, Formulierungsstärke.*
|
||||
* **Hillier, F. S. & Lieberman, G. J. (2021):** *Introduction to Operations Research*
|
||||
(11. Aufl.). McGraw-Hill.
|
||||
*Breite Einführung inkl. Warteschlangen, DP und Entscheidungsmodellen.*
|
||||
|
||||
## Konvexe, nichtlineare und robuste Optimierung
|
||||
|
||||
* **Boyd, S. & Vandenberghe, L. (2004):** *Convex Optimization.* Cambridge University Press.
|
||||
*Referenzwerk für konvexe Mengen, KKT, QP und SDP; theoretische Basis von CVXPY.
|
||||
Frei verfügbar unter stanford.edu/~boyd/cvxbook/.*
|
||||
* **Ben-Tal, A., El Ghaoui, L. & Nemirovski, A. (2009):** *Robust Optimization.*
|
||||
Princeton University Press.
|
||||
* **Bertsimas, D. & Sim, M. (2004):** *The Price of Robustness.* In: *Operations Research*
|
||||
52(1), S. 35–53. *Quelle des Budgeted-Uncertainty-Ansatzes.*
|
||||
* **Bellman, R. (1957):** *Dynamic Programming.* Princeton University Press.
|
||||
|
||||
## Constraint Programming und Scheduling
|
||||
|
||||
* **Rossi, F., van Beek, P. & Walsh, T. (2006):** *Handbook of Constraint Programming.*
|
||||
Elsevier.
|
||||
* **Perron, L. & Furnon, V.:** *OR-Tools CP-SAT Solver Documentation.*
|
||||
[developers.google.com/optimization/cp](https://developers.google.com/optimization/cp)
|
||||
|
||||
## Quantitative Finanzmathematik
|
||||
|
||||
* **Markowitz, H. (1952):** *Portfolio Selection.* In: *The Journal of Finance* 7(1),
|
||||
S. 77–91.
|
||||
* **Rockafellar, R. T. & Uryasev, S. (2000):** *Optimization of Conditional Value-at-Risk.*
|
||||
In: *Journal of Risk* 2(3), S. 21–42.
|
||||
* **Ledoit, O. & Wolf, M. (2003):** *Improved estimation of the covariance matrix of stock
|
||||
returns with an application to portfolio selection.* In: *Journal of Empirical Finance*
|
||||
10(5), S. 603–621. *Quelle des Konstant-Korrelations-Ziels.*
|
||||
* **Ledoit, O. & Wolf, M. (2004):** *A well-conditioned estimator for large-dimensional
|
||||
covariance matrices.* In: *Journal of Multivariate Analysis* 88(2), S. 365–411.
|
||||
*Quelle des von `scikit-learn` verwendeten Ziels (skalierte Einheitsmatrix).*
|
||||
* **Almgren, R. & Chriss, N. (2000):** *Optimal Execution of Portfolio Transactions.*
|
||||
In: *Journal of Risk* 3(2), S. 5–39.
|
||||
* **DeMiguel, V., Garlappi, L. & Uppal, R. (2009):** *Optimal Versus Naive Diversification.*
|
||||
In: *Review of Financial Studies* 22(5), S. 1915–1953.
|
||||
*Die Studie, die 1/N als ernstzunehmenden Vergleichsmaßstab etablierte.*
|
||||
* **López de Prado, M. (2018):** *Advances in Financial Machine Learning.* Wiley.
|
||||
*Backtest-Overfitting, Deflated Sharpe Ratio, Denoising.*
|
||||
* **Cornuéjols, G., Peña, J. & Tütüncü, R. (2018):** *Optimization Methods in Finance*
|
||||
(2. Aufl.). Cambridge University Press.
|
||||
|
||||
## Software und Dokumentation
|
||||
|
||||
* **Google OR-Tools** — [developers.google.com/optimization](https://developers.google.com/optimization)
|
||||
· Quellcode: [github.com/google/or-tools](https://github.com/google/or-tools)
|
||||
* **Diamond, S. & Boyd, S. (2016):** *CVXPY: A Python-embedded modeling language for convex
|
||||
optimization.* In: *Journal of Machine Learning Research* 17(83), S. 1–5.
|
||||
· [www.cvxpy.org](https://www.cvxpy.org)
|
||||
* **Huangfu, Q. & Hall, J. A. J. (2018):** *Parallelizing the dual revised simplex method.*
|
||||
In: *Mathematical Programming Computation* 10(1), S. 119–142. · [highs.dev](https://highs.dev)
|
||||
* **SciPy** — [docs.scipy.org/doc/scipy/reference/optimize.html](https://docs.scipy.org/doc/scipy/reference/optimize.html)
|
||||
* **scikit-learn, Covariance Estimation** — [scikit-learn.org/stable/modules/covariance.html](https://scikit-learn.org/stable/modules/covariance.html)
|
||||
|
||||
## Verbände und Normen
|
||||
|
||||
* **INFORMS** — [www.informs.org](https://www.informs.org)
|
||||
* **GOR (Gesellschaft für Operations Research e. V.)** — deutschsprachiger Fachverband.
|
||||
* **Basel Committee on Banking Supervision:** *Minimum capital requirements for market risk*
|
||||
(Basel III, FRTB) — Grundlage der Umstellung von VaR auf Expected Shortfall.
|
||||
* **Europäische Union:** MiFID II / ESMA-Leitlinien zum algorithmischen Handel.
|
||||
|
||||
---
|
||||
|
||||
*Zurück zum* [Wegweiser](README.md)
|
||||
137
Operations_Research_mit_Python_Version_04/README.md
Normal file
137
Operations_Research_mit_Python_Version_04/README.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Optimierte Entscheidungsfindung mit Python
|
||||
|
||||
**Wegweiser durch diese Ausgabe.** Dieses Kompendium verfolgt einen klaren Anspruch:
|
||||
**Kursteilnehmerinnen und Kursteilnehmer sollen den Text nicht nur lesen, sondern damit
|
||||
arbeiten, üben und eigene Anwendungen bauen können.**
|
||||
|
||||
---
|
||||
|
||||
## Was dieses Kompendium bietet
|
||||
|
||||
| Merkmal | Umfang | Warum |
|
||||
| --- | --- | --- |
|
||||
| **Formel-Lesehilfen** | bei jeder nicht-trivialen Formel | Nicht jede/r liest Formelsprache flüssig |
|
||||
| **Handrechnungen** | Simplex, Big-M, Dualität, Bellman, CVaR | Verstehen kommt vor Programmieren |
|
||||
| **Zeile-für-Zeile-Code-Erklärungen** | alle Kernprogramme | Code lesen lernen, nicht nur ausführen |
|
||||
| **Erwartete Programmausgaben** | alle Programme | Selbstkontrolle: „Läuft es bei mir richtig?“ |
|
||||
| **~130 Übungsaufgaben mit Lösungen** | jedes Kapitel | Aktives Üben statt passives Lesen |
|
||||
| **Selbsttests** | jedes Kapitel | Schnelle Wissenskontrolle |
|
||||
| **Codebeispiele durchgängig lauffähig** | u. a. Rucksack, Zuordnung, Job-Shop, Monte-Carlo, robuste Optimierung, Min-Cost-Flow | Theorie und Code gehören zusammen |
|
||||
| **Notations- und Abkürzungsverzeichnis** | [`01_Notation_und_Abkuerzungen.md`](01_Notation_und_Abkuerzungen.md) | Jede Abkürzung genau einmal sauber eingeführt |
|
||||
| **Projektwerkstatt** | [`59_Projektwerkstatt.md`](59_Projektwerkstatt.md) | 8 vollständige Projektaufträge für eigene Anwendungen |
|
||||
| **Modellierungsmuster-Katalog** | [`91_Anhang_Modellierungsmuster.md`](91_Anhang_Modellierungsmuster.md) | Nachschlagewerk: „Wie modelliere ich X?“ |
|
||||
| **Fehlerdiagnose-Handbuch** | [`92_Anhang_Fehlerdiagnose.md`](92_Anhang_Fehlerdiagnose.md) | Was tun, wenn der Solver `INFEASIBLE` meldet? |
|
||||
|
||||
---
|
||||
|
||||
## Aufbau der Dateien
|
||||
|
||||
Der Text ist auf mehrere Dateien verteilt, damit er handhabbar bleibt. Die Nummern-Präfixe
|
||||
legen die Lesereihenfolge fest.
|
||||
|
||||
### Vorspann
|
||||
|
||||
| Datei | Inhalt |
|
||||
| --- | --- |
|
||||
| [`00_Vorwort_und_Lesehilfe.md`](00_Vorwort_und_Lesehilfe.md) | Vorwort, Lernpfade, Verzeichnis der Beispielprogramme, Setup, wie die Lernelemente funktionieren |
|
||||
| [`01_Notation_und_Abkuerzungen.md`](01_Notation_und_Abkuerzungen.md) | Alle Symbole und Abkürzungen, ausgeschrieben und erklärt |
|
||||
|
||||
### Teil I — Grundlagen
|
||||
|
||||
| Datei | Kapitel |
|
||||
| --- | --- |
|
||||
| [`10_Einfuehrung_OR.md`](10_Einfuehrung_OR.md) | 1 — Einführung in Operations Research |
|
||||
| [`11_Mathematisches_Fundament.md`](11_Mathematisches_Fundament.md) | 2 — Vektoren, Matrizen, Konvexität |
|
||||
| [`12_Python_Oekosystem.md`](12_Python_Oekosystem.md) | 3 — Das Python-Ökosystem für OR |
|
||||
|
||||
### Teil II — Deterministische Kernverfahren
|
||||
|
||||
| Datei | Kapitel |
|
||||
| --- | --- |
|
||||
| [`20_Lineare_Programmierung.md`](20_Lineare_Programmierung.md) | 4 — Lineare Programmierung, Simplex, Dualität |
|
||||
| [`21_MILP.md`](21_MILP.md) | 5 — Gemischt-ganzzahlige Optimierung |
|
||||
| [`22_CP_SAT.md`](22_CP_SAT.md) | 6 — Constraint Programming mit CP-SAT |
|
||||
| [`23_Graphen_Fluesse_Touren.md`](23_Graphen_Fluesse_Touren.md) | 7 — Graphen, Flüsse, Tourenplanung |
|
||||
|
||||
### Teil III — Nichtlinearität, Unsicherheit, Dynamik
|
||||
|
||||
| Datei | Kapitel |
|
||||
| --- | --- |
|
||||
| [`30_QP_und_NLP.md`](30_QP_und_NLP.md) | 8 — Quadratische und nichtlineare Optimierung, KKT |
|
||||
| [`31_Unsicherheit.md`](31_Unsicherheit.md) | 9 — Monte-Carlo, stochastische und robuste Optimierung |
|
||||
| [`32_Dynamische_Programmierung.md`](32_Dynamische_Programmierung.md) | 10 — Dynamische Programmierung, Bellman |
|
||||
|
||||
### Teil IV — Operations Research an den Finanzmärkten
|
||||
|
||||
| Datei | Kapitel |
|
||||
| --- | --- |
|
||||
| [`40_Finanzdaten.md`](40_Finanzdaten.md) | 11 — Finanzdaten, Kovarianz, Shrinkage |
|
||||
| [`41_Markowitz.md`](41_Markowitz.md) | 12 — Markowitz-Portfoliotheorie |
|
||||
| [`42_CVaR.md`](42_CVaR.md) | 13 — Tail-Risiko, CVaR, Transaktionskosten |
|
||||
| [`43_Handelsmaschine.md`](43_Handelsmaschine.md) | 14 — Die vollständige Handelsmaschine |
|
||||
|
||||
### Teil V — Praxis
|
||||
|
||||
| Datei | Kapitel |
|
||||
| --- | --- |
|
||||
| [`50_Praxis.md`](50_Praxis.md) | 15 — Praxisfallen und produktiver Einsatz |
|
||||
| [`59_Projektwerkstatt.md`](59_Projektwerkstatt.md) | Projektwerkstatt: 8 eigene Anwendungen |
|
||||
|
||||
### Anhang
|
||||
|
||||
| Datei | Inhalt |
|
||||
| --- | --- |
|
||||
| [`90_Anhang_Loesungen.md`](90_Anhang_Loesungen.md) | Lösungen zu **allen** Übungsaufgaben und Selbsttests |
|
||||
| [`91_Anhang_Modellierungsmuster.md`](91_Anhang_Modellierungsmuster.md) | Musterkatalog: 24 wiederkehrende Modellierungstricks |
|
||||
| [`92_Anhang_Fehlerdiagnose.md`](92_Anhang_Fehlerdiagnose.md) | Diagnose-Handbuch für Solver-Probleme |
|
||||
| [`94_Anhang_Glossar_und_Literatur.md`](94_Anhang_Glossar_und_Literatur.md) | Glossar, Literatur |
|
||||
|
||||
---
|
||||
|
||||
## Eine Einzeldatei erzeugen
|
||||
|
||||
Wer lieber ein einziges Dokument möchte (z. B. zum Drucken oder für die PDF-Erzeugung),
|
||||
baut es aus den Einzeldateien:
|
||||
|
||||
```bash
|
||||
cd OR_mit_Python/Version_04 # Wurzel der Version 04
|
||||
python3 Operations_Research_mit_Python_Version_04/build_version_04.py
|
||||
```
|
||||
|
||||
Das Skript erzeugt `Operations_Research_mit_Python_Version_04.md` im übergeordneten
|
||||
Verzeichnis — in derselben Reihenfolge wie oben, mit Titelblock, automatisch gesetztem
|
||||
Inhaltsverzeichnis-Platzhalter und entfernten unnötigen Zeilenumbrüchen innerhalb von
|
||||
Absätzen. Optionen:
|
||||
|
||||
```bash
|
||||
python3 .../build_version_03.py --pdf # zusätzlich PDF über pandoc + xelatex
|
||||
python3 .../build_version_03.py --html # zusätzlich Single-Page-HTML
|
||||
python3 .../build_version_03.py --check # nur Konsistenzprüfung, schreibt nichts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Empfohlene Reihenfolge für Kursteilnehmer
|
||||
|
||||
**Vollständiger Lehrgang (empfohlen, ca. 100–140 Stunden):**
|
||||
Vorwort → Notation → alle Kapitel der Reihe nach → Projektwerkstatt.
|
||||
Jedes Kapitel endet mit Übungsaufgaben — bearbeiten Sie diese, **bevor** Sie weiterlesen.
|
||||
|
||||
**Schnellpfad Planung & Disposition (ca. 25 Stunden):**
|
||||
Einführung, Fundament, Vom Wunsch zum Modell, LP, MILP, CP-SAT → Projekt P1 oder P2.
|
||||
|
||||
**Schnellpfad Logistik (ca. 25 Stunden):**
|
||||
Einführung, Fundament, LP, MILP, Graphen → Projekt P3.
|
||||
|
||||
**Schnellpfad Quantitative Finance (ca. 30 Stunden):**
|
||||
Einführung, Fundament, QP/NLP, Finanzdaten, Markowitz, CVaR, Handelsmaschine
|
||||
→ Projekt P6 oder P7.
|
||||
|
||||
*(Ohne Kapitelnummern — die verschieben sich beim Einfügen neuer Kapitel. Die
|
||||
maßgebliche, immer aktuelle Fassung der Lernpfade steht im Vorwort.)*
|
||||
|
||||
Details zu den Lernpfaden stehen in [`00_Vorwort_und_Lesehilfe.md`](00_Vorwort_und_Lesehilfe.md).
|
||||
|
||||
---
|
||||
|
||||
*Autor / Herausgeber: Dieter Schlüter · Stand: 8. September 2026*
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# Beispielprogramme zu „Optimierte Entscheidungsfindung mit Python" (Version 04)
|
||||
|
||||
Diese {anzahl} Dateien sind automatisch aus den Kapiteldateien in
|
||||
`../Operations_Research_mit_Python_Version_04/` extrahiert — jede Datei entspricht
|
||||
genau einem im Buch vollständig abgedruckten Programm. Sie sind hier zusätzlich als
|
||||
eigenständige, direkt ausführbare `.py`-Dateien verfügbar, damit man sie ohne
|
||||
Copy-Paste aus dem Buch starten kann.
|
||||
|
||||
**Diese Dateien sind ein generiertes Artefakt, keine Quelle.** Ändern Sie stattdessen
|
||||
den Codeblock im jeweiligen Kapitel und lassen Sie das Extraktionsskript erneut laufen:
|
||||
|
||||
```bash
|
||||
cd OR_mit_Python/Version_04 # Wurzel der Version 04
|
||||
python3 Operations_Research_mit_Python_Version_04/extract_programme_04.py
|
||||
```
|
||||
|
||||
Das Verzeichnis „Verzeichnis der Beispielprogramme" im Vorwort des Buchs
|
||||
(`00_Vorwort_und_Lesehilfe.md`) listet dieselben {anzahl} Programme mit Kurzbeschreibung.
|
||||
|
||||
Auch dieses README ist selbst generiert: `extract_programme.py` erzeugt es bei jedem
|
||||
Lauf aus der Vorlage `README_Programme.md` im Quellverzeichnis und setzt die
|
||||
Programmzahl automatisch ein — Textänderungen gehören daher in die Vorlage, nicht in
|
||||
diese Datei.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Hinweise zum Ausführen
|
||||
|
||||
- **Die Finanzkapitel** (Finanzdaten, Markowitz, CVaR, Handelsmaschine) laden
|
||||
Kursdaten live über `yfinance` — es wird eine
|
||||
Internetverbindung benötigt. Ergebnisse und abgedruckte Zahlen weichen je nach
|
||||
Abrufzeitpunkt vom Buchtext ab; das ist beabsichtigt (relative Zeitfenster statt
|
||||
fester Daten).
|
||||
- **Programme mit Diagrammausgabe** schreiben PNGs in
|
||||
einen Unterordner `output/` neben dem jeweiligen Skript. Für Headless-Läufe ohne
|
||||
Anzeige `MPLBACKEND=Agg` setzen:
|
||||
|
||||
```bash
|
||||
MPLBACKEND=Agg python3 Markowitz_CVXPY.py
|
||||
```
|
||||
|
||||
- `Ein_System_Vier_Ansaetze.py` startet `ortools` und `highspy` jeweils in
|
||||
einem eigenen Unterprozess, weil beide Pakete auf vielen Systemen dieselbe
|
||||
HiGHS-Bibliothek mitbringen und sich nicht im selben Prozess importieren lassen.
|
||||
2075
Operations_Research_mit_Python_Version_04/build_version_04.py
Normal file
2075
Operations_Research_mit_Python_Version_04/build_version_04.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,172 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# extract_programme_04.py
|
||||
"""
|
||||
Extrahiert alle vollstaendigen Beispielprogramme aus den Kapiteldateien dieses
|
||||
Verzeichnisses und schreibt sie unter ihrem eigenen Dateinamen nach
|
||||
|
||||
../Operations_Research_mit_Python_Version_04_Programme/
|
||||
|
||||
Ein Codeblock gilt als vollstaendiges Programm, wenn er direkt mit
|
||||
|
||||
#!/usr/bin/env python3
|
||||
<Leerzeile>
|
||||
# Name.py
|
||||
|
||||
beginnt (die im Buch durchgaengig verwendete Konvention). Kurze, illustrative
|
||||
Codeschnipsel (Formel-Lesehilfen, Anhang B/C, Projektwerkstatt) folgen diesem
|
||||
Muster nicht und werden deshalb korrekt ignoriert.
|
||||
|
||||
Der Dateiname traegt bewusst KEINE Kapitelnummer (z.B. "Sensitivitaetsanalyse.py",
|
||||
nicht "Kap_04_Sensitivitaetsanalyse.py") - die Kapitelzuordnung wird dynamisch
|
||||
aus der Quelldatei berechnet, damit beim Umsortieren von Kapiteln nichts
|
||||
umbenannt werden muss.
|
||||
|
||||
Das Skript ist bewusst wiederholbar: Nach jeder inhaltlichen Aenderung an einem
|
||||
Kapitel-Codeblock einfach erneut ausfuehren, um das Programme-Verzeichnis auf
|
||||
den aktuellen Stand zu bringen.
|
||||
|
||||
Das README.md des Zielverzeichnisses wird ebenfalls bei jedem Lauf erzeugt -
|
||||
aus der Vorlage README_Programme.md in diesem Verzeichnis. Der Platzhalter
|
||||
{anzahl} wird dabei durch die tatsaechliche Programmzahl ersetzt, damit die
|
||||
Angabe im README nie veraltet.
|
||||
|
||||
Aufruf:
|
||||
python3 Operations_Research_mit_Python_Version_04/extract_programme_04.py
|
||||
python3 .../extract_programme.py --check # nur pruefen, nichts schreiben
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
HIER = os.path.dirname(os.path.abspath(__file__))
|
||||
BASIS = os.path.dirname(HIER)
|
||||
ZIEL_DIR = os.path.join(BASIS, "Operations_Research_mit_Python_Version_04_Programme")
|
||||
|
||||
# Das README im Zielverzeichnis wird bei jedem Lauf aus dieser Vorlage erzeugt;
|
||||
# der Platzhalter {anzahl} steht dort fuer die tatsaechliche Programmzahl.
|
||||
VORLAGE = os.path.join(HIER, "README_Programme.md")
|
||||
ZIEL_README = os.path.join(ZIEL_DIR, "README.md")
|
||||
|
||||
FENCE_RE = re.compile(r"^```python\s*$")
|
||||
FENCE_END_RE = re.compile(r"^```\s*$")
|
||||
HEADER_RE = re.compile(r"\A#!/usr/bin/env python3\n\n# ([^\n]+\.py)\n")
|
||||
|
||||
|
||||
def finde_programme():
|
||||
"""Liefert {dateiname: (quelldatei, code)} fuer alle gefundenen Vollprogramme."""
|
||||
programme = {}
|
||||
for name in sorted(os.listdir(HIER)):
|
||||
if not name.endswith(".md"):
|
||||
continue
|
||||
pfad = os.path.join(HIER, name)
|
||||
with open(pfad, encoding="utf-8") as f:
|
||||
zeilen = f.read().split("\n")
|
||||
|
||||
i = 0
|
||||
n = len(zeilen)
|
||||
while i < n:
|
||||
if FENCE_RE.match(zeilen[i]):
|
||||
start = i + 1
|
||||
j = start
|
||||
while j < n and not FENCE_END_RE.match(zeilen[j]):
|
||||
j += 1
|
||||
code = "\n".join(zeilen[start:j]) + "\n"
|
||||
m = HEADER_RE.match(code)
|
||||
if m:
|
||||
dateiname = m.group(1)
|
||||
if dateiname in programme and programme[dateiname][1] != code:
|
||||
print(f"WARNUNG: {dateiname} kommt mehrfach mit "
|
||||
f"unterschiedlichem Inhalt vor "
|
||||
f"({programme[dateiname][0]} und {name}).")
|
||||
programme[dateiname] = (name, code)
|
||||
i = j + 1
|
||||
else:
|
||||
i += 1
|
||||
return programme
|
||||
|
||||
|
||||
def rendere_readme(anzahl: int) -> str | None:
|
||||
"""Liest die README-Vorlage und setzt {anzahl} ein.
|
||||
|
||||
Liefert None, wenn die Vorlage fehlt - dann bleibt ein vorhandenes README
|
||||
im Zielverzeichnis unangetastet.
|
||||
"""
|
||||
if not os.path.exists(VORLAGE):
|
||||
return None
|
||||
with open(VORLAGE, encoding="utf-8") as f:
|
||||
return f.read().replace("{anzahl}", str(anzahl))
|
||||
|
||||
|
||||
def pruefe_readme(anzahl: int) -> None:
|
||||
"""Meldet fuer --check, ob das README im Zielverzeichnis zur Vorlage passt."""
|
||||
readme = rendere_readme(anzahl)
|
||||
if readme is None:
|
||||
print(f"WARNUNG: Vorlage {VORLAGE} fehlt - README wird nicht erzeugt.")
|
||||
return
|
||||
if not os.path.exists(ZIEL_README):
|
||||
print(f"README im Zielverzeichnis fehlt: {ZIEL_README}")
|
||||
return
|
||||
with open(ZIEL_README, encoding="utf-8") as f:
|
||||
steht = f.read()
|
||||
if steht == readme:
|
||||
print("README im Zielverzeichnis ist aktuell.")
|
||||
else:
|
||||
print("README im Zielverzeichnis weicht von der Vorlage ab "
|
||||
"(wird beim naechsten Extraktionslauf neu erzeugt).")
|
||||
|
||||
|
||||
def schreibe_readme(anzahl: int) -> None:
|
||||
"""Erzeugt das README im Zielverzeichnis aus der Vorlage."""
|
||||
readme = rendere_readme(anzahl)
|
||||
if readme is None:
|
||||
print(f"WARNUNG: Vorlage {VORLAGE} fehlt - README nicht aktualisiert.")
|
||||
return
|
||||
with open(ZIEL_README, "w", encoding="utf-8") as f:
|
||||
f.write(readme)
|
||||
print(f"README erzeugt: {ZIEL_README}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Extrahiert Beispielprogramme aus den Kapiteldateien.")
|
||||
parser.add_argument("--check", action="store_true",
|
||||
help="nur pruefen und auflisten (inkl. README-Abgleich), "
|
||||
"nichts schreiben")
|
||||
args = parser.parse_args()
|
||||
|
||||
programme = finde_programme()
|
||||
print(f"Gefunden: {len(programme)} vollstaendige Programme in {HIER}.")
|
||||
|
||||
if args.check:
|
||||
for dateiname, (quelle, _) in sorted(programme.items()):
|
||||
print(f" {dateiname} <- {quelle}")
|
||||
pruefe_readme(len(programme))
|
||||
return 0
|
||||
|
||||
os.makedirs(ZIEL_DIR, exist_ok=True)
|
||||
vorher = {f for f in os.listdir(ZIEL_DIR) if f.endswith(".py")}
|
||||
geschrieben = set()
|
||||
|
||||
for dateiname, (quelle, code) in sorted(programme.items()):
|
||||
ziel = os.path.join(ZIEL_DIR, dateiname)
|
||||
with open(ziel, "w", encoding="utf-8") as f:
|
||||
f.write(code)
|
||||
geschrieben.add(dateiname)
|
||||
|
||||
verwaist = vorher - geschrieben
|
||||
if verwaist:
|
||||
print("ACHTUNG - verwaiste Dateien im Zielverzeichnis (kein passendes "
|
||||
"Programm mehr in den Kapiteln, wurden NICHT geloescht):")
|
||||
for f in sorted(verwaist):
|
||||
print(f" {f}")
|
||||
|
||||
print(f"Geschrieben: {len(geschrieben)} Dateien nach {ZIEL_DIR}")
|
||||
schreibe_readme(len(programme))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Reference in a new issue