Version 04 als eigenes Repository

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

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

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

Zusaetzlich in diesem Commit:

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

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

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

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

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

View file

@ -0,0 +1,280 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Anhang C: Fehlerdiagnose-Handbuch\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Wenn die fünf Schritte nicht reichen: den Konflikt einkreisen\n",
"\n",
"`Konfliktsuche.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Konfliktsuche.py\n",
"\"\"\"\n",
"Anhang Fehlerdiagnose: Den Konflikt finden, der INFEASIBLE verursacht.\n",
"\n",
"Ein Deletion Filter (auch: IIS, Irreducible Infeasible Subset) isoliert aus\n",
"einem unloesbaren Modell eine kleinste widerspruechliche Teilmenge von\n",
"Bedingungen: Nimmt man aus ihr auch nur eine einzige Bedingung heraus, ist\n",
"der Rest wieder loesbar.\n",
"\n",
"Das Programm zeigt sechs Dinge:\n",
" 1. Der Solver meldet INFEASIBLE - und sonst nichts.\n",
" 2. Die naive Suche (\"jede Bedingung einmal weglassen\") findet hier gar nichts.\n",
" 3. Der Deletion Filter findet einen Konflikt in n Solveraufrufen.\n",
" 4. Ein Konflikt ist selten einer: nach der Reparatur folgt der naechste.\n",
" 5. \"Den\" kleinsten Konflikt gibt es nicht - dieses Modell enthaelt neun.\n",
" 6. Welchen davon man zu sehen bekommt, steuert die Pruefreihenfolge.\n",
"\n",
"Abgrenzung zu Infeasibility_Diagnose.py (Kapitel Praxisfallen): Dort geht es\n",
"um die Relaxation - das Modell soll trotz Widerspruch eine brauchbare Antwort\n",
"liefern. Hier geht es um die Diagnose - welche Bedingungen widersprechen sich\n",
"ueberhaupt. Beides zusammen ergibt den Umgang mit INFEASIBLE in Produktion.\n",
"\n",
"Benoetigt: numpy, scipy\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import numpy as np\n",
"from scipy.optimize import linprog\n",
"\n",
"PRODUKTE = [\"Rahmen\", \"Gehaeuse\", \"Deckel\", \"Traeger\", \"Halter\"]\n",
"\n",
"# Jede Bedingung traegt einen sprechenden Namen - das ist keine Kosmetik,\n",
"# sondern die Voraussetzung dafuer, dass der Befund lesbar wird.\n",
"# (Name, Koeffizienten je Produkt, Richtung, rechte Seite)\n",
"BEDINGUNGEN: list[tuple[str, list[float], str, float]] = [\n",
" (\"Kapazitaet Montage\", [3, 2, 1, 4, 2], \"<=\", 400),\n",
" (\"Kapazitaet Lackieren\", [2, 3, 0, 1, 0], \"<=\", 150),\n",
" (\"Kapazitaet Pruefung\", [1, 1, 1, 1, 1], \"<=\", 300),\n",
" (\"Liefervertrag Rahmen\", [1, 0, 0, 0, 0], \">=\", 40),\n",
" (\"Liefervertrag Gehaeuse\", [0, 1, 0, 0, 0], \">=\", 30),\n",
" (\"Liefervertrag Deckel\", [0, 0, 1, 0, 0], \">=\", 20),\n",
" (\"Liefervertrag Traeger\", [0, 0, 0, 1, 0], \">=\", 25),\n",
" (\"Marktgrenze Rahmen\", [1, 0, 0, 0, 0], \"<=\", 120),\n",
" (\"Marktgrenze Gehaeuse\", [0, 1, 0, 0, 0], \"<=\", 90),\n",
" (\"Marktgrenze Deckel\", [0, 0, 1, 0, 0], \"<=\", 150),\n",
" (\"Marktgrenze Halter\", [0, 0, 0, 0, 1], \"<=\", 200),\n",
" (\"Mindestumsatz\", [90, 70, 30, 60, 20], \">=\", 12000),\n",
" (\"Sortimentsbreite\", [0, 0, 1, 1, 1], \">=\", 100),\n",
" (\"Lackierbudget Schicht 2\", [0, 1, 0, 1, 0], \"<=\", 55),\n",
"]\n",
"\n",
"# Welche Bedingungen koennte ein Planer im Ernstfall wirklich veraendern?\n",
"# Kapazitaeten lassen sich durch Sonderschichten dehnen, Liefervertraege und\n",
"# Marktgrenzen nicht.\n",
"VERHANDELBAR = {\"Kapazitaet Montage\", \"Kapazitaet Lackieren\",\n",
" \"Kapazitaet Pruefung\", \"Lackierbudget Schicht 2\"}\n",
"\n",
"aufrufe = 0 # zaehlt jeden Solveraufruf mit - der Preis des Verfahrens\n",
"\n",
"\n",
"def ist_loesbar(auswahl: list[int]) -> bool:\n",
" \"\"\"Gibt es einen Punkt, der ALLE Bedingungen aus 'auswahl' erfuellt?\n",
"\n",
" Es wird nur Zulaessigkeit geprueft, keine Zielfunktion optimiert: die\n",
" Zielfunktion ist konstant null. INFEASIBLE haengt nie an der Zielfunktion.\n",
" \"\"\"\n",
" global aufrufe\n",
" aufrufe += 1\n",
" matrix, rechte_seite = [], []\n",
" for i in auswahl:\n",
" _, koeffizienten, richtung, grenze = BEDINGUNGEN[i]\n",
" zeile = np.array(koeffizienten, dtype=float)\n",
" # linprog kennt nur \"<=\", also \">=\" durch Negation umdrehen\n",
" matrix.append(zeile if richtung == \"<=\" else -zeile)\n",
" rechte_seite.append(grenze if richtung == \"<=\" else -grenze)\n",
" ergebnis = linprog(np.zeros(len(PRODUKTE)),\n",
" A_ub=np.array(matrix), b_ub=np.array(rechte_seite),\n",
" bounds=(0, None), method=\"highs\")\n",
" return ergebnis.status == 0\n",
"\n",
"\n",
"def deletion_filter(auswahl: list[int]) -> list[int]:\n",
" \"\"\"Verkleinert eine unloesbare Menge zu einer kleinsten unloesbaren Menge.\n",
"\n",
" Der Kern des Verfahrens ist eine einzige Regel: Nimm eine Bedingung\n",
" versuchsweise heraus. Bleibt der Rest unloesbar, wurde sie fuer den\n",
" Widerspruch nicht gebraucht - sie darf endgueltig weg. Wird der Rest\n",
" loesbar, war sie beteiligt und muss bleiben.\n",
"\n",
" Nach genau einem Durchlauf ueber alle Bedingungen ist das Ergebnis\n",
" unreduzierbar: n Solveraufrufe statt 2^n Teilmengen.\n",
" \"\"\"\n",
" rest = list(auswahl)\n",
" for i in list(auswahl):\n",
" probe = [j for j in rest if j != i]\n",
" if not ist_loesbar(probe):\n",
" rest = probe\n",
" return rest\n",
"\n",
"\n",
"def namen(auswahl: list[int]) -> list[str]:\n",
" return [BEDINGUNGEN[i][0] for i in auswahl]\n",
"\n",
"\n",
"def teil_1_der_befund(alle: list[int]) -> None:\n",
" print(\"=\" * 68)\n",
" print(\"1. Was der Solver sagt\")\n",
" print(\"=\" * 68)\n",
" print(f\"Modell: {len(PRODUKTE)} Variablen, {len(BEDINGUNGEN)} Bedingungen\")\n",
" status = \"OPTIMAL\" if ist_loesbar(alle) else \"INFEASIBLE\"\n",
" print(f\"Status: {status}\")\n",
" print(\"\\nMehr ist es nicht. Der Solver nennt keine Ursache, weil es die eine\")\n",
" print(\"Ursache nicht gibt: Unloesbarkeit ist eine Eigenschaft von Mengen von\")\n",
" print(\"Bedingungen, nicht von einzelnen Bedingungen.\")\n",
"\n",
"\n",
"def teil_2_die_naive_suche(alle: list[int]) -> None:\n",
" print(\"\\n\" + \"=\" * 68)\n",
" print(\"2. Die naheliegende Idee - und warum sie hier scheitert\")\n",
" print(\"=\" * 68)\n",
" print(\"Jede Bedingung einmal weglassen und schauen, ob es dann geht:\\n\")\n",
" treffer = [i for i in alle if ist_loesbar([j for j in alle if j != i])]\n",
" for i in alle:\n",
" print(f\" ohne {BEDINGUNGEN[i][0]:<26} {'loesbar' if i in treffer else 'weiter unloesbar'}\")\n",
" print(f\"\\nGefundene Schuldige: {len(treffer)}\")\n",
" print(\"Keine einzelne Bedingung ist schuld. Genau das ist der Normalfall -\")\n",
" print(\"und der Grund, warum diese Suche in der Praxis so oft im Nichts endet.\")\n",
"\n",
"\n",
"def teil_3_der_filter(alle: list[int]) -> list[int]:\n",
" global aufrufe\n",
" print(\"\\n\" + \"=\" * 68)\n",
" print(\"3. Der Deletion Filter\")\n",
" print(\"=\" * 68)\n",
" vorher = aufrufe\n",
" konflikt = deletion_filter(alle)\n",
" kosten = aufrufe - vorher\n",
" print(f\"{kosten} Solveraufrufe -> Konflikt aus {len(konflikt)} von \"\n",
" f\"{len(BEDINGUNGEN)} Bedingungen:\\n\")\n",
" for name in namen(konflikt):\n",
" print(f\" * {name}\")\n",
" print(\"\\nProbe auf Unreduzierbarkeit - jede einzelne davon weglassen:\")\n",
" for i in konflikt:\n",
" rest_loesbar = ist_loesbar([j for j in konflikt if j != i])\n",
" print(f\" ohne {BEDINGUNGEN[i][0]:<26} {'loesbar' if rest_loesbar else 'UNLOESBAR - nicht minimal!'}\")\n",
" print(f\"\\nAufwand: {kosten} Aufrufe. Alle Teilmengen durchzuprobieren waeren \"\n",
" f\"2^{len(BEDINGUNGEN)} = {2 ** len(BEDINGUNGEN):,} gewesen.\".replace(\",\", \".\"))\n",
" return konflikt\n",
"\n",
"\n",
"def teil_4_der_naechste_konflikt(alle: list[int], konflikt: list[int]) -> None:\n",
" print(\"\\n\" + \"=\" * 68)\n",
" print(\"4. Ein Konflikt ist selten einer\")\n",
" print(\"=\" * 68)\n",
" entfernt = konflikt[0]\n",
" rest = [i for i in alle if i != entfernt]\n",
" print(f\"Angenommen, '{BEDINGUNGEN[entfernt][0]}' laesst sich verhandeln\")\n",
" print(\"und wird aus dem Modell genommen. Dann ist das Modell ...\")\n",
" if ist_loesbar(rest):\n",
" print(\"... loesbar. Fertig.\")\n",
" return\n",
" print(\"... immer noch unloesbar. Der Filter erneut:\\n\")\n",
" zweiter = deletion_filter(rest)\n",
" for name in namen(zweiter):\n",
" print(f\" * {name}\")\n",
" gemeinsam = set(zweiter) & set(konflikt)\n",
" print(f\"\\nUeberschneidung mit dem ersten Konflikt: {len(gemeinsam)} Bedingungen\")\n",
" print(\"Ein zweiter, unabhaengiger Widerspruch, den der erste verdeckt hat.\")\n",
" print(\"Deshalb ist die Konfliktsuche eine Schleife, kein einzelner Aufruf:\")\n",
" print(\"reparieren, neu suchen, bis das Modell loesbar ist.\")\n",
"\n",
"\n",
"def teil_5_die_reihenfolge(alle: list[int]) -> None:\n",
" print(\"\\n\" + \"=\" * 68)\n",
" print(\"5. Es gibt nicht DEN Konflikt\")\n",
" print(\"=\" * 68)\n",
" print(\"Derselbe Filter, nur eine andere Pruefreihenfolge - 200-mal gewuerfelt:\\n\")\n",
" zufall = np.random.default_rng(0)\n",
" haeufigkeit: dict[tuple[int, ...], int] = {}\n",
" for _ in range(200):\n",
" ordnung = [int(i) for i in zufall.permutation(alle)]\n",
" schluessel = tuple(sorted(deletion_filter(ordnung)))\n",
" haeufigkeit[schluessel] = haeufigkeit.get(schluessel, 0) + 1\n",
" for schluessel, anzahl in sorted(haeufigkeit.items(), key=lambda p: -p[1]):\n",
" print(f\" {anzahl:3d}x {len(schluessel)} Bedingungen: \"\n",
" f\"{', '.join(namen(list(schluessel)))}\")\n",
" print(f\"\\n{len(haeufigkeit)} verschiedene minimale Konflikte in EINEM Modell.\")\n",
" print(\"Der Filter liefert *einen* kleinsten Konflikt, nicht *den* kleinsten -\")\n",
" print(\"den gibt es nicht. Alle oben sind gleichermassen korrekt.\")\n",
"\n",
"\n",
"def teil_6_den_befund_steuern(alle: list[int]) -> None:\n",
" print(\"\\n\" + \"=\" * 68)\n",
" print(\"6. Den Befund brauchbar machen\")\n",
" print(\"=\" * 68)\n",
" print(\"Der Filter wirft heraus, was er zuerst in die Hand bekommt. Wer die\")\n",
" print(\"unveraenderlichen Bedingungen zuerst pruefen laesst, bekommt sie eher\")\n",
" print(\"aus dem Befund heraus - und sieht dafuer mehr Stellschrauben.\\n\")\n",
" fest = [i for i in alle if BEDINGUNGEN[i][0] not in VERHANDELBAR]\n",
" frei = [i for i in alle if BEDINGUNGEN[i][0] in VERHANDELBAR]\n",
" for titel, ordnung in ((\"unveraenderliche zuerst\", fest + frei),\n",
" (\"veraenderliche zuerst \", frei + fest)):\n",
" konflikt = sorted(deletion_filter(ordnung))\n",
" stellschrauben = [i for i in konflikt if BEDINGUNGEN[i][0] in VERHANDELBAR]\n",
" print(f\" {titel} -> {len(konflikt)} Bedingungen, davon \"\n",
" f\"{len(stellschrauben)} veraenderlich\")\n",
" print(f\" {', '.join(namen(konflikt))}\")\n",
" print(\"\\nBeide Befunde sind wahr. Nur einer davon nennt dem Planer etwas,\")\n",
" print(\"das er tatsaechlich tun kann.\")\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" alle = list(range(len(BEDINGUNGEN)))\n",
" teil_1_der_befund(alle)\n",
" teil_2_die_naive_suche(alle)\n",
" konflikt = teil_3_der_filter(alle)\n",
" teil_4_der_naechste_konflikt(alle, konflikt)\n",
" teil_5_die_reihenfolge(alle)\n",
" teil_6_den_befund_steuern(alle)\n",
" print(\"\\n\" + \"=\" * 68)\n",
" print(f\"Insgesamt {aufrufe} Solveraufrufe fuer die gesamte Diagnose.\")\n",
" print(\"=\" * 68)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -0,0 +1,330 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 16: Die Strukturbrücke — dieselbe Mathematik, zwei Welten\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Das Programm\n",
"\n",
"`Strukturbruecke.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Strukturbruecke.py\n",
"\"\"\"\n",
"Kapitel Bruecke: Derselbe Code, zwei Welten - der Beweis statt der Behauptung.\n",
"\n",
"Der Teil-Auftakt stellt eine Tabelle auf: \"Ressourcen auf Produkte verteilen\"\n",
"entspreche \"Kapital auf Anlagen verteilen\", \"gegen den Worst Case absichern\"\n",
"entspreche \"Absicherung gegen Kursabstuerze\". Solche Tabellen stehen in vielen\n",
"Buechern. Sie sind billig - und man kann sie pruefen.\n",
"\n",
"Dieses Programm prueft sie. Es fuettert zweimal DENSELBEN Code mit Daten aus\n",
"zwei Welten und zeigt die Ergebnisse nebeneinander:\n",
"\n",
" 1. Die Allokation als LP. Das Domaenenmodell aus or_kern.py, einmal mit einer\n",
" Schreinerei (Montagestunden, Plattenmaterial) und einmal mit einem Depot\n",
" (Kapital, Risikobudget). Gleiche Klasse, gleicher Modellbauer, gleiche\n",
" Abnahmepruefung - und der Schattenpreis heisst in der einen Welt\n",
" \"Wert einer zusaetzlichen Montagestunde\" und in der anderen \"Preis des\n",
" Risikos\".\n",
" 2. Die Absicherung gegen den schlechtesten Fall als CVaR. EINE Funktion,\n",
" einmal mit Kursrenditen und einmal mit Lieferverzuegen.\n",
" 3. Was NICHT hinueberreicht. Der ehrliche Teil: Drei Unterschiede, die die\n",
" Analogie begrenzen - und die man kennen muss, bevor man Methoden aus\n",
" der einen Welt in die andere traegt.\n",
"\n",
"ZUR SOLVERWAHL: Teil 1 benutzt 'loese_mit_scipy' und nicht 'loese_mit_glop'.\n",
"Das ist kein Zufall - CVXPY laedt fuer Teil 2 highspy, und ortools vertraegt\n",
"sich damit nicht im selben Prozess (Kapitel Oekosystem). Wer hier GLOP nimmt,\n",
"bekommt beim cvxpy-Import eine Fehlermeldung ueber ein 'undefined symbol'.\n",
"Genau deshalb laedt or_kern.py seine Solver erst beim Aufruf.\n",
"\n",
"Benoetigt: numpy, cvxpy, scipy und pydantic (ueber or_kern)\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import numpy as np\n",
"\n",
"from or_kern import (Produkt, Produktionsproblem, loese_mit_scipy,\n",
" pruefe_loesung)\n",
"\n",
"SAAT = 7\n",
"SZENARIEN = 500\n",
"ALPHA = 0.95 # die schlechtesten 5 % der Faelle\n",
"MAX_ANTEIL = 0.40 # Streuungsgebot in beiden Welten\n",
"\n",
"\n",
"# --- Teil 1: Dieselbe Klasse, zwei Welten ----------------------------------\n",
"\n",
"def schreinerei() -> Produktionsproblem:\n",
" \"\"\"Montagestunden und Plattenmaterial auf Tische und Stuehle verteilen.\"\"\"\n",
" return Produktionsproblem(\n",
" produkte=[\n",
" Produkt(name=\"Tisch\", deckungsbeitrag=240.0,\n",
" verbrauch={\"Montagestunden\": 3.0, \"Plattenmaterial\": 6.0}),\n",
" Produkt(name=\"Stuhl\", deckungsbeitrag=60.0,\n",
" verbrauch={\"Montagestunden\": 1.0, \"Plattenmaterial\": 1.0}),\n",
" Produkt(name=\"Regal\", deckungsbeitrag=130.0,\n",
" verbrauch={\"Montagestunden\": 2.0, \"Plattenmaterial\": 4.0}),\n",
" ],\n",
" kapazitaeten={\"Montagestunden\": 150.0, \"Plattenmaterial\": 240.0})\n",
"\n",
"\n",
"def depot() -> Produktionsproblem:\n",
" \"\"\"Kapital und Risikobudget auf Anlageklassen verteilen.\n",
"\n",
" Eine \"Einheit\" ist hier 1.000 EUR Anlagesumme. Der Deckungsbeitrag ist der\n",
" erwartete Jahresertrag dieser Einheit, der Verbrauch die beanspruchte\n",
" Kapital- und Risikomenge. Das ist keine Analogie, sondern buchstaeblich\n",
" dasselbe Modell - deshalb passt es in dieselbe Klasse.\n",
" \"\"\"\n",
" return Produktionsproblem(\n",
" produkte=[\n",
" Produkt(name=\"Aktien Welt\", deckungsbeitrag=75.0,\n",
" verbrauch={\"Kapital (Tsd. EUR)\": 1.0, \"Risikobudget\": 1.00}),\n",
" Produkt(name=\"Anleihen\", deckungsbeitrag=28.0,\n",
" verbrauch={\"Kapital (Tsd. EUR)\": 1.0, \"Risikobudget\": 0.22}),\n",
" Produkt(name=\"Immobilienfonds\", deckungsbeitrag=46.0,\n",
" verbrauch={\"Kapital (Tsd. EUR)\": 1.0, \"Risikobudget\": 0.55}),\n",
" ],\n",
" kapazitaeten={\"Kapital (Tsd. EUR)\": 150.0, \"Risikobudget\": 90.0})\n",
"\n",
"\n",
"def berichte_allokation(titel: str, problem: Produktionsproblem,\n",
" einheit: str, ertragsname: str) -> None:\n",
" \"\"\"Ein Bericht fuer beide Welten - nur die Beschriftung wechselt.\"\"\"\n",
" loesung = loese_mit_scipy(problem)\n",
" beanstandungen = pruefe_loesung(problem, loesung)\n",
"\n",
" print(f\" {titel}\")\n",
" print(f\" {loesung.als_bericht()}\")\n",
" for produkt in problem.produkte:\n",
" print(f\" {produkt.name:<18} {loesung.werte[produkt.name]:8.2f} {einheit}\")\n",
" print(f\" {ertragsname:<18} {loesung.zielwert:8.2f} EUR\")\n",
" for ressource, preis in loesung.schattenpreise.items():\n",
" print(f\" Schattenpreis {ressource:<24} {preis:7.2f} EUR\")\n",
" print(f\" Abnahmepruefung: \"\n",
" f\"{'bestanden' if not beanstandungen else beanstandungen}\")\n",
"\n",
"\n",
"# --- Teil 2: Eine CVaR-Funktion, zwei Welten -------------------------------\n",
"\n",
"def optimiere_cvar(verluste: np.ndarray, ertrag: np.ndarray,\n",
" mindestertrag: float, alpha: float = ALPHA,\n",
" max_anteil: float = MAX_ANTEIL):\n",
" \"\"\"Minimiert den CVaR der Verluste unter einer Mindestertragsbedingung.\n",
"\n",
" 'verluste' hat die Form (Szenarien x Optionen) und enthaelt, was in\n",
" Szenario s passiert, wenn eine Einheit in Option j steckt. Was ein\n",
" \"Verlust\" ist, entscheidet allein die Einheit der Matrix: Prozentpunkte\n",
" Kursverlust oder Tage Lieferverzug - die Formel sieht keinen Unterschied.\n",
"\n",
" Das ist die Rockafellar-Uryasev-Formulierung aus dem Kapitel CVaR, hier\n",
" ohne jede Aenderung wiederverwendet.\n",
" \"\"\"\n",
" import cvxpy as cp\n",
"\n",
" anzahl_szenarien, anzahl_optionen = verluste.shape\n",
" anteil = cp.Variable(anzahl_optionen, nonneg=True)\n",
" schwelle = cp.Variable() # wird im Optimum zum VaR\n",
" ueberschuss = cp.Variable(anzahl_szenarien, nonneg=True)\n",
"\n",
" cvar = schwelle + (1.0 / (anzahl_szenarien * (1 - alpha))) * cp.sum(ueberschuss)\n",
" problem = cp.Problem(\n",
" cp.Minimize(cvar),\n",
" [ueberschuss >= verluste @ anteil - schwelle,\n",
" cp.sum(anteil) == 1,\n",
" anteil <= max_anteil,\n",
" ertrag @ anteil >= mindestertrag])\n",
" problem.solve()\n",
" if problem.status not in (\"optimal\", \"optimal_inaccurate\"):\n",
" raise SystemExit(f\"CVaR-Problem nicht loesbar: {problem.status}\")\n",
" return anteil.value, float(cvar.value), float(schwelle.value)\n",
"\n",
"\n",
"def kursszenarien(rng) -> tuple[np.ndarray, np.ndarray, list[str]]:\n",
" \"\"\"Taegliche Verluste (negative Renditen) von sechs Anlageklassen.\"\"\"\n",
" namen = [\"Aktien Welt\", \"Aktien EU\", \"Schwellenlaender\",\n",
" \"Staatsanleihen\", \"Unternehmensanl.\", \"Rohstoffe\"]\n",
" rendite_pa = np.array([0.080, 0.065, 0.110, 0.025, 0.045, 0.070])\n",
" schwankung = np.array([0.180, 0.160, 0.260, 0.040, 0.075, 0.210])\n",
" taeglich = rng.normal(rendite_pa / 252, schwankung / np.sqrt(252),\n",
" (SZENARIEN, len(namen)))\n",
" return -taeglich * 100.0, rendite_pa * 100.0, namen\n",
"\n",
"\n",
"def lieferszenarien(rng) -> tuple[np.ndarray, np.ndarray, list[str]]:\n",
" \"\"\"Lieferverzug in Tagen bei sechs Lieferanten.\n",
"\n",
" Der Aufbau ist bewusst anders als bei den Kursen: Hier gibt es einen\n",
" normalen Verzug UND seltene Totalausfaelle, die 14 Tage kosten. Das ist\n",
" genau die Art fetter Raender, wegen der man in beiden Welten CVaR statt\n",
" Standardabweichung benutzt.\n",
" \"\"\"\n",
" namen = [\"Nordwerk\", \"Sued-Metall\", \"Fernost A\", \"Lokalzulieferer\",\n",
" \"Fernost B\", \"Osteuropa\"]\n",
" zuverlaessigkeit = np.array([0.94, 0.97, 0.90, 0.99, 0.92, 0.95])\n",
" verzugsstreuung = np.array([3.5, 1.8, 5.0, 0.9, 4.2, 2.8])\n",
" normaler_verzug = np.maximum(0.0, rng.normal(0.5, 1.0, (SZENARIEN, len(namen)))\n",
" * verzugsstreuung)\n",
" totalausfall = (rng.random((SZENARIEN, len(namen))) > zuverlaessigkeit) * 14.0\n",
" return normaler_verzug + totalausfall, zuverlaessigkeit, namen\n",
"\n",
"\n",
"def berichte_cvar(titel: str, verluste, ertrag, namen, mindestertrag,\n",
" einheit: str, ertragsname: str):\n",
" anteile, cvar, var = optimiere_cvar(verluste, ertrag, mindestertrag)\n",
" mittel = float((verluste @ anteile).mean())\n",
" print(f\" {titel}\")\n",
" print(f\" VaR {ALPHA:.0%}: {var:8.3f} {einheit}\")\n",
" print(f\" CVaR {ALPHA:.0%}: {cvar:8.3f} {einheit} \"\n",
" f\"(Mittelwert ueber alle Szenarien: {mittel:.3f})\")\n",
" print(f\" {ertragsname}: {float(ertrag @ anteile):.3f}\")\n",
" print(\" Aufteilung:\")\n",
" for name, anteil in zip(namen, anteile):\n",
" balken = \"#\" * int(round(anteil * 40))\n",
" grenze = \" <- an der Streuungsgrenze\" if anteil > MAX_ANTEIL - 1e-4 else \"\"\n",
" print(f\" {name:<18} {anteil:6.1%} {balken}{grenze}\")\n",
" return anteile\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" rng = np.random.default_rng(SAAT)\n",
"\n",
" print(\"=\" * 80)\n",
" print(\" DIESELBE STRUKTUR, ZWEI WELTEN\")\n",
" print(\"=\" * 80)\n",
"\n",
" # --- Teil 1 ----------------------------------------------------------\n",
" print(\"\\n1. Allokation als LP - EINE Klasse, EIN Modellbauer\\n\")\n",
" berichte_allokation(\"Werkstatt: Produktionsprogramm\", schreinerei(),\n",
" \"Stueck\", \"Deckungsbeitrag\")\n",
" print()\n",
" berichte_allokation(\"Depot: Anlageaufteilung\", depot(),\n",
" \"Tsd. \", \"Erwarteter Ertrag\")\n",
"\n",
" print(\"\\n Beide Ausgaben stammen aus derselben Funktion \"\n",
" \"'berichte_allokation'.\")\n",
" print(\" Ausgetauscht wurden nur die Daten und die Beschriftungen -\")\n",
" print(\" keine Zeile Modellcode.\")\n",
" print()\n",
" print(\" Lesen Sie die Schattenpreise nebeneinander: In der Werkstatt sagt\")\n",
" print(\" er, was eine zusaetzliche Montagestunde wert waere. Im Depot sagt\")\n",
" print(\" dieselbe Zahl, was eine zusaetzliche Einheit Risikobudget wert\")\n",
" print(\" waere - der PREIS DES RISIKOS. Das ist kein Sprachbild, sondern\")\n",
" print(\" derselbe Dualwert derselben Nebenbedingung.\")\n",
"\n",
" # --- Teil 2 ----------------------------------------------------------\n",
" print(\"\\n\" + \"-\" * 80)\n",
" print(\"2. Absicherung gegen den schlechtesten Fall - EINE CVaR-Funktion\\n\")\n",
"\n",
" kurse, rendite, anlagen = kursszenarien(rng)\n",
" anteile_depot = berichte_cvar(\n",
" \"Depot: die schlechtesten 5 % der Handelstage\",\n",
" kurse, rendite, anlagen, 5.5,\n",
" \"% je Tag \", \"Erwartete Jahresrendite (%)\")\n",
" print()\n",
" lieferung, zuverlaessig, lieferanten = lieferszenarien(rng)\n",
" anteile_einkauf = berichte_cvar(\"Einkauf: die schlechtesten 5 % der Bestellungen\",\n",
" lieferung, zuverlaessig, lieferanten, 0.945,\n",
" \"Tage Verzug \", \"Mittlere Zuverlaessigkeit\")\n",
"\n",
" print(\"\\n Auch hier: eine Funktion, zwei Aufrufe. Die Zielfunktion\")\n",
" print(\" interessiert sich nicht dafuer, ob in der Matrix Prozentpunkte\")\n",
" print(\" oder Tage stehen.\")\n",
" print()\n",
" print(f\" Interessant ist, WO die Streuungsgrenze von {MAX_ANTEIL:.0%} bindet:\")\n",
" print(f\" Depot : {(anteile_depot > MAX_ANTEIL - 1e-4).sum()} von \"\n",
" f\"{len(anteile_depot)} Posten am Anschlag\")\n",
" print(f\" Einkauf : {(anteile_einkauf > MAX_ANTEIL - 1e-4).sum()} von \"\n",
" f\"{len(anteile_einkauf)} Posten am Anschlag\")\n",
" print()\n",
" print(\" Im Einkauf zieht es die Loesung an den sicheren Lokalzulieferer,\")\n",
" print(\" bis die Grenze sie stoppt. Im Depot nicht - dort verhindert die\")\n",
" print(\" Mindestrendite, dass alles in Anleihen wandert. Zwei verschiedene\")\n",
" print(\" Bremsen also, und sie stehen an verschiedenen Stellen des Modells:\")\n",
" print(\" einmal in einer Nebenbedingung ueber die Anteile, einmal in einer\")\n",
" print(\" ueber den Ertrag. Wer eine Struktur uebertraegt, uebertraegt eben\")\n",
" print(\" nicht automatisch mit, WELCHE Bedingung am Ende bindet.\")\n",
"\n",
" # --- Teil 3: Der ehrliche Teil ---------------------------------------\n",
" print(\"\\n\" + \"=\" * 80)\n",
" print(\" WAS NICHT HINUEBERREICHT\")\n",
" print(\"=\" * 80)\n",
" print(\"Die Struktur traegt. Drei Unterschiede tragen NICHT mit, und wer sie\")\n",
" print(\"uebersieht, macht aus einer nuetzlichen Analogie einen Fehler:\")\n",
" print()\n",
" print(\"1. WOHER DIE ZAHLEN KOMMEN.\")\n",
" print(\" In der Werkstatt ist der Verbrauch je Tisch gemessen - drei\")\n",
" print(\" Montagestunden sind drei Montagestunden. Im Depot ist die\")\n",
" print(\" erwartete Rendite GESCHAETZT, und zwar mit einem Fehler, der\")\n",
" print(\" groesser sein kann als die Unterschiede zwischen den Anlagen\")\n",
" print(\" (Kapitel Markowitz, Renditeschaetzung_Falle.py). Dieselbe\")\n",
" print(\" Optimierung ist im einen Fall Planung und im anderen\")\n",
" print(\" Fehlerverstaerkung.\")\n",
" print()\n",
" print(\"2. OB DIE VERGANGENHEIT ETWAS UEBER DIE ZUKUNFT SAGT.\")\n",
" print(\" Lieferzeiten haben physikalische Ursachen: Entfernung, Zoll,\")\n",
" print(\" Kapazitaet. Sie aendern sich langsam und nachvollziehbar.\")\n",
" print(\" Kursrenditen entstehen aus dem Verhalten von Marktteilnehmern,\")\n",
" print(\" die selbst auf Modelle reagieren - dort verschwindet ein\")\n",
" print(\" erkanntes Muster oft genau deshalb, weil es erkannt wurde\")\n",
" print(\" (Kapitel Handelsmaschine, Data_Snooping.py).\")\n",
" print()\n",
" print(\"3. OB TEILBARKEIT ERLAUBT IST.\")\n",
" print(\" 37,4 % eines Aktienfonds sind ein normaler Auftrag. 37,4 % eines\")\n",
" print(\" Lieferanten sind es nicht - Vertraege, Mindestabnahmen und\")\n",
" print(\" Ruestzeiten machen Einkaufsentscheidungen ganzzahlig. Genau\")\n",
" print(\" deshalb ist Teil II voller MILP und Teil V fast frei davon.\")\n",
" print()\n",
" print(\"Die Bruecke traegt also die MODELLE, nicht die Annahmen. Wer sie\")\n",
" print(\"benutzt, spart sich das Lernen der Methoden - nicht das Nachdenken\")\n",
" print(\"ueber die Daten.\")\n",
" print(\"=\" * 80)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -0,0 +1,902 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 7: Constraint Programming mit CP-SAT — Logik, Scheduling und Zuweisung\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Was Propagation leistet\n",
"\n",
"`Propagation_Demo.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Propagation_Demo.py\n",
"\"\"\"\n",
"Kapitel CP-SAT: Propagation sichtbar machen.\n",
"Vergleicht die Zahl der geprueften Kombinationen bei roher Aufzaehlung\n",
"mit der Zahl der Verzweigungen, die CP-SAT tatsaechlich braucht.\n",
"\"\"\"\n",
"\n",
"import itertools\n",
"\n",
"from ortools.sat.python import cp_model\n",
"\n",
"\n",
"def rohe_aufzaehlung(n: int, obergrenze: int) -> tuple[int, int]:\n",
" \"\"\"Zaehlt ALLE Kombinationen und prueft jede einzeln (Brute Force).\"\"\"\n",
" geprueft = 0\n",
" loesungen = 0\n",
" for kombination in itertools.product(range(1, obergrenze + 1), repeat=n):\n",
" geprueft += 1\n",
" if len(set(kombination)) == n and sum(kombination) == 3 * n:\n",
" loesungen += 1\n",
" return geprueft, loesungen\n",
"\n",
"\n",
"def mit_cp_sat(n: int, obergrenze: int) -> tuple[int, int, float]:\n",
" \"\"\"Dasselbe Problem deklarativ: alle verschieden, Summe = 3n.\"\"\"\n",
" modell = cp_model.CpModel()\n",
" x = [modell.NewIntVar(1, obergrenze, f\"x{i}\") for i in range(n)]\n",
" modell.AddAllDifferent(x) # globales Constraint\n",
" modell.Add(sum(x) == 3 * n)\n",
"\n",
" loeser = cp_model.CpSolver()\n",
" loeser.parameters.enumerate_all_solutions = True\n",
"\n",
" class Zaehler(cp_model.CpSolverSolutionCallback):\n",
" def __init__(self):\n",
" super().__init__()\n",
" self.anzahl = 0\n",
"\n",
" def on_solution_callback(self):\n",
" self.anzahl += 1\n",
"\n",
" zaehler = Zaehler()\n",
" loeser.Solve(modell, zaehler)\n",
" return zaehler.anzahl, loeser.NumBranches(), loeser.WallTime()\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 84)\n",
" print(\" PROPAGATION: WIE VIEL ARBEIT SPART SICH DER SOLVER?\")\n",
" print(\" Aufgabe: n Zahlen aus 1..G, alle verschieden, Summe = 3n\")\n",
" print(\"=\" * 84)\n",
" print(f\"{'n':>3} {'G':>4} | {'Brute Force':>14} | {'CP-SAT':>10} | \"\n",
" f\"{'Ersparnis':>11} | {'Loesungen':>10}\")\n",
" print(\"-\" * 84)\n",
"\n",
" for n, grenze in [(4, 8), (5, 10), (6, 12), (7, 14)]:\n",
" kombis, treffer_bf = rohe_aufzaehlung(n, grenze)\n",
" treffer_cp, verzweigungen, dauer = mit_cp_sat(n, grenze)\n",
" assert treffer_bf == treffer_cp, \"Beide Verfahren muessen gleich viele finden!\"\n",
" ersparnis = kombis / max(verzweigungen, 1)\n",
" print(f\"{n:>3} {grenze:>4} | {kombis:>14,} | {verzweigungen:>10,} | \"\n",
" f\"{ersparnis:>10.0f}x | {treffer_cp:>10,}\")\n",
"\n",
" print(\"-\" * 84)\n",
" print(\"'Brute Force' = alle Kombinationen. 'CP-SAT' = tatsaechliche Verzweigungen.\")\n",
" print(\"Der Rest wurde durch Propagation ausgeschlossen, ohne ihn anzusehen.\")\n",
" print(\"=\" * 84)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Praxisfall: Dynamisches Vertretungssystem\n",
"\n",
"`CP_SAT_Vertretungssystem.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# CP_SAT_Vertretungssystem.py\n",
"\"\"\"\n",
"Kapitel CP-SAT: Dynamisches Vertretungs- und Einsatzplanungssystem mit CP-SAT.\n",
"\n",
"Eigenschaften:\n",
" * Solver-Status wird ueber SolverStatus aus or_kern.py ausgewertet - dieselbe\n",
" Fallunterscheidung wie bei jedem anderen Solver im Buch\n",
" * Strafkosten-Zerlegung wird ausgewiesen (Erklaerbarkeit, Kapitel Praxisfallen)\n",
" * Fairness-Kriterium ergaenzt (Spannweite der Belastung minimieren)\n",
" * Vorabdiagnose auf offensichtliche Unloesbarkeit\n",
" * Abnahmepruefung des fertigen Plans OHNE den Solver zu fragen\n",
"\n",
"Benoetigt: ortools, pandas, pydantic (ueber or_kern)\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"from ortools.sat.python import cp_model\n",
"import pandas as pd\n",
"\n",
"from or_kern import Loesung, SolverStatus, status_von_cpsat\n",
"\n",
"# --- 1. Datenbasis --------------------------------------------------------\n",
"SLOTS = [1, 2, 3, 4]\n",
"FAECHER = [\"Mathematik\", \"Physik\", \"Mathematik\", \"Informatik\"]\n",
"KLASSEN = [\"Klasse 8a\", \"Klasse 10b\", \"Klasse 7a\", \"Klasse 9c\"]\n",
"\n",
"PERSONAL = [\"Frau_Mueller\", \"Herr_Schmidt\", \"Frau_Albrecht\", \"Herr_Bauer\", \"Frau_Koch\"]\n",
"\n",
"QUALIFIKATION = {\n",
" \"Frau_Mueller\": {\"Mathematik\", \"Physik\"},\n",
" \"Herr_Schmidt\": {\"Informatik\", \"Mathematik\"},\n",
" \"Frau_Albrecht\": {\"Physik\", \"Informatik\"},\n",
" \"Herr_Bauer\": {\"Mathematik\"},\n",
" \"Frau_Koch\": {\"Mathematik\", \"Physik\", \"Informatik\"},\n",
"}\n",
"\n",
"VORBELASTUNG = { # bereits geleistete Stunden heute\n",
" \"Frau_Mueller\": 1, \"Herr_Schmidt\": 0, \"Frau_Albrecht\": 2,\n",
" \"Herr_Bauer\": 0, \"Frau_Koch\": 1,\n",
"}\n",
"\n",
"MAX_VERTRETUNGEN = 2\n",
"STRAFE_VORBELASTUNG = 50 # je Stunde Vorbelastung und Zuweisung\n",
"STRAFE_LOCH = 80 # je Freistundenloch\n",
"STRAFE_UNFAIRNESS = 30 # je Einheit Spannweite der Gesamtbelastung\n",
"\n",
"\n",
"def pruefe_grundsaetzliche_loesbarkeit() -> bool:\n",
" \"\"\"Vorabdiagnose: Gibt es fuer jeden Slot ueberhaupt qualifiziertes Personal?\"\"\"\n",
" ok = True\n",
" for s, fach in enumerate(FAECHER):\n",
" kandidaten = [p for p in PERSONAL if fach in QUALIFIKATION[p]]\n",
" if not kandidaten:\n",
" print(f\" UNLOESBAR: Fuer Slot {SLOTS[s]} ({fach}) gibt es niemanden.\")\n",
" ok = False\n",
" kapazitaet = len(PERSONAL) * MAX_VERTRETUNGEN\n",
" if kapazitaet < len(SLOTS):\n",
" print(f\" UNLOESBAR: Kapazitaet {kapazitaet} < {len(SLOTS)} offene Stunden.\")\n",
" ok = False\n",
" return ok\n",
"\n",
"\n",
"def pruefe_plan(plan: dict[int, str]) -> list[str]:\n",
" \"\"\"Prueft den fertigen Plan gegen die harten Regeln - ohne den Solver.\n",
"\n",
" Der Solver kann nur pruefen, was ihm gesagt wurde. Diese Funktion prueft\n",
" gegen die ANFORDERUNG und benutzt dafuer bewusst keine Modellvariable\n",
" (siehe Kapitel Praxisfallen). Leere Liste heisst bestanden.\n",
" \"\"\"\n",
" beanstandungen: list[str] = []\n",
" if sorted(plan) != list(range(len(SLOTS))):\n",
" beanstandungen.append(f\"nicht jede Stunde genau einmal besetzt: {sorted(plan)}\")\n",
" return beanstandungen\n",
" for s, person in plan.items():\n",
" if FAECHER[s] not in QUALIFIKATION[person]:\n",
" beanstandungen.append(f\"{person} ist nicht fuer {FAECHER[s]} qualifiziert\")\n",
" for person in PERSONAL:\n",
" anzahl = sum(1 for p in plan.values() if p == person)\n",
" if anzahl > MAX_VERTRETUNGEN:\n",
" beanstandungen.append(f\"{person} hat {anzahl} Vertretungen \"\n",
" f\"(hoechstens {MAX_VERTRETUNGEN})\")\n",
" return beanstandungen\n",
"\n",
"\n",
"def plane_vertretung() -> list[dict[str, str]] | None:\n",
" if not pruefe_grundsaetzliche_loesbarkeit():\n",
" return None\n",
"\n",
" modell = cp_model.CpModel()\n",
"\n",
" # Entscheidungsvariablen: x[person, slot] = 1 <=> Person uebernimmt Slot\n",
" x = {(p, s): modell.NewBoolVar(f\"zuweisung_{p}_slot{s+1}\")\n",
" for p in PERSONAL for s in range(len(SLOTS))}\n",
"\n",
" # --- HARTE NEBENBEDINGUNGEN ------------------------------------------\n",
" # H1: Jede Stunde genau einmal besetzen\n",
" for s in range(len(SLOTS)):\n",
" modell.AddExactlyOne(x[p, s] for p in PERSONAL)\n",
"\n",
" # H2: Qualifikation - unqualifizierte Zuweisung ausschliessen\n",
" for s, fach in enumerate(FAECHER):\n",
" for p in PERSONAL:\n",
" if fach not in QUALIFIKATION[p]:\n",
" modell.Add(x[p, s] == 0)\n",
"\n",
" # H3: Hoechstens MAX_VERTRETUNGEN Stunden je Person\n",
" for p in PERSONAL:\n",
" modell.Add(sum(x[p, s] for s in range(len(SLOTS))) <= MAX_VERTRETUNGEN)\n",
"\n",
" # (H4 \"nicht an zwei Orten gleichzeitig\" ist durch H1 bereits erfuellt:\n",
" # jeder Slot hat genau eine Person, und eine Person kann pro Slot nur\n",
" # eine Variable auf 1 setzen.)\n",
"\n",
" # --- WEICHE ZIELE ----------------------------------------------------\n",
" strafterme = []\n",
"\n",
" # W1: Vorbelastete Personen schonen\n",
" strafe_last = []\n",
" for p in PERSONAL:\n",
" zuweisungen = sum(x[p, s] for s in range(len(SLOTS)))\n",
" strafe_last.append(VORBELASTUNG[p] * STRAFE_VORBELASTUNG * zuweisungen)\n",
" strafterme.extend(strafe_last)\n",
"\n",
" # W2: Freistundenloecher vermeiden (Muster: arbeitet, frei, arbeitet)\n",
" loch_variablen = []\n",
" for p in PERSONAL:\n",
" for s in range(len(SLOTS) - 2):\n",
" loch = modell.NewBoolVar(f\"loch_{p}_{s}\")\n",
" # Reifizierung: loch == 1 <=> (x_s AND NOT x_{s+1} AND x_{s+2})\n",
" modell.AddBoolAnd([x[p, s], x[p, s + 1].Not(), x[p, s + 2]]).OnlyEnforceIf(loch)\n",
" modell.AddBoolOr([x[p, s].Not(), x[p, s + 1], x[p, s + 2].Not()]) \\\n",
" .OnlyEnforceIf(loch.Not())\n",
" loch_variablen.append(loch)\n",
" strafterme.append(loch * STRAFE_LOCH)\n",
"\n",
" # W3: Fairness - Spannweite der Gesamtbelastung minimieren\n",
" gesamtlast = {}\n",
" for p in PERSONAL:\n",
" last = modell.NewIntVar(0, len(SLOTS) + max(VORBELASTUNG.values()), f\"last_{p}\")\n",
" modell.Add(last == VORBELASTUNG[p] + sum(x[p, s] for s in range(len(SLOTS))))\n",
" gesamtlast[p] = last\n",
" max_last = modell.NewIntVar(0, 10, \"max_last\")\n",
" min_last = modell.NewIntVar(0, 10, \"min_last\")\n",
" modell.AddMaxEquality(max_last, list(gesamtlast.values()))\n",
" modell.AddMinEquality(min_last, list(gesamtlast.values()))\n",
" spannweite = modell.NewIntVar(0, 10, \"spannweite\")\n",
" modell.Add(spannweite == max_last - min_last)\n",
" strafterme.append(spannweite * STRAFE_UNFAIRNESS)\n",
"\n",
" modell.Minimize(sum(strafterme))\n",
"\n",
" # --- LOESEN -----------------------------------------------------------\n",
" loeser = cp_model.CpSolver()\n",
" loeser.parameters.max_time_in_seconds = 5.0\n",
" # Reproduzierbarkeit: ein Arbeiter, fester Startwert. Ohne das kann CP-SAT\n",
" # bei mehreren gleich guten Plaenen von Lauf zu Lauf einen anderen liefern\n",
" # (siehe JobShop_Intervalle.py im naechsten Abschnitt).\n",
" loeser.parameters.num_workers = 1\n",
" loeser.parameters.random_seed = 1\n",
" rohstatus = loeser.Solve(modell)\n",
"\n",
" # Der Rueckgabewert wird in die gemeinsame Sprache uebersetzt. Ab hier\n",
" # sieht die Auswertung genauso aus wie bei HiGHS, GLOP oder CVXPY.\n",
" status = status_von_cpsat(rohstatus)\n",
" loesung = Loesung(\n",
" status=status,\n",
" zielwert=loeser.ObjectiveValue() if status.brauchbar else None,\n",
" schranke=loeser.BestObjectiveBound() if status.brauchbar else None,\n",
" laufzeit=loeser.WallTime())\n",
"\n",
" print(\"=\" * 78)\n",
" print(\" DYNAMISCHER VERTRETUNGSPLAN (CP-SAT OPTIMIERT)\")\n",
" print(\"=\" * 78)\n",
"\n",
" if status.modellfehler:\n",
" print(f\"Kein zulaessiger Plan moeglich (Status: {status.value}).\")\n",
" print(\"Die harten Regeln widersprechen sich. Naechster Schritt: eine Regel\")\n",
" print(\"weich machen (Kapitel Praxisfallen) oder den Konflikt eingrenzen\")\n",
" print(\"(Anhang Fehlerdiagnose).\")\n",
" return None\n",
" if not status.brauchbar:\n",
" print(f\"Solver lieferte kein Ergebnis (Status: {status.value}).\")\n",
" print(\"Zeitlimit erhoehen oder das Modell vereinfachen.\")\n",
" return None\n",
"\n",
" guete = (\"beweisbar optimal\" if status is SolverStatus.OPTIMAL\n",
" else \"zulaessig, aber nicht bewiesen - Gap beachten\")\n",
" print(f\"Solver-Status: {loeser.StatusName(rohstatus)} -> {status.value} ({guete})\")\n",
" print(f\"{loesung.als_bericht()}\\n\")\n",
"\n",
" # --- Plan ausgeben ----------------------------------------------------\n",
" zuweisung = {s: next(p for p in PERSONAL if loeser.Value(x[p, s]) == 1)\n",
" for s in range(len(SLOTS))}\n",
" plan = [{\n",
" \"Stunde\": f\"Std. {SLOTS[s]}\",\n",
" \"Klasse\": KLASSEN[s],\n",
" \"Fach\": FAECHER[s],\n",
" \"Vertretung\": zuweisung[s],\n",
" \"Vorbelastung\": f\"{VORBELASTUNG[zuweisung[s]]} Std.\",\n",
" } for s in range(len(SLOTS))]\n",
" print(pd.DataFrame(plan).to_string(index=False))\n",
"\n",
" # --- Abnahmepruefung gegen die Anforderung ----------------------------\n",
" beanstandungen = pruefe_plan(zuweisung)\n",
" if beanstandungen:\n",
" print(\"\\nABNAHMEPRUEFUNG FEHLGESCHLAGEN:\")\n",
" for beanstandung in beanstandungen:\n",
" print(f\" - {beanstandung}\")\n",
" return None\n",
" print(\"\\nAbnahmepruefung: bestanden (Besetzung, Qualifikation, Hoechstzahl).\")\n",
"\n",
" # --- Erklaerbarkeit: Strafkosten aufschluesseln -----------------------\n",
" kosten_last = sum(VORBELASTUNG[p] * STRAFE_VORBELASTUNG\n",
" * sum(loeser.Value(x[p, s]) for s in range(len(SLOTS)))\n",
" for p in PERSONAL)\n",
" kosten_loecher = sum(loeser.Value(v) for v in loch_variablen) * STRAFE_LOCH\n",
" kosten_fairness = loeser.Value(spannweite) * STRAFE_UNFAIRNESS\n",
"\n",
" print(\"\\n--- Woraus bestehen die Strafkosten? ---\")\n",
" print(f\" Vorbelastung geschont: {kosten_last:5.0f} Punkte\")\n",
" print(f\" Freistundenloecher: {kosten_loecher:5.0f} Punkte \"\n",
" f\"({sum(loeser.Value(v) for v in loch_variablen)} Loch/Loecher)\")\n",
" print(f\" Fairness (Spannweite {loeser.Value(spannweite)}): {kosten_fairness:5.0f} Punkte\")\n",
" print(f\" {'Summe':<23} {loeser.ObjectiveValue():5.0f} Punkte\")\n",
"\n",
" print(\"\\n--- Auslastung nach Optimierung ---\")\n",
" for p in PERSONAL:\n",
" heute = sum(loeser.Value(x[p, s]) for s in range(len(SLOTS)))\n",
" print(f\" * {p:<15}: {VORBELASTUNG[p]} vorher + {heute} Vertretung \"\n",
" f\"= {VORBELASTUNG[p] + heute} Stunden\")\n",
"\n",
" print(f\"\\nSuchstatistik: {loeser.NumBranches()} Verzweigungen, \"\n",
" f\"{loeser.NumConflicts()} Konflikte, {loeser.WallTime():.3f} s\")\n",
" print(\"=\" * 78)\n",
" return plan\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" plane_vertretung()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Intervallvariablen: Job-Shop-Scheduling\n",
"\n",
"`JobShop_Intervalle.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# JobShop_Intervalle.py\n",
"\"\"\"\n",
"Kapitel CP-SAT: Job-Shop-Scheduling mit Intervallvariablen.\n",
"Zeigt NewIntervalVar, AddNoOverlap und die Minimierung des Makespan.\n",
"\"\"\"\n",
"\n",
"import collections\n",
"\n",
"from ortools.sat.python import cp_model\n",
"\n",
"# (Maschine, Dauer) je Arbeitsgang, in der Reihenfolge des Auftrags\n",
"AUFTRAEGE = [\n",
" [(0, 3), (1, 2), (2, 2)], # Auftrag 0\n",
" [(0, 2), (2, 1), (1, 4)], # Auftrag 1\n",
" [(1, 4), (2, 3)], # Auftrag 2\n",
"]\n",
"MASCHINENNAMEN = [\"Fraese\", \"Dreherei\", \"Lackiererei\"]\n",
"\n",
"\n",
"def loese_jobshop():\n",
" anzahl_maschinen = 1 + max(m for auftrag in AUFTRAEGE for m, _ in auftrag)\n",
" horizont = sum(dauer for auftrag in AUFTRAEGE for _, dauer in auftrag)\n",
"\n",
" modell = cp_model.CpModel()\n",
" Gang = collections.namedtuple(\"Gang\", \"start ende intervall\")\n",
" plaene = {}\n",
" maschinen_intervalle = collections.defaultdict(list)\n",
"\n",
" # --- Intervallvariablen anlegen --------------------------------------\n",
" for a, auftrag in enumerate(AUFTRAEGE):\n",
" for g, (maschine, dauer) in enumerate(auftrag):\n",
" start = modell.NewIntVar(0, horizont, f\"start_{a}_{g}\")\n",
" ende = modell.NewIntVar(0, horizont, f\"ende_{a}_{g}\")\n",
" # Ein Intervall koppelt start + dauer == ende automatisch\n",
" intervall = modell.NewIntervalVar(start, dauer, ende, f\"intervall_{a}_{g}\")\n",
" plaene[a, g] = Gang(start, ende, intervall)\n",
" maschinen_intervalle[maschine].append(intervall)\n",
"\n",
" # --- H1: eine Maschine bearbeitet nur einen Gang gleichzeitig --------\n",
" for maschine in range(anzahl_maschinen):\n",
" modell.AddNoOverlap(maschinen_intervalle[maschine])\n",
"\n",
" # --- H2: Reihenfolge innerhalb eines Auftrags einhalten -------------\n",
" for a, auftrag in enumerate(AUFTRAEGE):\n",
" for g in range(len(auftrag) - 1):\n",
" modell.Add(plaene[a, g + 1].start >= plaene[a, g].ende)\n",
"\n",
" # --- Ziel: Makespan minimieren ---------------------------------------\n",
" makespan = modell.NewIntVar(0, horizont, \"makespan\")\n",
" modell.AddMaxEquality(makespan, [plaene[a, len(auftrag) - 1].ende\n",
" for a, auftrag in enumerate(AUFTRAEGE)])\n",
" modell.Minimize(makespan)\n",
"\n",
" loeser = cp_model.CpSolver()\n",
" loeser.parameters.max_time_in_seconds = 10.0\n",
" # Es gibt mehrere Plaene mit demselben kuerzesten Makespan. Welchen CP-SAT\n",
" # findet, haengt sonst davon ab, welcher seiner parallelen Suchstraenge\n",
" # zuerst fertig wird - dasselbe Programm liefert dann von Lauf zu Lauf\n",
" # verschiedene (gleich gute) Plaene. Fuer ein reproduzierbares Buchbeispiel\n",
" # fixieren wir beides. Im Produktivbetrieb laesst man die Standardwerte\n",
" # stehen: Mehrere Arbeiter sind dort deutlich schneller.\n",
" loeser.parameters.num_workers = 1\n",
" loeser.parameters.random_seed = 1\n",
" status = loeser.Solve(modell)\n",
"\n",
" if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):\n",
" print(f\"Kein Plan gefunden: {loeser.StatusName(status)}\")\n",
" return\n",
"\n",
" print(\"=\" * 74)\n",
" print(\" JOB-SHOP-SCHEDULING MIT INTERVALLVARIABLEN\")\n",
" print(\"=\" * 74)\n",
" print(f\"Status: {loeser.StatusName(status)} | \"\n",
" f\"Kuerzeste Gesamtdauer (Makespan): {loeser.Value(makespan)} Zeiteinheiten\\n\")\n",
"\n",
" # --- Gantt-Diagramm als Text ------------------------------------------\n",
" dauer_gesamt = loeser.Value(makespan)\n",
" print(f\"{'Maschine':<14}|\" + \"\".join(f\"{t:<3}\" for t in range(dauer_gesamt)))\n",
" print(\"-\" * (15 + 3 * dauer_gesamt))\n",
" for maschine in range(anzahl_maschinen):\n",
" zeile = [\" . \"] * dauer_gesamt\n",
" for a, auftrag in enumerate(AUFTRAEGE):\n",
" for g, (m, dauer) in enumerate(auftrag):\n",
" if m == maschine:\n",
" beginn = loeser.Value(plaene[a, g].start)\n",
" for t in range(beginn, beginn + dauer):\n",
" zeile[t] = f\" A{a}\"\n",
" print(f\"{MASCHINENNAMEN[maschine]:<14}|\" + \"\".join(zeile))\n",
"\n",
" print(\"\\n--- Detailplan ---\")\n",
" for a, auftrag in enumerate(AUFTRAEGE):\n",
" teile = []\n",
" for g, (maschine, dauer) in enumerate(auftrag):\n",
" beginn = loeser.Value(plaene[a, g].start)\n",
" teile.append(f\"{MASCHINENNAMEN[maschine]} {beginn}-{beginn + dauer}\")\n",
" print(f\" Auftrag {a}: \" + \" -> \".join(teile))\n",
"\n",
" # --- Untere Schranke zum Vergleich ------------------------------------\n",
" laengster_auftrag = max(sum(d for _, d in a) for a in AUFTRAEGE)\n",
" hoechste_maschinenlast = max(\n",
" sum(d for auftrag in AUFTRAEGE for m, d in auftrag if m == maschine)\n",
" for maschine in range(anzahl_maschinen))\n",
" schranke = max(laengster_auftrag, hoechste_maschinenlast)\n",
" print(f\"\\nUntere Schranke (laengster Auftrag / hoechste Maschinenlast): {schranke}\")\n",
" print(f\"Erreichter Makespan: {loeser.Value(makespan)}\"\n",
" f\"{' -> beweisbar bestmoeglich' if loeser.Value(makespan) == schranke else ''}\")\n",
" print(\"=\" * 74)\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" loese_jobshop()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Die fünf Antworten von CP-SAT\n",
"\n",
"`CP_SAT_Statusfaelle.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# CP_SAT_Statusfaelle.py\n",
"\"\"\"\n",
"Kapitel CP-SAT: Alle fuenf Antworten von CP-SAT - und was jede bedeutet.\n",
"\n",
"Der haeufigste Fehler beim Einsatz von CP-SAT im Betrieb ist eine Zeile wie\n",
"\n",
" if status == cp_model.OPTIMAL:\n",
" ...verwerte die Loesung...\n",
"\n",
"Sie wirft in drei von fuenf Faellen etwas weg, das man haette gebrauchen\n",
"koennen, und verschweigt in einem weiteren Fall einen Modellierungsfehler.\n",
"\n",
"Dieses Programm erzeugt jeden Statusfall absichtlich und zeigt die passende\n",
"Reaktion:\n",
"\n",
" OPTIMAL beweisbar bestmoeglich -> ausfuehren\n",
" FEASIBLE zulaessig, Zeit war um -> Gap pruefen, dann entscheiden\n",
" INFEASIBLE kein Plan existiert -> harte Regeln lockern\n",
" MODEL_INVALID das Modell ist fehlerhaft -> Programmierfehler beheben\n",
" UNKNOWN nichts gefunden, Zeit war um -> mehr Zeit oder Heuristik\n",
"\n",
"Benoetigt: numpy, ortools\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"from dataclasses import dataclass\n",
"\n",
"import numpy as np\n",
"from ortools.sat.python import cp_model\n",
"\n",
"\n",
"@dataclass\n",
"class Auswertung:\n",
" \"\"\"Was nach einem CP-SAT-Lauf tatsaechlich feststeht.\"\"\"\n",
" status: str\n",
" hat_loesung: bool\n",
" beweisbar_optimal: bool\n",
" zielwert: float | None\n",
" schranke: float | None\n",
" gap: float | None\n",
" empfehlung: str\n",
"\n",
"\n",
"def werte_aus(loeser: cp_model.CpSolver, status: int,\n",
" mit_zielfunktion: bool) -> Auswertung:\n",
" \"\"\"Uebersetzt einen CP-SAT-Status in eine Handlungsempfehlung.\n",
"\n",
" Genau diese Funktion fehlt in den meisten Projekten. Sie ist kurz, aber\n",
" sie ist der Unterschied zwischen einem Prototyp und einem System, das\n",
" nachts ohne Aufsicht laeuft.\n",
" \"\"\"\n",
" name = loeser.StatusName(status)\n",
" hat_loesung = status in (cp_model.OPTIMAL, cp_model.FEASIBLE)\n",
"\n",
" zielwert = schranke = gap = None\n",
" if hat_loesung and mit_zielfunktion:\n",
" zielwert = loeser.ObjectiveValue()\n",
" schranke = loeser.BestObjectiveBound()\n",
" nenner = max(abs(zielwert), 1e-9)\n",
" gap = abs(zielwert - schranke) / nenner\n",
"\n",
" if status == cp_model.OPTIMAL:\n",
" empfehlung = \"Ausfuehren. Besser geht es nachweislich nicht.\"\n",
" elif status == cp_model.FEASIBLE:\n",
" empfehlung = (f\"Brauchbar. Der Plan ist hoechstens {gap * 100:.2f} % \"\n",
" f\"schlechter als das theoretisch Bestmoegliche.\"\n",
" if gap is not None else\n",
" \"Brauchbar, aber nicht als optimal bewiesen.\")\n",
" elif status == cp_model.INFEASIBLE:\n",
" empfehlung = (\"KEIN Plan existiert. Das ist eine Aussage ueber Ihre \"\n",
" \"harten Regeln, nicht ueber die Rechenzeit: Auch \"\n",
" \"unendlich viel Zeit wuerde nichts aendern. Regeln \"\n",
" \"lockern oder weich machen (Anhang C).\")\n",
" elif status == cp_model.MODEL_INVALID:\n",
" empfehlung = (\"Das MODELL ist fehlerhaft, nicht das Problem. \"\n",
" \"Typisch: eine Variable mit leerem Wertebereich. \"\n",
" \"model.Validate() nennt die Ursache.\")\n",
" else: # UNKNOWN\n",
" empfehlung = (\"Nichts gefunden - aber auch nicht widerlegt. Mehr Zeit \"\n",
" \"geben, das Modell vereinfachen oder mit einer \"\n",
" \"Heuristik vorbelegen (AddHint).\")\n",
"\n",
" return Auswertung(\n",
" status=name,\n",
" hat_loesung=hat_loesung,\n",
" beweisbar_optimal=status == cp_model.OPTIMAL,\n",
" zielwert=zielwert, schranke=schranke, gap=gap,\n",
" empfehlung=empfehlung,\n",
" )\n",
"\n",
"\n",
"def loese(baue, zeitlimit: float, mit_zielfunktion: bool) -> Auswertung:\n",
" modell = cp_model.CpModel()\n",
" baue(modell)\n",
" loeser = cp_model.CpSolver()\n",
" loeser.parameters.max_time_in_seconds = zeitlimit\n",
" # Fuer reproduzierbare Buchausgaben; im Betrieb weglassen.\n",
" loeser.parameters.num_workers = 1\n",
" loeser.parameters.random_seed = 1\n",
" status = loeser.Solve(modell)\n",
" return werte_aus(loeser, status, mit_zielfunktion)\n",
"\n",
"\n",
"# --- Die fuenf Faelle --------------------------------------------------------\n",
"\n",
"def dienstplan_loesbar(modell: cp_model.CpModel) -> None:\n",
" \"\"\"Vier Personen, fuenf Dienste, hoechstens zwei je Person - loesbar.\"\"\"\n",
" personen, dienste = 4, 5\n",
" x = [[modell.NewBoolVar(f\"x_{i}_{j}\") for j in range(dienste)]\n",
" for i in range(personen)]\n",
" for j in range(dienste):\n",
" modell.AddExactlyOne(x[i][j] for i in range(personen))\n",
" for i in range(personen):\n",
" modell.Add(sum(x[i]) <= 2)\n",
"\n",
"\n",
"def dienstplan_unloesbar(modell: cp_model.CpModel) -> None:\n",
" \"\"\"Dieselben fuenf Dienste, aber hoechstens EIN Dienst je Person.\n",
"\n",
" Vier Personen koennen zusammen hoechstens vier Dienste uebernehmen - fuer\n",
" fuenf Dienste reicht das nicht. Kein Solver der Welt findet hier etwas.\n",
" \"\"\"\n",
" personen, dienste = 4, 5\n",
" x = [[modell.NewBoolVar(f\"x_{i}_{j}\") for j in range(dienste)]\n",
" for i in range(personen)]\n",
" for j in range(dienste):\n",
" modell.AddExactlyOne(x[i][j] for i in range(personen))\n",
" for i in range(personen):\n",
" modell.Add(sum(x[i]) <= 1)\n",
"\n",
"\n",
"def modell_fehlerhaft(modell: cp_model.CpModel) -> None:\n",
" \"\"\"Eine Variable mit leerem Wertebereich: untere Schranke > obere.\n",
"\n",
" Im Alltag entsteht das durch einen Rechenfehler in den Grenzen, etwa\n",
" NewIntVar(mindestbesetzung, kapazitaet, ...) bei falsch sortierten Daten.\n",
" \"\"\"\n",
" kaputt = modell.NewIntVar(5, 2, \"leerer_bereich\")\n",
" modell.Add(kaputt >= 0)\n",
"\n",
"\n",
"def lastverteilung(modell: cp_model.CpModel) -> None:\n",
" \"\"\"120 Auftraege auf 11 Maschinen - gross genug, dass der Optimalitaets-\n",
" beweis laenger dauert als das Finden einer sehr guten Loesung.\"\"\"\n",
" rng = np.random.default_rng(3)\n",
" dauer = rng.integers(100, 9000, 120)\n",
" n, maschinen = len(dauer), 11\n",
" obergrenze = int(dauer.sum())\n",
"\n",
" x = [[modell.NewBoolVar(f\"x_{i}_{k}\") for k in range(maschinen)]\n",
" for i in range(n)]\n",
" for i in range(n):\n",
" modell.AddExactlyOne(x[i])\n",
" belegung = [modell.NewIntVar(0, obergrenze, f\"l_{k}\") for k in range(maschinen)]\n",
" for k in range(maschinen):\n",
" modell.Add(belegung[k] == sum(int(dauer[i]) * x[i][k] for i in range(n)))\n",
" makespan = modell.NewIntVar(0, obergrenze, \"makespan\")\n",
" modell.AddMaxEquality(makespan, belegung)\n",
" modell.Minimize(makespan)\n",
"\n",
"\n",
"def zahlpartition(modell: cp_model.CpModel) -> None:\n",
" \"\"\"46 grosse Zahlen exakt in zwei gleich schwere Haelften teilen.\n",
"\n",
" Ein beruehmt schweres Problem: Es gibt keine Zielfunktion, entweder es\n",
" geht auf oder nicht - und beides zu entscheiden dauert lange.\n",
" \"\"\"\n",
" rng = np.random.default_rng(1)\n",
" gewichte = rng.integers(10**6, 2 * 10**6, 46)\n",
" x = [modell.NewBoolVar(f\"x_{i}\") for i in range(len(gewichte))]\n",
" modell.Add(sum(int(gewichte[i]) * x[i] for i in range(len(gewichte)))\n",
" == int(gewichte.sum() // 2))\n",
"\n",
"\n",
"def zeige(titel: str, a: Auswertung) -> None:\n",
" print(f\"\\n{titel}\")\n",
" print(f\" Status {a.status}\")\n",
" print(f\" Loesung vorhanden {'ja' if a.hat_loesung else 'nein'}\")\n",
" if a.zielwert is not None:\n",
" print(f\" Zielwert / Schranke {a.zielwert:,.0f} / {a.schranke:,.0f}\"\n",
" f\" (Gap {a.gap * 100:.3f} %)\")\n",
" print(f\" -> {a.empfehlung}\")\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 78)\n",
" print(\" DIE FUENF ANTWORTEN VON CP-SAT\")\n",
" print(\"=\" * 78)\n",
"\n",
" zeige(\"[1] Dienstplan, hoechstens 2 Dienste je Person\",\n",
" loese(dienstplan_loesbar, 10.0, mit_zielfunktion=False))\n",
"\n",
" zeige(\"[2] Lastverteilung 120 Auftraege / 11 Maschinen, 0,5 s Limit\",\n",
" loese(lastverteilung, 0.5, mit_zielfunktion=True))\n",
"\n",
" zeige(\"[3] Derselbe Fall mit 10 s Limit\",\n",
" loese(lastverteilung, 10.0, mit_zielfunktion=True))\n",
"\n",
" zeige(\"[4] Dienstplan, hoechstens 1 Dienst je Person\",\n",
" loese(dienstplan_unloesbar, 10.0, mit_zielfunktion=False))\n",
"\n",
" zeige(\"[5] Variable mit leerem Wertebereich\",\n",
" loese(modell_fehlerhaft, 10.0, mit_zielfunktion=False))\n",
"\n",
" zeige(\"[6] Zahlpartition mit 46 grossen Zahlen, 2 s Limit\",\n",
" loese(zahlpartition, 2.0, mit_zielfunktion=False))\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" DIE DREI, DIE MAN NICHT VERWECHSELN DARF\")\n",
" print(\"=\" * 78)\n",
" print(\"INFEASIBLE Es gibt keine Loesung. Eine Aussage ueber Ihr MODELL.\")\n",
" print(\" Mehr Rechenzeit aendert daran nichts.\")\n",
" print(\"UNKNOWN Es wurde keine gefunden. Eine Aussage ueber die ZEIT.\")\n",
" print(\" Ob es eine gibt, ist offen.\")\n",
" print(\"MODEL_INVALID Das Modell ist gar kein gueltiges Modell. Eine\")\n",
" print(\" Aussage ueber Ihren CODE - immer ein Programmierfehler.\")\n",
" print()\n",
" print(\"Wer diese drei in einem 'else: return None' zusammenfasst, verliert\")\n",
" print(\"genau die Information, die zur Fehlersuche noetig waere.\")\n",
" print(\"=\" * 78)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Finde den Denkfehler\n",
"\n",
"`Strafgewichte.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Strafgewichte.py\n",
"\"\"\"\n",
"Kapitel CP-SAT: Warum ein Strafgewicht keine Wichtigkeit ausdrueckt, sondern\n",
"einen Wechselkurs.\n",
"\n",
"Weiche Regeln kommen als Strafterm in die Zielfunktion. Die Frage, die dabei\n",
"regelmaessig falsch beantwortet wird, lautet: \"Wie gross muss die Strafe sein?\"\n",
"Die uebliche Antwort - \"je wichtiger, desto groesser\" - klingt vernuenftig und\n",
"fuehrt zuverlaessig zu Plaenen, die niemand unterschreiben will.\n",
"\n",
"Der Grund: Zwei Strafgewichte legen nicht Wichtigkeiten fest, sondern einen\n",
"UMRECHNUNGSKURS. Steht die Serienregel bei 1 und der Wunschfrei-Tag bei 10,\n",
"dann hat man dem Solver woertlich gesagt: \"Zehn Verstoesse gegen die\n",
"Serienregel sind so schlimm wie ein abgelehnter Wunsch.\" Er nimmt das\n",
"ernst - und handelt danach.\n",
"\n",
"Szenario: Schichtplan, 6 Personen, 14 Tage, 4 Besetzungen pro Tag.\n",
" HART jeder Tag genau 4 Personen; hoechstens 11 Dienste je Person\n",
" WEICH keine drei Dienste in Folge (Betriebsvereinbarung)\n",
" WEICH Wunschfrei-Tage einhalten (26 Antraege)\n",
"\n",
"Benoetigt: numpy, ortools\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import numpy as np\n",
"from ortools.sat.python import cp_model\n",
"\n",
"PERSONEN, TAGE, PRO_TAG = 6, 14, 4\n",
"MAX_DIENSTE = 11\n",
"STRAFE_WUNSCH = 10 # bleibt fest - variiert wird die Serienstrafe\n",
"\n",
"RNG = np.random.default_rng(2)\n",
"WUNSCHFREI = sorted({(p, t) for p in range(PERSONEN) for t in range(TAGE)\n",
" if RNG.random() < 0.35})\n",
"\n",
"\n",
"def plane(strafe_serie: int, zeitlimit: float = 25.0) -> dict:\n",
" \"\"\"Baut den Schichtplan mit dem angegebenen Gewicht fuer die Serienregel.\"\"\"\n",
" modell = cp_model.CpModel()\n",
" x = {(p, t): modell.NewBoolVar(f\"x_{p}_{t}\")\n",
" for p in range(PERSONEN) for t in range(TAGE)}\n",
"\n",
" # --- harte Regeln --------------------------------------------------\n",
" for t in range(TAGE):\n",
" modell.Add(sum(x[p, t] for p in range(PERSONEN)) == PRO_TAG)\n",
" for p in range(PERSONEN):\n",
" modell.Add(sum(x[p, t] for t in range(TAGE)) <= MAX_DIENSTE)\n",
"\n",
" # --- weiche Regel 1: keine drei Dienste in Folge --------------------\n",
" # serie[p, t] = 1 <=> Person p arbeitet an t, t+1 UND t+2.\n",
" # Die Ungleichung erzwingt das nur in eine Richtung; das genuegt, weil\n",
" # der Solver serie minimiert - er wuerde die Variable nie freiwillig\n",
" # auf 1 setzen.\n",
" strafterme = []\n",
" serien = []\n",
" for p in range(PERSONEN):\n",
" for t in range(TAGE - 2):\n",
" serie = modell.NewBoolVar(f\"serie_{p}_{t}\")\n",
" modell.Add(x[p, t] + x[p, t + 1] + x[p, t + 2] <= 2 + serie)\n",
" serien.append(serie)\n",
" strafterme.append(serie * strafe_serie)\n",
"\n",
" # --- weiche Regel 2: Wunschfrei ------------------------------------\n",
" wunschverstoesse = []\n",
" for p, t in WUNSCHFREI:\n",
" wunschverstoesse.append(x[p, t])\n",
" strafterme.append(x[p, t] * STRAFE_WUNSCH)\n",
"\n",
" modell.Minimize(sum(strafterme))\n",
"\n",
" loeser = cp_model.CpSolver()\n",
" loeser.parameters.max_time_in_seconds = zeitlimit\n",
" loeser.parameters.num_workers = 1 # reproduzierbare Buchausgabe\n",
" loeser.parameters.random_seed = 1\n",
" status = loeser.Solve(modell)\n",
" if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):\n",
" raise RuntimeError(f\"Kein Plan: {loeser.StatusName(status)}\")\n",
"\n",
" return {\n",
" \"status\": loeser.StatusName(status),\n",
" \"zielwert\": loeser.ObjectiveValue(),\n",
" \"serien\": sum(loeser.Value(v) for v in serien),\n",
" \"wunsch_verletzt\": sum(loeser.Value(v) for v in wunschverstoesse),\n",
" }\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 78)\n",
" print(\" STRAFGEWICHTE SIND WECHSELKURSE, KEINE WICHTIGKEITEN\")\n",
" print(\"=\" * 78)\n",
" print(f\"{PERSONEN} Personen, {TAGE} Tage, {PRO_TAG} Besetzungen pro Tag \"\n",
" f\"= {TAGE * PRO_TAG} Dienste\")\n",
" print(f\"Das sind {TAGE * PRO_TAG / PERSONEN:.1f} Dienste je Person - der Plan ist eng.\")\n",
" print(f\"{len(WUNSCHFREI)} Wunschfrei-Antraege, Strafe je abgelehntem Wunsch: \"\n",
" f\"{STRAFE_WUNSCH}\\n\")\n",
"\n",
" print(f\"{'Strafe je 3er-Serie':>20} {'3er-Serien':>12} {'Wunsch verletzt':>17} \"\n",
" f\"{'Zielwert':>10}\")\n",
" print(\"-\" * 78)\n",
" ergebnisse = {}\n",
" for strafe in (1, 2, 5, 10, 30, 100):\n",
" e = plane(strafe)\n",
" ergebnisse[strafe] = e\n",
" print(f\"{strafe:>20} {e['serien']:>12} {e['wunsch_verletzt']:>17} \"\n",
" f\"{e['zielwert']:>10.0f}\")\n",
" print(\"-\" * 78)\n",
"\n",
" niedrig, hoch = ergebnisse[1], ergebnisse[30]\n",
" print(f\"\\nBei Strafe 1 baut der Solver {niedrig['serien']} Drei-Tage-Serien ein.\")\n",
" print(\"Die Betriebsvereinbarung steht im Modell - und wird trotzdem\")\n",
" print(\"systematisch verletzt. Das ist kein Fehler des Solvers: Bei einem\")\n",
" print(f\"Gewicht von 1 gegen {STRAFE_WUNSCH} lohnt sich jede Serie, solange sie\")\n",
" print(\"auch nur einen Zehntel-Wunsch rettet.\")\n",
" print(f\"\\nBei Strafe 30 sind es {hoch['serien']} Serien - dafuer werden\")\n",
" print(f\"{hoch['wunsch_verletzt']} statt {niedrig['wunsch_verletzt']} Wuensche abgelehnt.\")\n",
" print(\"Beide Plaene sind optimal. Sie beantworten nur verschiedene Fragen.\")\n",
" print()\n",
" print(\"Die Frage lautet also nie 'wie wichtig ist mir diese Regel?', sondern:\")\n",
" print(\" 'Wie viele abgelehnte Wuensche bin ich bereit zu akzeptieren,\")\n",
" print(\" um eine Drei-Tage-Serie zu vermeiden?'\")\n",
" print(\"Wer darauf keine Zahl nennen kann, hat das Problem noch nicht\")\n",
" print(\"verstanden - und sollte diese Tabelle dem Auftraggeber vorlegen,\")\n",
" print(\"statt ein Gewicht zu raten.\")\n",
" print()\n",
" print(\"Und die wichtigste Konsequenz: Eine Regel, die NIE gebrochen werden\")\n",
" print(\"darf, gehoert nicht in die Zielfunktion, sondern unter die harten\")\n",
" print(\"Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.\")\n",
" print(\"=\" * 78)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -0,0 +1,365 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 20: Tail-Risiko, CVaR und Transaktionskosten\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Die zwei Schwächen des Markowitz-Modells\n",
"\n",
"`VaR_CVaR_Demo.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# VaR_CVaR_Demo.py\n",
"\"\"\"\n",
"Kapitel CVaR: Fat Tails, VaR und CVaR anschaulich.\n",
"\n",
"Teil 1: Wie oft treten \"unmoegliche\" Tage wirklich auf?\n",
"Teil 2: Warum ist der VaR nicht subadditiv - ein Gegenbeispiel zum Nachrechnen.\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"from scipy import stats\n",
"\n",
"\n",
"def var_quantil(verluste: np.ndarray, alpha: float = 0.95) -> float:\n",
" \"\"\"VaR = Quantil der Verlustverteilung (Verluste positiv, Gewinne negativ).\"\"\"\n",
" return float(np.quantile(verluste, alpha))\n",
"\n",
"\n",
"def cvar_rockafellar(verluste: np.ndarray, alpha: float = 0.95) -> float:\n",
" \"\"\"\n",
" CVaR ueber die Rockafellar-Uryasev-Formel:\n",
" CVaR = min_gamma { gamma + 1/(1-alpha) * E[max(Verlust - gamma, 0)] }\n",
"\n",
" WICHTIG: Der naheliegende Weg \"Mittelwert aller Werte >= VaR\" ist FALSCH,\n",
" sobald die Verteilung Atome hat (z. B. genau zwei moegliche Verluste).\n",
" Dann liegt der VaR selbst auf einem Atom, und der Vergleich '>=' erfasst\n",
" zu viel Wahrscheinlichkeitsmasse. Die Formel unten behandelt das korrekt -\n",
" und ist zugleich genau der Ausdruck, den wir im Abschnitt 'Value at Risk\n",
" und Conditional Value at Risk' optimieren.\n",
" \"\"\"\n",
" kandidaten = np.unique(verluste) # Optimum liegt immer auf einem Datenpunkt\n",
" return float(min(g + np.mean(np.maximum(verluste - g, 0.0)) / (1.0 - alpha)\n",
" for g in kandidaten))\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" rng = np.random.default_rng(2026)\n",
"\n",
" # --- Teil 1: Fat Tails (analytisch, nicht simuliert) ------------------\n",
" print(\"=\" * 88)\n",
" print(\" TEIL 1: WIE OFT TRITT DAS 'UNMOEGLICHE' EIN?\")\n",
" print(\"=\" * 88)\n",
" print(\"Vergleich: Normalverteilung gegen t-Verteilung mit 3 Freiheitsgraden\")\n",
" print(\"(beide auf Standardabweichung 1 normiert).\\n\")\n",
"\n",
" t_verteilung = stats.t(df=3)\n",
" skalierung = t_verteilung.std() # auf Varianz 1 bringen\n",
"\n",
" print(f\"{'Ereignis':<22} {'Normal':>14} {'t (df=3)':>14} {'Faktor':>11} \"\n",
" f\"{'Normal: 1 Tag in':>18}\")\n",
" print(\"-\" * 88)\n",
" for k in [3, 4, 5, 6]:\n",
" p_normal = 2 * stats.norm.sf(k) # beidseitig\n",
" p_t = 2 * t_verteilung.sf(k * skalierung)\n",
" print(f\"Abweichung > {k} Sigma {p_normal*100:>13.6f} % {p_t*100:>13.6f} % \"\n",
" f\"{p_t/p_normal:>10.1f}x {1/p_normal/252:>15,.0f} Jahre\")\n",
"\n",
" print(\"\\nDeutung: Ein 5-Sigma-Tag ist unter Normalverteilung ein Ereignis von\")\n",
" print(\"etwa einmal in 6.900 Jahren. Reale Aktienmaerkte liefern solche Tage\")\n",
" print(\"mehrfach pro Jahrzehnt. Wer allein mit Varianz steuert, plant fuer\")\n",
" print(\"eine Welt, in der Crashs praktisch nicht vorkommen.\")\n",
"\n",
" # --- Teil 2: VaR ist nicht subadditiv ---------------------------------\n",
" print(\"\\n\" + \"=\" * 88)\n",
" print(\" TEIL 2: WARUM DER VaR KEIN KOHAERENTES RISIKOMASS IST\")\n",
" print(\"=\" * 88)\n",
" print(\"Zwei unabhaengige Anleihen, je 100 EUR Nominal.\")\n",
" print(\"Jede faellt mit 4 % Wahrscheinlichkeit aus (Verlust 100),\")\n",
" print(\"sonst zahlt sie 2 EUR Kupon (Verlust -2).\\n\")\n",
"\n",
" ziehungen = 2_000_000\n",
" verlust_a = np.where(rng.random(ziehungen) < 0.04, 100.0, -2.0)\n",
" verlust_b = np.where(rng.random(ziehungen) < 0.04, 100.0, -2.0)\n",
" verlust_ab = verlust_a + verlust_b\n",
"\n",
" print(f\"{'':<28} {'VaR 95%':>12} {'CVaR 95%':>12}\")\n",
" print(\"-\" * 88)\n",
" werte = {}\n",
" for name, v in [(\"Anleihe A allein\", verlust_a),\n",
" (\"Anleihe B allein\", verlust_b),\n",
" (\"Portfolio A+B\", verlust_ab)]:\n",
" werte[name] = (var_quantil(v), cvar_rockafellar(v))\n",
" print(f\"{name:<28} {werte[name][0]:>12.2f} {werte[name][1]:>12.2f}\")\n",
"\n",
" var_summe = werte[\"Anleihe A allein\"][0] + werte[\"Anleihe B allein\"][0]\n",
" cvar_summe = werte[\"Anleihe A allein\"][1] + werte[\"Anleihe B allein\"][1]\n",
" print(f\"{'Summe der Einzelwerte':<28} {var_summe:>12.2f} {cvar_summe:>12.2f}\")\n",
"\n",
" var_port, cvar_port = werte[\"Portfolio A+B\"]\n",
" print(\"-\" * 88)\n",
" print(f\"VaR: Portfolio {var_port:7.2f} vs. Summe {var_summe:7.2f} -> \"\n",
" f\"{'VERLETZT die Subadditivitaet!' if var_port > var_summe else 'subadditiv'}\")\n",
" print(f\"CVaR: Portfolio {cvar_port:7.2f} vs. Summe {cvar_summe:7.2f} -> \"\n",
" f\"{'subadditiv (kohaerent)' if cvar_port <= cvar_summe + 1e-6 else 'verletzt'}\")\n",
"\n",
" print(\"\\nDeutung: Einzeln betrachtet meldet der VaR fuer jede Anleihe einen\")\n",
" print(\"GEWINN von 2 EUR - denn mit 96 % Wahrscheinlichkeit passiert nichts,\")\n",
" print(\"und 4 % liegen unterhalb der 5-%-Schwelle. Im Portfolio steigt die\")\n",
" print(\"Wahrscheinlichkeit mindestens eines Ausfalls auf 7,8 % und damit UEBER\")\n",
" print(\"die Schwelle - der VaR springt auf 98. Er behauptet also, Streuung\")\n",
" print(\"habe das Risiko erhoeht. Das ist oekonomisch unsinnig und der Grund,\")\n",
" print(\"warum die Bankenaufsicht mit Basel III auf den Expected Shortfall\")\n",
" print(\"umgestellt hat.\")\n",
" print(\"=\" * 88)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Implementierung: CVaR-Portfolio mit Reibung\n",
"\n",
"`CVaR_Portfolio.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# CVaR_Portfolio.py\n",
"\"\"\"\n",
"Kapitel CVaR: CVaR-Optimierung mit L1-Transaktionskosten via CVXPY.\n",
"\n",
"Eigenschaften:\n",
" * Spaltenreihenfolge erzwungen\n",
" * Einheiten konsistent (alles taeglich, Annualisierung nur in der Ausgabe)\n",
" * Szenario-Nebenbedingungen VEKTORISIERT statt in einer Python-Schleife\n",
" (eine Matrixbedingung statt S einzelner Constraints - deutlich schneller)\n",
" * Vergleich CVaR- gegen Varianz-Optimierung\n",
" * Nachrechnung von VaR/CVaR aus den realisierten Szenarien\n",
"\"\"\"\n",
"\n",
"import time\n",
"\n",
"import cvxpy as cp\n",
"import numpy as np\n",
"import pandas as pd\n",
"import yfinance as yf\n",
"\n",
"TICKER = [\"AAPL\", \"MSFT\", \"NVDA\", \"AMZN\", \"JNJ\", \"PFE\", \"JPM\", \"GS\", \"XOM\", \"CVX\"]\n",
"ALPHA = 0.95 # Konfidenzniveau: schlechteste 5 % der Tage\n",
"MAX_GEWICHT = 0.25\n",
"GEBUEHRENSATZ = 0.002 # 0,2 % je Einheit Turnover (Spread + Brokerage)\n",
"RISIKOAVERSION = 1.5 # bezogen auf TAEGLICHE Groessen\n",
"HANDELSTAGE = 252\n",
"\n",
"\n",
"def lade_renditen():\n",
" ende = pd.Timestamp.today().normalize()\n",
" start = ende - pd.DateOffset(years=2)\n",
" roh = yf.download(TICKER, start=start, end=ende, auto_adjust=True, progress=False)\n",
" if roh.empty:\n",
" raise SystemExit(\"Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).\")\n",
"\n",
" if isinstance(roh.columns, pd.MultiIndex):\n",
" kurse = roh[\"Close\"][TICKER].dropna() # erzwingt eigene Spaltenreihenfolge\n",
" else:\n",
" kurse = roh[[\"Close\"]].dropna()\n",
" kurse.columns = TICKER\n",
" assert list(kurse.columns) == TICKER, \"Spaltenreihenfolge weicht ab!\"\n",
" return kurse.pct_change().dropna()\n",
"\n",
"\n",
"def optimiere_cvar(R, w_alt, vektorisiert=True):\n",
" \"\"\"\n",
" Maximiere: taegliche Rendite - lambda * CVaR - Transaktionskosten\n",
" Alle Groessen TAEGLICH.\n",
" \"\"\"\n",
" S, N = R.shape\n",
" mu_taeglich = R.mean(axis=0)\n",
"\n",
" w = cp.Variable(N, nonneg=True)\n",
" gamma = cp.Variable() # wird im Optimum zum VaR\n",
" u = cp.Variable(S, nonneg=True) # Ueberschuss ueber die Schwelle\n",
"\n",
" cvar = gamma + (1.0 / (S * (1.0 - ALPHA))) * cp.sum(u)\n",
" turnover = cp.norm1(w - w_alt)\n",
" kosten = GEBUEHRENSATZ * turnover\n",
"\n",
" ziel = cp.Maximize(mu_taeglich @ w - RISIKOAVERSION * cvar - kosten)\n",
"\n",
" bedingungen = [cp.sum(w) == 1, w <= MAX_GEWICHT]\n",
" if vektorisiert:\n",
" # EINE Matrixbedingung statt S einzelner - deutlich schneller\n",
" bedingungen.append(u >= -(R @ w) - gamma)\n",
" else:\n",
" for s in range(S): # Alternative: S einzelne Constraints (langsamer)\n",
" bedingungen.append(u[s] >= -R[s] @ w - gamma)\n",
"\n",
" problem = cp.Problem(ziel, bedingungen)\n",
" problem.solve()\n",
" if problem.status not in (\"optimal\", \"optimal_inaccurate\"):\n",
" raise SystemExit(f\"CVaR-Problem nicht loesbar: {problem.status}\")\n",
" return w.value, float(gamma.value), float(cvar.value), float(turnover.value)\n",
"\n",
"\n",
"def optimiere_varianz(R, w_alt):\n",
" \"\"\"Klassisches Mean-Variance zum Vergleich - ebenfalls taeglich gerechnet.\n",
"\n",
" ACHTUNG, DCP-Falle: Die Standardabweichung ist hier NICHT als\n",
" cp.sqrt(cp.quad_form(w, sigma)) formulierbar. cp.sqrt ist konkav und\n",
" verlangt ein konkaves Argument; quad_form ist konvex - CVXPY lehnt den\n",
" Ausdruck mit einem DCPError ab, und zwar voellig zu Recht (Anhang\n",
" Fehlerdiagnose). cp.psd_wrap() hilft dagegen nicht: Es behebt eine\n",
" NUMERISCHE Beanstandung an sigma, keine Regelverletzung im Aufbau.\n",
"\n",
" Der Standardweg ist die Cholesky-Zerlegung sigma = L L^T. Damit gilt\n",
" w' sigma w = ||L^T w||^2, also ist die Standardabweichung die 2-Norm\n",
" eines AFFINEN Ausdrucks - konvex und damit regelkonform.\n",
" \"\"\"\n",
" N = R.shape[1]\n",
" mu_taeglich = R.mean(axis=0)\n",
" sigma = np.cov(R, rowvar=False, ddof=1)\n",
" # Der winzige Diagonalzuschlag faengt den Fall ab, dass sigma numerisch\n",
" # nur halbdefinit ist (mehr Titel als Handelstage, doppelte Spalten).\n",
" L = np.linalg.cholesky(sigma + 1e-12 * np.eye(N))\n",
"\n",
" w = cp.Variable(N, nonneg=True)\n",
" ziel = cp.Maximize(mu_taeglich @ w\n",
" - RISIKOAVERSION * cp.norm2(L.T @ w)\n",
" - GEBUEHRENSATZ * cp.norm1(w - w_alt))\n",
" problem = cp.Problem(ziel, [cp.sum(w) == 1, w <= MAX_GEWICHT])\n",
" problem.solve()\n",
" if problem.status not in (\"optimal\", \"optimal_inaccurate\"):\n",
" raise SystemExit(f\"Varianz-Problem nicht loesbar: {problem.status}\")\n",
" return w.value\n",
"\n",
"\n",
"def realisierte_kennzahlen(w, R):\n",
" \"\"\"VaR und CVaR direkt aus den Szenarien - unabhaengige Gegenprobe.\"\"\"\n",
" portfoliorenditen = R @ w\n",
" verluste = -portfoliorenditen\n",
" var = float(np.quantile(verluste, ALPHA))\n",
" cvar = float(verluste[verluste >= var].mean())\n",
" return var, cvar, float(portfoliorenditen.mean()), float(portfoliorenditen.std(ddof=1))\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" renditen = lade_renditen()\n",
" R = renditen.values\n",
" S, N = R.shape\n",
" w_alt = np.ones(N) / N # Ausgangslage: Gleichgewichtung\n",
"\n",
" t0 = time.perf_counter()\n",
" w_cvar, var_modell, cvar_modell, turnover = optimiere_cvar(R, w_alt, vektorisiert=True)\n",
" dauer_vektor = time.perf_counter() - t0\n",
"\n",
" w_var = optimiere_varianz(R, w_alt)\n",
"\n",
" print(\"=\" * 90)\n",
" print(\" CVaR-PORTFOLIO-OPTIMIERUNG MIT TRANSAKTIONSKOSTEN\")\n",
" print(\"=\" * 90)\n",
" print(f\"Datenbasis: {S} Handelstage, {N} Titel | Konfidenzniveau \"\n",
" f\"{ALPHA*100:.0f} % | Loesungszeit {dauer_vektor:.2f} s\\n\")\n",
"\n",
" # --- Gegenprobe: Modellwerte gegen realisierte Szenariowerte ---------\n",
" var_real, cvar_real, mu_real, sd_real = realisierte_kennzahlen(w_cvar, R)\n",
" print(\"--- Gegenprobe: stimmen Modell und Szenarien ueberein? ---\")\n",
" print(f\" VaR aus dem Modell (gamma): {var_modell*100:7.4f} % | \"\n",
" f\"aus den Szenarien: {var_real*100:7.4f} %\")\n",
" print(f\" CVaR aus dem Modell: {cvar_modell*100:7.4f} % | \"\n",
" f\"aus den Szenarien: {cvar_real*100:7.4f} %\")\n",
" assert abs(cvar_modell - cvar_real) < 1e-4, \"CVaR stimmt nicht mit den Szenarien!\"\n",
" print(\" -> Der Rockafellar-Uryasev-Trick liefert exakt den empirischen CVaR.\")\n",
"\n",
" # --- Kennzahlen beider Portfolios ------------------------------------\n",
" print(f\"\\n{'Portfolio':<24} {'Rendite p.a.':>13} {'Vola p.a.':>11} \"\n",
" f\"{'VaR 95% (Tag)':>15} {'CVaR 95% (Tag)':>16} {'Turnover':>10}\")\n",
" print(\"-\" * 90)\n",
" for name, w in [(\"CVaR-optimiert\", w_cvar), (\"Varianz-optimiert\", w_var),\n",
" (\"Gleichgewichtung\", w_alt)]:\n",
" v, c, m, s = realisierte_kennzahlen(w, R)\n",
" to = float(np.abs(w - w_alt).sum())\n",
" print(f\"{name:<24} {m*HANDELSTAGE*100:>12.2f} % \"\n",
" f\"{s*np.sqrt(HANDELSTAGE)*100:>10.2f} % {v*100:>14.3f} % \"\n",
" f\"{c*100:>15.3f} % {to*100:>9.1f} %\")\n",
"\n",
" print(\"\\nHinweis zur Annualisierung: Renditen werden mit 252 skaliert,\")\n",
" print(\"Volatilitaeten mit sqrt(252). Fuer VaR/CVaR ist eine solche Skalierung\")\n",
" print(\"nur unter starken Annahmen (Unabhaengigkeit, kein Drift) zulaessig -\")\n",
" print(\"sie werden hier deshalb bewusst als TAGESwerte ausgewiesen.\")\n",
"\n",
" # --- Allokationstabelle ------------------------------------------------\n",
" print(\"\\n--- Allokation ---\")\n",
" tabelle = pd.DataFrame({\n",
" \"Ticker\": TICKER,\n",
" \"vorher\": [f\"{v*100:5.1f} %\" for v in w_alt],\n",
" \"CVaR-opt.\": [f\"{v*100:5.1f} %\" for v in w_cvar],\n",
" \"Handel\": [f\"{(w_cvar[i]-w_alt[i])*100:+6.1f} %\" for i in range(N)],\n",
" \"Varianz-opt.\": [f\"{v*100:5.1f} %\" for v in w_var],\n",
" })\n",
" print(tabelle.to_string(index=False))\n",
" print(f\"\\nTurnover {turnover*100:.1f} % -> Transaktionskosten \"\n",
" f\"{GEBUEHRENSATZ*turnover*100:.3f} % des Portfoliowerts\")\n",
"\n",
" # --- Laufzeitvergleich vektorisiert vs. Schleife ----------------------\n",
" if S <= 600: # bei sehr vielen Szenarien zu langsam\n",
" t0 = time.perf_counter()\n",
" optimiere_cvar(R, w_alt, vektorisiert=False)\n",
" dauer_schleife = time.perf_counter() - t0\n",
" print(f\"\\n--- Laufzeit: {S} Nebenbedingungen aufbauen ---\")\n",
" print(f\" vektorisiert (u >= -(R @ w) - gamma): {dauer_vektor:6.2f} s\")\n",
" print(f\" Schleife ueber Szenarien (V01): {dauer_schleife:6.2f} s \"\n",
" f\"({dauer_schleife/dauer_vektor:.1f}x langsamer)\")\n",
" print(\"=\" * 90)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -0,0 +1,347 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 10: Spaltengenerierung — das Modell umbauen statt die Lösung raten\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Das Programm\n",
"\n",
"`Spaltengenerierung.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Spaltengenerierung.py\n",
"\"\"\"\n",
"Kapitel Dekomposition: Das Modell umbauen, statt die Loesung zu raten.\n",
"\n",
"Eine Papierfabrik liefert Rollen von 5.600 mm Breite. Die Kunden bestellen\n",
"schmalere Breiten, und aus jeder Mutterrolle werden mehrere davon geschnitten.\n",
"Gesucht ist der Schnittplan, der mit den wenigsten Mutterrollen auskommt.\n",
"\n",
"Der naheliegende Modellansatz - \"welches Stueck kommt auf welche Rolle?\" -\n",
"fuehrt in eine Sackgasse: Er braucht eine Binaervariable je Stueck-und-Rolle,\n",
"und weil alle Rollen gleich sind, ist er hochgradig symmetrisch. Der Solver\n",
"probiert dieselbe Loesung in tausend Umbenennungen durch.\n",
"\n",
"Die Spaltengenerierung dreht die Frage um:\n",
"\n",
" Nicht \"welches Stueck auf welche Rolle\",\n",
" sondern \"wie oft schneide ich welches MUSTER\".\n",
"\n",
"Damit verschwindet die Symmetrie - aber es entsteht ein neues Problem: Die Zahl\n",
"der moeglichen Muster waechst kombinatorisch. Man kann sie nicht aufschreiben.\n",
"Der Trick besteht darin, sie auch nicht aufzuschreiben, sondern nur die wenigen\n",
"zu erzeugen, die tatsaechlich gebraucht werden - und zwar mit Hilfe der\n",
"Schattenpreise aus dem Kapitel LP.\n",
"\n",
"Das Programm zeigt vier Dinge:\n",
"\n",
" 1. Die beiden Formulierungen desselben Problems und ihre Groesse.\n",
" 2. Wie schnell die Musterzahl waechst - und wie wenige davon genuegen.\n",
" 3. Die Schleife selbst: Master-LP, Dualwerte, Rucksack, neue Spalte.\n",
" 4. Wann sich der Aufwand lohnt und wann nicht. Die Antwort haengt an einer\n",
" einzigen Kennzahl der Instanz, und sie erklaert nebenbei, warum eine\n",
" Faustregel bei manchen Zuschnittproblemen schon optimal ist.\n",
"\n",
"Benoetigt: numpy, scipy\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import numpy as np\n",
"from scipy.optimize import linprog\n",
"\n",
"ROLLENBREITE = 5600\n",
"\n",
"# Zwei Instanzen mit demselben Bedarf, aber verschiedenen Breiten. Der\n",
"# Unterschied ist die eine Kennzahl, um die es in Teil 4 geht.\n",
"BEDARF = [22, 25, 12, 14, 18, 18, 20, 10, 12, 14, 16, 18, 20]\n",
"BREIT = [1380, 1520, 1560, 1710, 1820, 1880, 1930, 2000, 2050, 2100, 2140,\n",
" 2150, 2200]\n",
"SCHMAL = [380, 520, 560, 710, 820, 880, 930, 1000, 1050, 1100, 1140,\n",
" 1150, 1200]\n",
"\n",
"\n",
"# --- Die Musterzahl ---------------------------------------------------------\n",
"\n",
"def zaehle_muster(breiten, rollenbreite: int = ROLLENBREITE) -> int:\n",
" \"\"\"Wie viele zulaessige Schnittmuster gibt es ueberhaupt?\n",
"\n",
" Nur zum Zeigen - in einem produktiven Programm wuerde man das nie\n",
" ausrechnen, weil die Zahl bei realistischen Instanzen jede Vorstellung\n",
" sprengt. Genau das ist der Punkt.\n",
" \"\"\"\n",
" speicher: dict[tuple[int, int], int] = {}\n",
"\n",
" def ab(i: int, rest: int) -> int:\n",
" if i == len(breiten):\n",
" return 1\n",
" if (i, rest) in speicher:\n",
" return speicher[(i, rest)]\n",
" summe = sum(ab(i + 1, rest - anzahl * breiten[i])\n",
" for anzahl in range(rest // breiten[i] + 1))\n",
" speicher[(i, rest)] = summe\n",
" return summe\n",
"\n",
" return ab(0, rollenbreite)\n",
"\n",
"\n",
"# --- Die beiden Bausteine der Schleife --------------------------------------\n",
"\n",
"def loese_master(muster, bedarf, ganzzahlig: bool = False):\n",
" \"\"\"Restringiertes Master: Wie oft schneide ich jedes bekannte Muster?\n",
"\n",
" min sum_p x_p unter sum_p a_ip * x_p >= bedarf_i\n",
"\n",
" Als LP gerechnet liefert es zusaetzlich die DUALWERTE - und die sind der\n",
" eigentliche Ertrag: Der Dualwert zu Breite i sagt, was ein zusaetzliches\n",
" Stueck dieser Breite an Rollen kostet. Es ist derselbe Schattenpreis wie\n",
" im Kapitel LP, nur dass er hier nicht berichtet, sondern weiterverarbeitet\n",
" wird.\n",
" \"\"\"\n",
" matrix = -np.array(muster, dtype=float).T # >= wird zu <= mit Minus\n",
" ergebnis = linprog(np.ones(len(muster)),\n",
" A_ub=matrix, b_ub=-np.array(bedarf, dtype=float),\n",
" bounds=(0, None),\n",
" integrality=(1 if ganzzahlig else 0), method=\"highs\")\n",
" if not ergebnis.success:\n",
" raise SystemExit(f\"Master nicht loesbar: {ergebnis.message}\")\n",
" dual = None if ganzzahlig else -ergebnis.ineqlin.marginals\n",
" return ergebnis.fun, ergebnis.x, dual\n",
"\n",
"\n",
"def bestes_neues_muster(dual, breiten, rollenbreite: int = ROLLENBREITE):\n",
" \"\"\"Pricing: Gibt es ein Muster, das sich noch lohnt?\n",
"\n",
" Gesucht ist das Muster mit dem groessten Gesamtwert zu den aktuellen\n",
" Schattenpreisen - unter der Bedingung, dass es in die Rolle passt. Das ist\n",
" ein RUCKSACKPROBLEM (Kapitel MILP, Rucksack.py): Preise sind der Nutzen,\n",
" Breiten das Gewicht, die Rollenbreite die Kapazitaet. Hier mit dynamischer\n",
" Programmierung geloest, weil die Breiten ganzzahlig sind.\n",
"\n",
" Bei Gleichstand wird bewusst der KLEINSTE Index bevorzugt (>-Vergleich mit\n",
" Toleranz statt >=). Sonst haengt das erzeugte Muster von der Reihenfolge\n",
" der Gleitkommaoperationen ab, und das Programm liefert von Lauf zu Lauf\n",
" verschiedene Ausgaben.\n",
" \"\"\"\n",
" wert = np.zeros(rollenbreite + 1)\n",
" herkunft = [-1] * (rollenbreite + 1)\n",
" for platz in range(rollenbreite + 1):\n",
" for i, breite in enumerate(breiten):\n",
" if breite <= platz and wert[platz - breite] + dual[i] > wert[platz] + 1e-9:\n",
" wert[platz] = wert[platz - breite] + dual[i]\n",
" herkunft[platz] = i\n",
"\n",
" muster = [0] * len(breiten)\n",
" platz = rollenbreite\n",
" while herkunft[platz] >= 0:\n",
" i = herkunft[platz]\n",
" muster[i] += 1\n",
" platz -= breiten[i]\n",
" return float(wert[rollenbreite]), muster\n",
"\n",
"\n",
"def spaltengenerierung(breiten, bedarf, rollenbreite: int = ROLLENBREITE):\n",
" \"\"\"Die Schleife. Startbasis: je ein Muster mit nur einer Breite.\n",
"\n",
" Abbruch, wenn kein Muster mehr einen Wert ueber 1 hat: Eine zusaetzliche\n",
" Rolle kostet 1, also lohnt sich ein neues Muster nur, wenn es zu den\n",
" aktuellen Preisen mehr als 1 wert ist. Das ist das Kriterium der\n",
" reduzierten Kosten.\n",
" \"\"\"\n",
" anzahl = len(breiten)\n",
" muster = [[0] * anzahl for _ in range(anzahl)]\n",
" for i in range(anzahl):\n",
" muster[i][i] = rollenbreite // breiten[i]\n",
"\n",
" runden = 0\n",
" while True:\n",
" zielwert, _, dual = loese_master(muster, bedarf)\n",
" wert, neu = bestes_neues_muster(dual, breiten, rollenbreite)\n",
" runden += 1\n",
" if wert <= 1 + 1e-6:\n",
" return muster, zielwert, runden\n",
" muster.append(neu)\n",
"\n",
"\n",
"def first_fit(breiten, bedarf, rollenbreite: int = ROLLENBREITE) -> int:\n",
" \"\"\"Die Faustregel: groesstes Stueck zuerst, auf die erste passende Rolle.\"\"\"\n",
" stuecke = sorted([b for b, menge in zip(breiten, bedarf)\n",
" for _ in range(menge)], reverse=True)\n",
" rollen: list[list[int]] = []\n",
" for stueck in stuecke:\n",
" for rolle in rollen:\n",
" if sum(rolle) + stueck <= rollenbreite:\n",
" rolle.append(stueck)\n",
" break\n",
" else:\n",
" rollen.append([stueck])\n",
" return len(rollen)\n",
"\n",
"\n",
"def auswerten(name: str, breiten, bedarf) -> dict:\n",
" muster, schranke, runden = spaltengenerierung(breiten, bedarf)\n",
" ganz, _, _ = loese_master(muster, bedarf, ganzzahlig=True)\n",
" return {\"name\": name, \"muster_gesamt\": zaehle_muster(breiten),\n",
" \"muster_erzeugt\": len(muster), \"runden\": runden,\n",
" \"schranke\": schranke, \"ganzzahlig\": int(round(ganz)),\n",
" \"first_fit\": first_fit(breiten, bedarf),\n",
" \"stuecke_je_rolle\": ROLLENBREITE / np.mean(breiten)}\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 84)\n",
" print(\" SPALTENGENERIERUNG: NUR DIE MUSTER ERZEUGEN, DIE MAN BRAUCHT\")\n",
" print(\"=\" * 84)\n",
" print(f\"Mutterrolle {ROLLENBREITE:,} mm, {len(BREIT)} bestellte Breiten, \"\n",
" f\"{sum(BEDARF)} Zuschnitte.\\n\")\n",
"\n",
" # --- 1. Zwei Formulierungen ------------------------------------------\n",
" stuecke = sum(BEDARF)\n",
" # Wie viele Rollen braucht man hoechstens? Die Faustregel liefert eine\n",
" # brauchbare Obergrenze - mehr Rollen als das wird niemand benoetigen.\n",
" rollen_obergrenze = first_fit(BREIT, BEDARF)\n",
" print(\"1. Zwei Modelle fuer dieselbe Aufgabe\\n\")\n",
" print(f\" {'Formulierung':<34} {'Binaervariablen':>16} {'symmetrisch?':>16}\")\n",
" print(\" \" + \"-\" * 70)\n",
" print(f\" {'Stueck -> Rolle (naheliegend)':<34} \"\n",
" f\"{stuecke * rollen_obergrenze:>16,} {'ja':>16}\")\n",
" print(f\" {'wie oft welches Muster':<34} \"\n",
" f\"{'eine je Muster':>16} {'nein':>16}\")\n",
" print(f\"\\n Der naheliegende Ansatz braucht eine Variable je Stueck und Rolle:\")\n",
" print(f\" {stuecke} Zuschnitte x {rollen_obergrenze} Rollen (Obergrenze aus der Faustregel)\")\n",
" print(f\" = {stuecke * rollen_obergrenze:,} Binaervariablen.\")\n",
" print(\"\\n Schlimmer als die Zahl ist die Symmetrie: Alle Mutterrollen sind\")\n",
" print(\" gleich, also beschreibt jede Loesung dieselbe Schnittvorschrift in\")\n",
" print(\" unzaehligen Umbenennungen. Branch-and-Bound probiert sie einzeln\")\n",
" print(\" durch und kommt nicht voran - der Suchbaum waechst, ohne dass sich\")\n",
" print(\" die Schranke bewegt.\")\n",
" print(\"\\n Das Mustermodell hat je Muster genau eine Variable und kennt keine\")\n",
" print(\" einzelnen Rollen mehr. Damit ist die Symmetrie weg. Sein Problem ist\")\n",
" print(\" ein anderes - und zwar das folgende.\")\n",
"\n",
" # --- 2. Die Musterzahl ------------------------------------------------\n",
" print(\"\\n\" + \"-\" * 84)\n",
" print(\"2. Warum man die Muster nicht aufschreiben kann\\n\")\n",
" gross = auswerten(\"breite Zuschnitte (1.380-2.200 mm)\", BREIT, BEDARF)\n",
" schmal = auswerten(\"schmale Zuschnitte (380-1.200 mm)\", SCHMAL, BEDARF)\n",
"\n",
" print(f\" {'Instanz':<36} {'Muster':>12} {'davon erzeugt':>14} {'Anteil':>9}\")\n",
" print(\" \" + \"-\" * 76)\n",
" for fall in (gross, schmal):\n",
" anteil = fall[\"muster_erzeugt\"] / fall[\"muster_gesamt\"]\n",
" print(f\" {fall['name']:<36} {fall['muster_gesamt']:>12,} \"\n",
" f\"{fall['muster_erzeugt']:>14} {anteil:>8.1%}\")\n",
"\n",
" print(f\"\\n Schon das Halbieren der Breiten laesst die Musterzahl von \"\n",
" f\"{gross['muster_gesamt']:,} auf\")\n",
" print(f\" {schmal['muster_gesamt']:,} springen - Faktor \"\n",
" f\"{schmal['muster_gesamt'] / gross['muster_gesamt']:.0f}. Bei einer echten \"\n",
" f\"Papierfabrik mit\")\n",
" print(\" vierzig Breiten und Millimeterschritten sind es mehr, als sich\")\n",
" print(\" speichern liesse.\")\n",
" print(f\"\\n Gebraucht werden davon {schmal['muster_erzeugt']} - \"\n",
" f\"{schmal['muster_erzeugt'] / schmal['muster_gesamt']:.2%} der Gesamtzahl.\")\n",
"\n",
" # --- 3. Die Schleife --------------------------------------------------\n",
" print(\"\\n\" + \"-\" * 84)\n",
" print(\"3. Die Schleife an der breiten Instanz\\n\")\n",
" print(f\" Startbasis: {len(BREIT)} triviale Muster (je Rolle nur eine Breite)\")\n",
" print(f\" Runden bis kein Muster mehr lohnt: {gross['runden']}\")\n",
" print(f\" Muster am Ende: {gross['muster_erzeugt']}\")\n",
" print(f\" LP-Schranke: {gross['schranke']:.4f} Rollen\")\n",
" print(f\"\\n Die LP-Schranke ist eine ZUSAGE: Weniger als \"\n",
" f\"{np.ceil(gross['schranke'] - 1e-9):.0f} Rollen sind\")\n",
" print(\" nicht moeglich - unabhaengig davon, wie clever man weiterschneidet.\")\n",
" print(\" Genau diese Aussage fehlt einer Heuristik (Kapitel Metaheuristiken).\")\n",
"\n",
" # --- 4. Wann es sich lohnt --------------------------------------------\n",
" print(\"\\n\" + \"-\" * 84)\n",
" print(\"4. Wann sich der Aufwand lohnt - und wann nicht\\n\")\n",
" print(f\" {'Instanz':<36} {'Faustregel':>11} {'exakt':>8} {'Schranke':>10} \"\n",
" f\"{'Ersparnis':>11}\")\n",
" print(\" \" + \"-\" * 80)\n",
" for fall in (gross, schmal):\n",
" ersparnis = (fall[\"first_fit\"] - fall[\"ganzzahlig\"]) / fall[\"first_fit\"]\n",
" print(f\" {fall['name']:<36} {fall['first_fit']:>11} \"\n",
" f\"{fall['ganzzahlig']:>8} {fall['schranke']:>10.2f} \"\n",
" f\"{ersparnis:>10.0%}\")\n",
"\n",
" print(f\"\\n Bei den breiten Zuschnitten spart die Spaltengenerierung \"\n",
" f\"{gross['first_fit'] - gross['ganzzahlig']} von\")\n",
" print(f\" {gross['first_fit']} Rollen. Bei den schmalen spart sie NICHTS - dort ist die\")\n",
" print(\" Faustregel bereits optimal.\")\n",
" print(f\"\\n Der Unterschied haengt an einer einzigen Kennzahl:\\n\")\n",
" print(f\" {'Instanz':<36} {'Stuecke je Rolle (etwa)':>24}\")\n",
" print(\" \" + \"-\" * 62)\n",
" for fall in (gross, schmal):\n",
" print(f\" {fall['name']:<36} {fall['stuecke_je_rolle']:>24.1f}\")\n",
"\n",
" print(\"\\n Passen nur zwei bis drei Stuecke auf eine Rolle, entscheidet jede\")\n",
" print(\" einzelne Zuordnung viel, und eine kurzsichtige Regel verschenkt\")\n",
" print(\" ganze Rollen. Passen sechs oder mehr darauf, gleichen sich die\")\n",
" print(\" Fehler aus - die Reste sind klein gegen die Rollenbreite, und die\")\n",
" print(\" Faustregel trifft es fast immer.\")\n",
"\n",
" print(\"\\n\" + \"=\" * 84)\n",
" print(\" WAS MAN DARAUS MITNIMMT\")\n",
" print(\"=\" * 84)\n",
" print(\"1. Der Perspektivwechsel ist die eigentliche Arbeit: nicht 'welches\")\n",
" print(\" Stueck wohin', sondern 'wie oft welches Muster'. Damit verschwindet\")\n",
" print(\" die Symmetrie, die das naheliegende Modell unloesbar macht.\")\n",
" print(\"2. Der Preis dafuer ist eine unaufschreibbare Zahl von Variablen. Die\")\n",
" print(\" Spaltengenerierung zahlt ihn nicht, sondern erzeugt nur die wenigen\")\n",
" print(\" Spalten, die die Dualwerte als lohnend ausweisen.\")\n",
" print(\"3. Das Teilproblem ist ein Rucksack - ein Verfahren, das die Leser\")\n",
" print(\" dieses Buchs schon kennen. Neu ist allein die Schleife.\")\n",
" print(\"4. Und die unbequeme Erkenntnis: Ob sich das alles lohnt, entscheidet\")\n",
" print(\" die Instanz, nicht die Methode. Bei sechs Stuecken je Rolle ist die\")\n",
" print(\" Faustregel so gut wie das Optimum - und der Ertrag der\")\n",
" print(\" Spaltengenerierung liegt dann allein in der SCHRANKE, die beweist,\")\n",
" print(\" dass man aufhoeren kann zu suchen.\")\n",
" print(\"=\" * 84)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -0,0 +1,368 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 13: Dynamische Programmierung — Die Bellman-Gleichung und Order-Execution\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Die Bellman-Gleichung\n",
"\n",
"`Bellman_Minimalbeispiel.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Bellman_Minimalbeispiel.py\n",
"\"\"\"\n",
"Kapitel Dynamische Programmierung: Die Handrechnung zur Rueckwaertsinduktion als Code.\n",
"Zeigt die Wertfunktionstabelle und die optimale Politik Schritt fuer Schritt.\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"\n",
"GESAMT = 3 # zu verkaufende Einheiten\n",
"PERIODEN = 2 # Anzahl Verkaufsperioden\n",
"\n",
"\n",
"def kosten(menge: int) -> float:\n",
" \"\"\"Ueberproportionale Marktauswirkung: doppelte Menge kostet vierfach.\"\"\"\n",
" return float(menge ** 2)\n",
"\n",
"\n",
"def loese_rueckwaerts():\n",
" # V[t, x] = minimale Restkosten, wenn zu Beginn von Periode t noch x Stueck offen sind\n",
" V = np.full((PERIODEN + 1, GESAMT + 1), np.inf)\n",
" politik = np.zeros((PERIODEN, GESAMT + 1), dtype=int)\n",
"\n",
" # Endbedingung: nach der letzten Periode darf nichts mehr offen sein\n",
" V[PERIODEN, 0] = 0.0\n",
"\n",
" print(\"=\" * 70)\n",
" print(\" RUECKWAERTSINDUKTION SCHRITT FUER SCHRITT\")\n",
" print(\"=\" * 70)\n",
"\n",
" for t in range(PERIODEN - 1, -1, -1):\n",
" letzte_periode = (t == PERIODEN - 1)\n",
" print(f\"\\nStufe t = {t}\" + (\" (letzte Periode: alles muss weg)\" if letzte_periode\n",
" else \" (freie Wahl der Menge)\"))\n",
" print(f\" {'Zustand x':>10} | {'beste Aktion':>12} | {'Sofortkosten':>13} | \"\n",
" f\"{'V[t+1]':>9} | {'V[t]':>8}\")\n",
" print(\" \" + \"-\" * 62)\n",
"\n",
" for x in range(GESAMT + 1):\n",
" aktionen = [x] if letzte_periode else range(x + 1)\n",
" bester_wert, beste_aktion, beste_teile = np.inf, 0, (0.0, 0.0)\n",
"\n",
" for n in aktionen:\n",
" rest = x - n\n",
" sofort = kosten(n)\n",
" zukunft = V[t + 1, rest]\n",
" gesamt = sofort + zukunft\n",
" if gesamt < bester_wert:\n",
" bester_wert, beste_aktion = gesamt, n\n",
" beste_teile = (sofort, zukunft)\n",
"\n",
" V[t, x] = bester_wert\n",
" politik[t, x] = beste_aktion\n",
" print(f\" {x:>10} | {beste_aktion:>12} | {beste_teile[0]:>13.1f} | \"\n",
" f\"{beste_teile[1]:>9.1f} | {bester_wert:>8.1f}\")\n",
"\n",
" return V, politik\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" V, politik = loese_rueckwaerts()\n",
"\n",
" # --- Vorwaertspfad: der optimalen Politik folgen ---------------------\n",
" print(\"\\n\" + \"=\" * 70)\n",
" print(\" OPTIMALER PFAD (Vorwaertssimulation)\")\n",
" print(\"=\" * 70)\n",
" bestand = GESAMT\n",
" gesamtkosten = 0.0\n",
" for t in range(PERIODEN):\n",
" aktion = politik[t, bestand]\n",
" gesamtkosten += kosten(aktion)\n",
" print(f\" Periode {t}: Bestand {bestand} -> verkaufe {aktion} \"\n",
" f\"(Kosten {kosten(aktion):.1f}) -> Rest {bestand - aktion}\")\n",
" bestand -= aktion\n",
"\n",
" print(f\"\\n Gesamtkosten: {gesamtkosten:.1f} (V[0, {GESAMT}] = {V[0, GESAMT]:.1f})\")\n",
" assert abs(gesamtkosten - V[0, GESAMT]) < 1e-9, \"Pfadkosten != Wertfunktion!\"\n",
"\n",
" # --- Vergleich mit naiven Strategien ---------------------------------\n",
" alles_sofort = kosten(GESAMT)\n",
" print(f\"\\n Zum Vergleich - alles in Periode 0 verkaufen: {alles_sofort:.1f}\")\n",
" print(f\" Ersparnis durch Stueckelung: {alles_sofort - gesamtkosten:.1f} \"\n",
" f\"({(1 - gesamtkosten/alles_sofort)*100:.0f} %)\")\n",
" print(\"=\" * 70)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Das Almgren-Chriss-Problem: optimale Orderausführung\n",
"\n",
"`Mehrperiodige_Order_Execution.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Mehrperiodige_Order_Execution.py\n",
"\"\"\"\n",
"Kapitel Dynamische Programmierung: Dynamische Programmierung fuer optimale Orderausfuehrung\n",
"(Almgren-Chriss-Rahmen, geloest per Rueckwaertsinduktion).\n",
"\n",
"Eigenschaften:\n",
" * Risikoterm sauber hergeleitet ueber den Aktienkurs P0 (Einheiten: EUR),\n",
" ohne undokumentierte Skalierungsfaktoren\n",
" * Vergleich mit der analytischen Almgren-Chriss-Loesung\n",
" * Vergleich mit naiven Strategien (alles sofort / gleichmaessig)\n",
" * Sensitivitaet gegenueber der Risikoaversion\n",
"\"\"\"\n",
"\n",
"import os\n",
"\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib\n",
"matplotlib.use(\"Agg\")\n",
"import matplotlib.pyplot as plt\n",
"\n",
"OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"output\")\n",
"os.makedirs(OUTPUT_DIR, exist_ok=True)\n",
"\n",
"# --- Parameter -------------------------------------------------------------\n",
"GESAMTBESTAND = 100_000 # X_0, zu verkaufende Aktien\n",
"PERIODEN = 5 # T Handelsperioden\n",
"KURS = 50.0 # P_0 in EUR, zur Skalierung des Risikoterms\n",
"ETA = 2.5e-6 # EUR je Stueck^2 (Slippage-Koeffizient)\n",
"VOLA_JAHR = 0.30 # 30 % p.a.\n",
"HANDELSSTUNDEN_JAHR = 252 * 6.5\n",
"RISIKOAVERSION = 1e-5 # 1/EUR; kalibriert, siehe Kommentar unten\n",
"SCHRITTWEITE = 1000 # Diskretisierung des Zustandsraums\n",
"\n",
"# Kalibrierungshinweis: Die dimensionslose Kennzahl des Modells ist\n",
"# kappa_tilde^2 = lambda * sigma_periode^2 * P0^2 / eta\n",
"# Sie entscheidet ueber den Charakter der Loesung:\n",
"# << 1 -> praktisch gleichmaessige Aufteilung (Risiko spielt keine Rolle)\n",
"# ~ 1 -> ausgewogener Kompromiss <- hier: 0.55\n",
"# >> 1 -> fast alles sofort verkaufen\n",
"# Genau diese Interpretierbarkeit geht mit einem undokumentierten\n",
"# Skalierungsfaktor verloren.\n",
"\n",
"VOLA_PERIODE = VOLA_JAHR / np.sqrt(HANDELSSTUNDEN_JAHR)\n",
"\n",
"\n",
"def periodenkosten(verkauf: float, restbestand: float) -> float:\n",
" \"\"\"\n",
" Sofortkosten einer Periode in EUR:\n",
" (1) Marktauswirkung: eta * n^2\n",
" (2) Risiko des Restbestands: lambda/2 * sigma^2 * P0^2 * X^2\n",
" -> P0^2 macht aus \"Stueck^2\" einen EUR^2-Wert; lambda hat damit\n",
" die Einheit 1/EUR und ist interpretierbar.\n",
" \"\"\"\n",
" marktauswirkung = ETA * verkauf ** 2\n",
" wertvarianz = (VOLA_PERIODE ** 2) * (KURS ** 2) * (restbestand ** 2)\n",
" risiko = 0.5 * RISIKOAVERSION * wertvarianz\n",
" return marktauswirkung + risiko\n",
"\n",
"\n",
"def loese_dp():\n",
" \"\"\"Rueckwaertsinduktion ueber den diskretisierten Zustandsraum.\"\"\"\n",
" zustaende = np.arange(0, GESAMTBESTAND + SCHRITTWEITE, SCHRITTWEITE)\n",
" anzahl = len(zustaende)\n",
"\n",
" V = np.full((PERIODEN + 1, anzahl), np.inf)\n",
" politik = np.zeros((PERIODEN, anzahl), dtype=int)\n",
" V[PERIODEN, 0] = 0.0 # am Ende muss alles verkauft sein\n",
"\n",
" for t in range(PERIODEN - 1, -1, -1):\n",
" for idx, bestand in enumerate(zustaende):\n",
" if t == PERIODEN - 1:\n",
" moegliche = [bestand] # letzte Periode: Rest muss weg\n",
" else:\n",
" moegliche = zustaende[zustaende <= bestand]\n",
"\n",
" bester_wert, beste_aktion = np.inf, 0\n",
" for verkauf in moegliche:\n",
" rest = bestand - verkauf\n",
" rest_idx = int(round(rest / SCHRITTWEITE))\n",
" gesamt = periodenkosten(verkauf, rest) + V[t + 1, rest_idx]\n",
" if gesamt < bester_wert:\n",
" bester_wert, beste_aktion = gesamt, int(verkauf)\n",
"\n",
" V[t, idx] = bester_wert\n",
" politik[t, idx] = beste_aktion\n",
"\n",
" return zustaende, V, politik\n",
"\n",
"\n",
"def analytische_loesung():\n",
" \"\"\"\n",
" Geschlossene Almgren-Chriss-Loesung fuer den kontinuierlichen Fall.\n",
" Der optimale Pfad ist X_t = X_0 * sinh(kappa*(T-t)) / sinh(kappa*T)\n",
" mit kappa = arccosh(tilde_kappa^2/2 + 1), tilde_kappa^2 = lambda*sigma^2*P0^2/eta.\n",
" Dient hier als unabhaengige Kontrolle des DP-Ergebnisses.\n",
" \"\"\"\n",
" kappa_tilde_quadrat = (RISIKOAVERSION * (VOLA_PERIODE ** 2) * (KURS ** 2)) / ETA\n",
" kappa = np.arccosh(kappa_tilde_quadrat / 2.0 + 1.0)\n",
" if kappa < 1e-12: # Grenzfall: risikoneutral -> linear\n",
" return np.linspace(GESAMTBESTAND, 0, PERIODEN + 1)\n",
" t = np.arange(PERIODEN + 1)\n",
" return GESAMTBESTAND * np.sinh(kappa * (PERIODEN - t)) / np.sinh(kappa * PERIODEN)\n",
"\n",
"\n",
"def bewerte_pfad(bestaende):\n",
" \"\"\"Gesamtkosten eines beliebigen Bestandspfades.\"\"\"\n",
" summe = 0.0\n",
" for t in range(len(bestaende) - 1):\n",
" verkauf = bestaende[t] - bestaende[t + 1]\n",
" summe += periodenkosten(verkauf, bestaende[t + 1])\n",
" return summe\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" zustaende, V, politik = loese_dp()\n",
"\n",
" # --- Vorwaertspfad der optimalen Politik -----------------------------\n",
" bestand = GESAMTBESTAND\n",
" verlauf = [bestand]\n",
" verkaeufe = []\n",
" for t in range(PERIODEN):\n",
" idx = int(round(bestand / SCHRITTWEITE))\n",
" verkauf = politik[t, idx]\n",
" verkaeufe.append(verkauf)\n",
" bestand -= verkauf\n",
" verlauf.append(bestand)\n",
"\n",
" print(\"=\" * 84)\n",
" print(\" OPTIMALE MEHRPERIODIGE ORDER-EXECUTION (BELLMAN DP)\")\n",
" print(\"=\" * 84)\n",
" print(f\"Gesamtvolumen: {GESAMTBESTAND:,} Stueck zu {KURS:.2f} EUR \"\n",
" f\"= {GESAMTBESTAND*KURS:,.0f} EUR Positionswert\")\n",
" print(f\"Zeithorizont: {PERIODEN} Handelsperioden\")\n",
" print(f\"Volatilitaet: {VOLA_JAHR*100:.0f} % p.a. \"\n",
" f\"= {VOLA_PERIODE*100:.3f} % je Periode\")\n",
" print(f\"Slippage eta: {ETA:.2e} EUR/Stueck^2\")\n",
" print(f\"Risikoaversion: {RISIKOAVERSION:.2e} 1/EUR\")\n",
" print(f\"Erwartete Gesamtreibung: {V[0, -1]:,.2f} EUR \"\n",
" f\"({V[0, -1]/(GESAMTBESTAND*KURS)*10000:.1f} Basispunkte)\\n\")\n",
"\n",
" plan = pd.DataFrame([{\n",
" \"Periode\": f\"t = {t} -> {t+1}\",\n",
" \"Startbestand\": f\"{verlauf[t]:,}\",\n",
" \"Verkauf n_t\": f\"{verkaeufe[t]:,}\",\n",
" \"Restbestand\": f\"{verlauf[t+1]:,}\",\n",
" \"Anteil\": f\"{verkaeufe[t]/GESAMTBESTAND*100:5.1f} %\",\n",
" \"Kosten (EUR)\": f\"{periodenkosten(verkaeufe[t], verlauf[t+1]):,.0f}\",\n",
" } for t in range(PERIODEN)])\n",
" print(plan.to_string(index=False))\n",
"\n",
" # --- Vergleich mit Alternativen und der analytischen Loesung ---------\n",
" sofort = [GESAMTBESTAND] + [0] * PERIODEN\n",
" gleichmaessig = [GESAMTBESTAND * (1 - t / PERIODEN) for t in range(PERIODEN + 1)]\n",
" analytisch = analytische_loesung()\n",
"\n",
" print(\"\\n\" + \"-\" * 84)\n",
" print(f\"{'Strategie':<34} {'Kosten (EUR)':>15} {'Basispunkte':>13} \"\n",
" f\"{'ggue. Optimum':>16}\")\n",
" print(\"-\" * 84)\n",
" optimum = V[0, -1]\n",
" for name, pfad in [(\"DP-Optimum\", verlauf),\n",
" (\"Analytisch (Almgren-Chriss)\", list(analytisch)),\n",
" (\"Gleichmaessig (TWAP)\", gleichmaessig),\n",
" (\"Alles sofort\", sofort)]:\n",
" kosten = bewerte_pfad(pfad)\n",
" bp = kosten / (GESAMTBESTAND * KURS) * 10000\n",
" print(f\"{name:<34} {kosten:>15,.0f} {bp:>12.1f} \"\n",
" f\"{kosten - optimum:>+15,.0f}\")\n",
"\n",
" print(\"-\" * 84)\n",
" abweichung = abs(bewerte_pfad(list(analytisch)) - optimum) / optimum\n",
" print(f\"Abweichung DP zur analytischen Loesung: {abweichung*100:.3f} % \"\n",
" f\"(Diskretisierung: {SCHRITTWEITE} Stueck)\")\n",
"\n",
" # --- Sensitivitaet gegenueber der Risikoaversion ---------------------\n",
" print(\"\\n--- Wie wirkt die Risikoaversion? ---\")\n",
" print(f\"{'lambda':>10} | {'Verkauf in Periode 0':>22} | {'Charakter':<28}\")\n",
" print(\"-\" * 70)\n",
" for lam in [1e-7, 1e-6, 1e-5, 1e-4, 1e-3]:\n",
" globals()[\"RISIKOAVERSION\"] = lam\n",
" _, V_l, pol_l = loese_dp()\n",
" erste = pol_l[0, -1]\n",
" anteil = erste / GESAMTBESTAND * 100\n",
" charakter = (\"nahezu gleichmaessig\" if anteil < 25 else\n",
" \"front-loaded\" if anteil < 60 else \"fast alles sofort\")\n",
" print(f\"{lam:>10.0e} | {erste:>13,} ({anteil:5.1f} %) | {charakter:<28}\")\n",
" globals()[\"RISIKOAVERSION\"] = 1e-5 # zuruecksetzen\n",
"\n",
" # --- Diagramm ---------------------------------------------------------\n",
" plt.figure(figsize=(10, 5.5))\n",
" plt.plot(range(PERIODEN + 1), verlauf, \"o-\", lw=2.5, label=\"DP-Optimum\")\n",
" plt.plot(range(PERIODEN + 1), analytisch, \"s--\", lw=1.8, alpha=0.8,\n",
" label=\"Analytisch (Almgren-Chriss)\")\n",
" plt.plot(range(PERIODEN + 1), gleichmaessig, \"^:\", lw=1.8, alpha=0.8,\n",
" label=\"Gleichmaessig (TWAP)\")\n",
" plt.bar(range(PERIODEN), verkaeufe, alpha=0.25, color=\"orange\", width=0.45,\n",
" label=\"Verkaufstranche $n_t$\")\n",
" plt.title(\"Optimaler Liquidationspfad ueber diskrete Perioden\", fontsize=12)\n",
" plt.xlabel(\"Handelsperiode $t$\")\n",
" plt.ylabel(\"Verbleibender Bestand $X_t$\")\n",
" plt.xticks(range(PERIODEN + 1))\n",
" plt.grid(True, linestyle=\":\", alpha=0.6)\n",
" plt.legend()\n",
" plt.tight_layout()\n",
" ziel = os.path.join(OUTPUT_DIR, \"optimal_execution_dp.png\")\n",
" plt.savefig(ziel, dpi=150)\n",
" print(f\"\\nDiagramm gespeichert unter '{ziel}'\")\n",
" print(\"=\" * 84)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -0,0 +1,484 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 1: Einführung in Operations Research — Vom Ursprung zur mathematischen Entscheidungsfindung\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Warum Ausprobieren scheitert — mit eigener Rechnung\n",
"\n",
"`Brute_Force_Vergleich.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Brute_Force_Vergleich.py\n",
"\"\"\"\n",
"Kapitel Einfuehrung: Warum Ausprobieren scheitert.\n",
"Vergleicht vollständige Enumeration mit einem Constraint-Solver an einem\n",
"Zuordnungsproblem wachsender Größe (n Mitarbeiter auf n Aufgaben).\n",
"\"\"\"\n",
"\n",
"import itertools\n",
"import math\n",
"import time\n",
"\n",
"import numpy as np\n",
"from ortools.sat.python import cp_model\n",
"\n",
"\n",
"def erzeuge_kostenmatrix(n: int, seed: int = 7) -> np.ndarray:\n",
" \"\"\"Zufällige, aber reproduzierbare Kosten: Wer bearbeitet welche Aufgabe wie teuer?\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" return rng.integers(low=10, high=100, size=(n, n))\n",
"\n",
"\n",
"def loese_brute_force(kosten: np.ndarray) -> tuple[float, tuple, int]:\n",
" \"\"\"\n",
" Probiert ALLE n! Zuordnungen durch und behält die beste.\n",
" Rückgabe: (bester Kostenwert, beste Permutation, Anzahl geprüfter Kombinationen)\n",
" \"\"\"\n",
" n = len(kosten)\n",
" bester_wert = math.inf\n",
" beste_zuordnung = None\n",
" geprueft = 0\n",
"\n",
" # itertools.permutations(range(n)) liefert nacheinander jede Reihenfolge\n",
" for zuordnung in itertools.permutations(range(n)):\n",
" # zuordnung[i] = Aufgabe, die Mitarbeiter i übernimmt\n",
" wert = sum(kosten[i][zuordnung[i]] for i in range(n))\n",
" geprueft += 1\n",
" if wert < bester_wert:\n",
" bester_wert = wert\n",
" beste_zuordnung = zuordnung\n",
"\n",
" return bester_wert, beste_zuordnung, geprueft\n",
"\n",
"\n",
"def loese_mit_solver(kosten: np.ndarray) -> tuple[float, tuple]:\n",
" \"\"\"Dasselbe Problem als Constraint-Programm — der Solver probiert NICHT alles durch.\"\"\"\n",
" n = len(kosten)\n",
" modell = cp_model.CpModel()\n",
"\n",
" # x[i][j] = 1 <=> Mitarbeiter i übernimmt Aufgabe j\n",
" x = [[modell.NewBoolVar(f\"x_{i}_{j}\") for j in range(n)] for i in range(n)]\n",
"\n",
" for i in range(n):\n",
" modell.AddExactlyOne(x[i][j] for j in range(n)) # jeder genau eine Aufgabe\n",
" for j in range(n):\n",
" modell.AddExactlyOne(x[i][j] for i in range(n)) # jede Aufgabe genau einmal\n",
"\n",
" modell.Minimize(sum(int(kosten[i][j]) * x[i][j] for i in range(n) for j in range(n)))\n",
"\n",
" loeser = cp_model.CpSolver()\n",
" loeser.parameters.max_time_in_seconds = 30.0\n",
" status = loeser.Solve(modell)\n",
" if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):\n",
" raise RuntimeError(\"Solver fand keine Lösung.\")\n",
"\n",
" zuordnung = tuple(\n",
" next(j for j in range(n) if loeser.Value(x[i][j]) == 1) for i in range(n)\n",
" )\n",
" return loeser.ObjectiveValue(), zuordnung\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 72)\n",
" print(\" VOLLSTÄNDIGES DURCHPROBIEREN vs. OPERATIONS RESEARCH\")\n",
" print(\"=\" * 72)\n",
" print(f\"{'n':>3} | {'Kombinationen':>18} | {'Brute Force':>14} | {'Solver':>10} | gleich?\")\n",
" print(\"-\" * 72)\n",
"\n",
" for n in [4, 6, 8, 9, 10]: # n = 11 dauert bereits ~2 Minuten\n",
" kosten = erzeuge_kostenmatrix(n)\n",
"\n",
" t0 = time.perf_counter()\n",
" wert_bf, zuord_bf, geprueft = loese_brute_force(kosten)\n",
" zeit_bf = time.perf_counter() - t0\n",
"\n",
" t0 = time.perf_counter()\n",
" wert_or, zuord_or = loese_mit_solver(kosten)\n",
" zeit_or = time.perf_counter() - t0\n",
"\n",
" gleich = \"ja\" if abs(wert_bf - wert_or) < 1e-9 else \"NEIN!\"\n",
" print(f\"{n:>3} | {geprueft:>18,} | {zeit_bf:>11.4f} s | {zeit_or:>7.4f} s | {gleich}\")\n",
"\n",
" print(\"-\" * 72)\n",
" # Hochrechnung: Wie lange bräuchte Brute Force bei n = 20?\n",
" kombis_20 = math.factorial(20)\n",
" # Messbasis: Prüfungen pro Sekunde aus dem letzten Lauf schätzen\n",
" pro_sekunde = geprueft / max(zeit_bf, 1e-9)\n",
" jahre = kombis_20 / pro_sekunde / (60 * 60 * 24 * 365.25)\n",
" print(f\"Hochrechnung für n = 20: {kombis_20:,} Kombinationen\")\n",
" print(f\"Bei gemessenen {pro_sekunde:,.0f} Prüfungen/s wären das {jahre:,.0f} Jahre.\")\n",
" print(\"Der Solver löst dieselbe Instanz in Sekundenbruchteilen.\")\n",
" print(\"=\" * 72)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Die Bausteine in der Praxis: eine Vorlage\n",
"\n",
"`Bausteine_Vorlage.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Bausteine_Vorlage.py\n",
"\"\"\"\n",
"Kapitel Einfuehrung: Die Bausteine-Vorlage als ausfuellbares Python-Geruest.\n",
"\n",
"Uebersetzt die Vier-Bausteine-Vorlage aus dem Abschnitt 'Die vier\n",
"universellen Bausteine jedes OR-Problems' direkt in Code: Wer sie\n",
"auf Papier ausgefuellt hat, kann sie fast unveraendert in ein loesbares\n",
"Modell umsetzen. Angewendet auf das Baeckerei-Beispiel der Handrechnung.\n",
"\"\"\"\n",
"\n",
"from dataclasses import dataclass\n",
"from ortools.sat.python import cp_model\n",
"\n",
"\n",
"@dataclass\n",
"class Bausteine:\n",
" \"\"\"Die vier Bausteine jedes OR-Problems - wortwoertlich aus der Vorlage\n",
" im Abschnitt 'Die vier universellen Bausteine' uebernommen.\"\"\"\n",
" variablen: str\n",
" parameter: str\n",
" zielfunktion: str\n",
" harte_bedingungen: str\n",
" weiche_bedingungen: str\n",
"\n",
" def zeige(self) -> None:\n",
" print(\"=\" * 78)\n",
" print(\" AUSGEFUELLTE BAUSTEINE-VORLAGE\")\n",
" print(\"=\" * 78)\n",
" for feld, wert in [\n",
" (\"Entscheidungsvariablen\", self.variablen),\n",
" (\"Parameter\", self.parameter),\n",
" (\"Zielfunktion\", self.zielfunktion),\n",
" (\"Harte Bedingungen\", self.harte_bedingungen),\n",
" (\"Weiche Bedingungen\", self.weiche_bedingungen),\n",
" ]:\n",
" print(f\"{feld:<24}: {wert}\")\n",
" print(\"=\" * 78)\n",
"\n",
"\n",
"# --- Baeckerei-Beispiel aus der Handrechnung ---------------------------------\n",
"VORLAGE = Bausteine(\n",
" variablen=\"x1 = Anzahl Brote, x2 = Anzahl Broetchen-Zehnerpackungen \"\n",
" \"(beide ganzzahlig >= 0)\",\n",
" parameter=\"Deckungsbeitraege (2,50; 3,00) EUR; Mehlbedarf (0,5; 0,6) kg; \"\n",
" \"Ofenzeit (4; 3) min; Kapazitaeten (90 kg, 600 min); Mindestmenge 40 Brote\",\n",
" zielfunktion=\"max 2,5*x1 + 3,0*x2 (Einheit: Euro Deckungsbeitrag)\",\n",
" harte_bedingungen=\"0,5*x1 + 0,6*x2 <= 90 (Mehl); 4*x1 + 3*x2 <= 600 (Ofen); \"\n",
" \"x1 >= 40 (Vertrag)\",\n",
" weiche_bedingungen=\"keine\",\n",
")\n",
"\n",
"\n",
"def loese_baeckerei():\n",
" \"\"\"Baut aus der ausgefuellten Vorlage mechanisch ein CP-SAT-Modell.\"\"\"\n",
" modell = cp_model.CpModel()\n",
" x1 = modell.NewIntVar(40, 1000, \"Brote\") # Vertrag: mindestens 40\n",
" x2 = modell.NewIntVar(0, 1000, \"Broetchen_Zehner\")\n",
"\n",
" modell.Add(5 * x1 + 6 * x2 <= 900) # Mehl, x10 fuer Ganzzahligkeit\n",
" modell.Add(4 * x1 + 3 * x2 <= 600) # Ofenzeit\n",
"\n",
" modell.Maximize(25 * x1 + 30 * x2) # Deckungsbeitrag x10\n",
"\n",
" loeser = cp_model.CpSolver()\n",
" status = loeser.Solve(modell)\n",
" if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):\n",
" raise SystemExit(f\"Keine Loesung: {loeser.StatusName(status)}\")\n",
" return loeser.Value(x1), loeser.Value(x2), loeser.ObjectiveValue() / 10\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" VORLAGE.zeige()\n",
"\n",
" print(\"\\n--- Aus der Vorlage mechanisch abgeleitetes Modell ---\")\n",
" brote, broetchen, gewinn = loese_baeckerei()\n",
" print(f\"Optimale Brote: {brote}\")\n",
" print(f\"Optimale Broetchen-Zehner: {broetchen}\")\n",
" print(f\"Maximaler Deckungsbeitrag: {gewinn:.2f} EUR\")\n",
" print()\n",
" print(\"Genau diese drei Codezeilen (Variablen, Add, Maximize) entstehen\")\n",
" print(\"direkt aus den fuenf Zeilen der Vorlage - das ist der ganze Trick.\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Umsetzung mit Google OR-Tools\n",
"\n",
"`Bot_Allokation.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Bot_Allokation.py\n",
"\"\"\"\n",
"Kapitel Einfuehrung: Erstes Optimierungsmodell mit Google OR-Tools (CP-SAT).\n",
"Problem: Server-Allokation für Trading-Bots.\n",
"\n",
"Modell mit expliziter Nebenbedingung statt versteckter Variablengrenze,\n",
"vollständiger Statusauswertung und Ergebnisprüfung.\n",
"\"\"\"\n",
"\n",
"from ortools.sat.python import cp_model\n",
"\n",
"# --- Parameter (die \"Fakten\" des Problems) ---------------------------------\n",
"GEWINN_A, GEWINN_B = 150, 250 # Euro pro Bot und Tag\n",
"CPU_A, CPU_B = 2, 5 # vCPU-Bedarf je Bot\n",
"RAM_A, RAM_B = 4, 6 # GB Arbeitsspeicher je Bot\n",
"CPU_GESAMT, RAM_GESAMT = 40, 60 # verfügbare Kapazitäten\n",
"MAX_ARBITRAGE = 8 # Marktliquiditätsgrenze\n",
"\n",
"\n",
"def loese_bot_allokation(zeitlimit_s: float = 10.0):\n",
" \"\"\"Baut das Modell, löst es und gibt eine Auswertung aus.\"\"\"\n",
"\n",
" # 1. Modell instanziieren\n",
" modell = cp_model.CpModel()\n",
"\n",
" # 2. Entscheidungsvariablen anlegen\n",
" # NewIntVar(untere_grenze, obere_grenze, name)\n",
" # Die obere Grenze ist bewusst großzügig; die echte Beschränkung\n",
" # formulieren wir unten als Nebenbedingung, damit das Modell die\n",
" # mathematische Formulierung 1:1 abbildet.\n",
" x_a = modell.NewIntVar(0, 100, \"Arbitrage_Bots\")\n",
" x_b = modell.NewIntVar(0, 100, \"Trend_Bots\")\n",
"\n",
" # 3. Nebenbedingungen definieren\n",
" c_cpu = modell.Add(CPU_A * x_a + CPU_B * x_b <= CPU_GESAMT) # vCPU-Limit\n",
" c_ram = modell.Add(RAM_A * x_a + RAM_B * x_b <= RAM_GESAMT) # RAM-Limit\n",
" c_liq = modell.Add(x_a <= MAX_ARBITRAGE) # Marktliquidität\n",
"\n",
" # 4. Zielfunktion: Maximiere den Tagesgewinn\n",
" modell.Maximize(GEWINN_A * x_a + GEWINN_B * x_b)\n",
"\n",
" # 5. Solver konfigurieren und ausführen\n",
" loeser = cp_model.CpSolver()\n",
" loeser.parameters.max_time_in_seconds = zeitlimit_s\n",
" status = loeser.Solve(modell)\n",
"\n",
" # 6. Status auswerten -- IMMER alle Fälle behandeln\n",
" status_text = loeser.StatusName(status)\n",
" if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):\n",
" print(f\"Keine verwertbare Lösung. Solver-Status: {status_text}\")\n",
" if status == cp_model.INFEASIBLE:\n",
" print(\"Das Modell ist unlösbar - die Bedingungen widersprechen sich.\")\n",
" return None\n",
"\n",
" a, b = loeser.Value(x_a), loeser.Value(x_b)\n",
" gewinn = loeser.ObjectiveValue()\n",
"\n",
" # 7. Ergebnis ausgeben\n",
" print(\"=\" * 58)\n",
" print(\" OPTIMALE BOT-ALLOKATION\")\n",
" print(\"=\" * 58)\n",
" print(f\"Solver-Status: {status_text}\"\n",
" f\"{' (beweisbar optimal)' if status == cp_model.OPTIMAL else ' (zulässig, nicht bewiesen)'}\")\n",
" print(f\"Arbitrage-Bots (x_A): {a} Instanzen\")\n",
" print(f\"Trendfolge-Bots (x_B): {b} Instanzen\")\n",
" print(f\"Täglicher Max-Gewinn: {gewinn:,.2f} EUR\")\n",
"\n",
" genutzt_cpu = CPU_A * a + CPU_B * b\n",
" genutzt_ram = RAM_A * a + RAM_B * b\n",
" print(\"-\" * 58)\n",
" print(f\"CPU-Auslastung: {genutzt_cpu} / {CPU_GESAMT} vCPUs \"\n",
" f\"({genutzt_cpu / CPU_GESAMT * 100:.1f} %)\")\n",
" print(f\"RAM-Auslastung: {genutzt_ram} / {RAM_GESAMT} GB \"\n",
" f\"({genutzt_ram / RAM_GESAMT * 100:.1f} %)\")\n",
" print(f\"Arbitrage-Limit: {a} / {MAX_ARBITRAGE}\")\n",
"\n",
" # 8. Selbstkontrolle: Sind wirklich alle Bedingungen eingehalten?\n",
" assert genutzt_cpu <= CPU_GESAMT, \"vCPU-Limit verletzt!\"\n",
" assert genutzt_ram <= RAM_GESAMT, \"RAM-Limit verletzt!\"\n",
" assert a <= MAX_ARBITRAGE, \"Arbitrage-Limit verletzt!\"\n",
"\n",
" # 9. Zum Vergleich: die naive Strategie \"nur der lukrativere Bot\"\n",
" naiv_b = min(CPU_GESAMT // CPU_B, RAM_GESAMT // RAM_B)\n",
" naiv_gewinn = GEWINN_B * naiv_b\n",
" print(\"-\" * 58)\n",
" print(f\"Naive Strategie (nur Trendfolge): {naiv_b} Bots -> {naiv_gewinn:,.2f} EUR\")\n",
" print(f\"Vorteil der Optimierung: {gewinn - naiv_gewinn:,.2f} EUR pro Tag \"\n",
" f\"({(gewinn / naiv_gewinn - 1) * 100:.1f} %)\")\n",
" print(\"=\" * 58)\n",
" return a, b, gewinn\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" loese_bot_allokation()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Von Excel zu Python: Ihre Daten liegen schon da\n",
"\n",
"`Excel_Bruecke.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Excel_Bruecke.py\n",
"\"\"\"\n",
"Kapitel Einfuehrung: Vom Excel-Solver zu Python - dieselbe Rechnung, dieselbe Datei.\n",
"\n",
"Das Programm bildet den vollstaendigen Arbeitsweg ab, den ein Excel-Modell im\n",
"Betrieb sonst von Hand geht:\n",
"\n",
" Tabellenblatt lesen -> Modell bauen -> loesen -> Ergebnis zurueckschreiben\n",
"\n",
"Damit es ohne Vorbereitung laeuft, legt es die Eingabedatei beim ersten Start\n",
"selbst an. Ersetzen Sie sie durch Ihre eigene Datei - solange die Spalten\n",
"gleich heissen, aendert sich am Code nichts.\n",
"\n",
"Die vier Arbeitsschritte kommen aus or_kern.py (Kapitel Praxisfallen). Was\n",
"hier steht, ist nur noch das, was diesen Fall ausmacht - der Rest ist\n",
"gemeinsamer Unterbau. Wer das Modul nicht kennt: Es ist im Buch vollstaendig\n",
"abgedruckt und liegt im selben Verzeichnis.\n",
"\n",
"Erzeugte Dateien (im Arbeitsverzeichnis):\n",
" produktionsmix.xlsx Eingabe: Blaetter 'Produkte' und 'Kapazitaeten'\n",
" produktionsmix_ergebnis.xlsx Ausgabe: Blaetter 'Plan' und 'Kennzahlen'\n",
"\n",
"Benoetigt: pandas, openpyxl, ortools, pydantic (ueber or_kern)\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import os\n",
"\n",
"import pandas as pd\n",
"\n",
"from or_kern import (lade_produktionsproblem, loese_mit_glop, pruefe_loesung,\n",
" schreibe_ergebnis)\n",
"\n",
"EINGABE = \"produktionsmix.xlsx\"\n",
"AUSGABE = \"produktionsmix_ergebnis.xlsx\"\n",
"\n",
"\n",
"def lege_beispieldatei_an(pfad: str) -> None:\n",
" \"\"\"Schreibt die Eingabemappe, wie sie auch aus dem Controlling kaeme.\"\"\"\n",
" produkte = pd.DataFrame({\n",
" \"Produkt\": [\"Tisch\", \"Stuhl\", \"Regal\"],\n",
" \"Deckungsbeitrag\": [240.0, 60.0, 130.0],\n",
" \"Montagestunden\": [3.0, 1.0, 2.0],\n",
" \"Plattenmaterial\": [6.0, 1.0, 4.0],\n",
" })\n",
" kapazitaeten = pd.DataFrame({\n",
" \"Ressource\": [\"Montagestunden\", \"Plattenmaterial\"],\n",
" \"Verfuegbar\": [150.0, 240.0],\n",
" })\n",
" with pd.ExcelWriter(pfad, engine=\"openpyxl\") as mappe:\n",
" produkte.to_excel(mappe, sheet_name=\"Produkte\", index=False)\n",
" kapazitaeten.to_excel(mappe, sheet_name=\"Kapazitaeten\", index=False)\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" if not os.path.exists(EINGABE):\n",
" lege_beispieldatei_an(EINGABE)\n",
" print(f\"Beispiel-Eingabedatei angelegt: {EINGABE}\")\n",
"\n",
" # 1. Lesen und pruefen. Faellt hier etwas auf, ist der Fehler noch\n",
" # zuzuordnen - spaeter waere er nur noch eine merkwuerdige Zahl.\n",
" problem = lade_produktionsproblem(EINGABE)\n",
"\n",
" # 2. Loesen. Einzige solverabhaengige Zeile des ganzen Programms -\n",
" # 'loese_mit_scipy' waere ein Einzeiler-Wechsel.\n",
" loesung = loese_mit_glop(problem)\n",
"\n",
" # 3. Abnahmepruefung gegen die Anforderungen, ohne den Solver zu fragen.\n",
" beanstandungen = pruefe_loesung(problem, loesung)\n",
" if beanstandungen:\n",
" raise SystemExit(\"Abnahmepruefung fehlgeschlagen:\\n - \"\n",
" + \"\\n - \".join(beanstandungen))\n",
"\n",
" # 4. Zurueckschreiben - im Format, das die Fachabteilung ohnehin benutzt.\n",
" schreibe_ergebnis(AUSGABE, problem, loesung)\n",
"\n",
" print(\"=\" * 66)\n",
" print(\" PRODUKTIONSPLAN AUS DER EXCEL-MAPPE\")\n",
" print(\"=\" * 66)\n",
" for produkt in problem.produkte:\n",
" menge = loesung.werte[produkt.name]\n",
" print(f\"{produkt.name:<18} {menge:8.1f} Stueck \"\n",
" f\"{menge * produkt.deckungsbeitrag:12,.2f} EUR\")\n",
" print(\"-\" * 66)\n",
" print(loesung.als_bericht())\n",
" print(\"Abnahmepruefung: bestanden\\n\")\n",
"\n",
" # Die Schattenpreise beantworten die Frage, die der Plan nicht stellt:\n",
" # Was waere eine zusaetzliche Einheit dieser Ressource wert?\n",
" for ressource, preis in loesung.schattenpreise.items():\n",
" print(f\" eine Einheit {ressource:<18} mehr waere wert: {preis:7.2f} EUR\")\n",
" print(\"=\" * 66)\n",
" print(f\"Ergebnis geschrieben nach: {AUSGABE}\")"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -0,0 +1,540 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 18: Finanzdaten-Modellierung — Renditen, Kovarianz und Shrinkage\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Logarithmische Rendite\n",
"\n",
"`Renditen_Vergleich.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Renditen_Vergleich.py\n",
"\"\"\"\n",
"Kapitel Finanzdaten: Diskrete vs. logarithmische Renditen.\n",
"Zeigt an einem simulierten Kursverlauf, welche Eigenschaft wo gilt -\n",
"und warum das arithmetische Mittel diskreter Renditen in die Irre fuehrt.\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"import pandas as pd\n",
"\n",
"if __name__ == \"__main__\":\n",
" kurse = np.array([100.0, 150.0, 100.0, 120.0, 90.0, 135.0])\n",
" tage = [f\"t{i}\" for i in range(len(kurse))]\n",
"\n",
" diskret = kurse[1:] / kurse[:-1] - 1.0\n",
" logarithmisch = np.log(kurse[1:] / kurse[:-1])\n",
"\n",
" print(\"=\" * 74)\n",
" print(\" DISKRETE UND LOGARITHMISCHE RENDITEN IM VERGLEICH\")\n",
" print(\"=\" * 74)\n",
" tabelle = pd.DataFrame({\n",
" \"Periode\": [f\"{tage[i]} -> {tage[i+1]}\" for i in range(len(diskret))],\n",
" \"Kurs von\": kurse[:-1],\n",
" \"Kurs bis\": kurse[1:],\n",
" \"diskret R\": [f\"{r*100:+7.2f} %\" for r in diskret],\n",
" \"log r\": [f\"{r:+8.4f}\" for r in logarithmisch],\n",
" })\n",
" print(tabelle.to_string(index=False))\n",
"\n",
" # --- Zeitliche Aggregation ------------------------------------------\n",
" gesamt_wahr = kurse[-1] / kurse[0] - 1.0\n",
" summe_log = logarithmisch.sum()\n",
" aus_log_zurueck = np.exp(summe_log) - 1.0\n",
" summe_diskret = diskret.sum()\n",
"\n",
" print(\"\\n--- Aggregation ueber die Zeit ---\")\n",
" print(f\" Tatsaechliche Gesamtrendite: {gesamt_wahr*100:+8.2f} %\")\n",
" print(f\" Summe der Log-Renditen -> exp()-1: {aus_log_zurueck*100:+8.2f} % \"\n",
" f\"{'KORREKT' if abs(aus_log_zurueck - gesamt_wahr) < 1e-9 else 'falsch'}\")\n",
" print(f\" Summe der diskreten Renditen: {summe_diskret*100:+8.2f} % FALSCH\")\n",
"\n",
" # --- Mittelwerte ------------------------------------------------------\n",
" arithmetisch = diskret.mean()\n",
" geometrisch = np.prod(1 + diskret) ** (1 / len(diskret)) - 1\n",
" aus_log = np.exp(logarithmisch.mean()) - 1\n",
"\n",
" print(\"\\n--- Welcher Mittelwert ist der richtige? ---\")\n",
" print(f\" Arithmetisches Mittel (diskret): {arithmetisch*100:+8.2f} % \"\n",
" f\"-> ueberschaetzt\")\n",
" print(f\" Geometrisches Mittel: {geometrisch*100:+8.2f} % -> korrekt\")\n",
" print(f\" exp(Mittel der Log-Renditen) - 1: {aus_log*100:+8.2f} % \"\n",
" f\"-> identisch zum geometrischen\")\n",
"\n",
" # Probe: Endkapital mit dem jeweiligen Mittelwert hochgerechnet\n",
" n = len(diskret)\n",
" print(f\"\\n Probe - Startkapital 100 EUR ueber {n} Perioden:\")\n",
" print(f\" tatsaechlich: {kurse[-1]:8.2f} EUR\")\n",
" print(f\" mit arithm. Mittel hochgerechnet: {100*(1+arithmetisch)**n:8.2f} EUR\")\n",
" print(f\" mit geom. Mittel hochgerechnet: {100*(1+geometrisch)**n:8.2f} EUR\")\n",
"\n",
" print(\"\\n--- Annualisierung (252 Handelstage) ---\")\n",
" print(f\" Volatilitaet aus Log-Renditen: \"\n",
" f\"{logarithmisch.std(ddof=1)*np.sqrt(252)*100:.2f} % p.a.\")\n",
" print(\" (Die Wurzel-Zeit-Regel gilt nur bei unabhaengigen Renditen -\")\n",
" print(\" fuer reale Maerkte ist sie eine Naeherung.)\")\n",
" print(\"=\" * 74)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Das Schätzfehler-Problem\n",
"\n",
"`Schaetzrauschen_Demo.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Schaetzrauschen_Demo.py\n",
"\"\"\"\n",
"Kapitel Finanzdaten: Der Error-Maximizer-Effekt im Experiment.\n",
"\n",
"Aufbau: Wir KENNEN die wahre Kovarianzmatrix, weil wir die Daten selbst\n",
"erzeugen. Dann schaetzen wir sie aus endlich vielen Beobachtungen und\n",
"vergleichen, wie gut das daraus optimierte Portfolio in der WAHREN Welt\n",
"abschneidet - mit und ohne Shrinkage.\n",
"\n",
"Das ist der einzige saubere Weg, Schaetzfehler zu messen: Man braucht eine\n",
"Wahrheit zum Vergleich, und die gibt es nur in der Simulation.\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"from sklearn.covariance import LedoitWolf\n",
"\n",
"\n",
"def erzeuge_wahre_kovarianz(n, rng):\n",
" \"\"\"Ein-Faktor-Modell: gemeinsamer Marktfaktor plus titelspezifisches Rauschen.\"\"\"\n",
" beta = rng.uniform(0.6, 1.4, size=n) # Marktsensitivitaeten\n",
" var_markt = 0.04 # Marktvarianz p.a.\n",
" var_spezifisch = rng.uniform(0.01, 0.09, size=n) # idiosynkratische Varianz\n",
" return np.outer(beta, beta) * var_markt + np.diag(var_spezifisch)\n",
"\n",
"\n",
"def minimum_varianz_gewichte(sigma):\n",
" \"\"\"Analytische GMV-Loesung: w = Sigma^-1 1 / (1' Sigma^-1 1). Ohne Restriktionen.\"\"\"\n",
" eins = np.ones(len(sigma))\n",
" try:\n",
" loesung = np.linalg.solve(sigma, eins)\n",
" except np.linalg.LinAlgError:\n",
" loesung = np.linalg.pinv(sigma) @ eins # falls singulaer\n",
" return loesung / loesung.sum()\n",
"\n",
"\n",
"def portfoliovolatilitaet(w, sigma):\n",
" return float(np.sqrt(w @ sigma @ w))\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" rng = np.random.default_rng(2026)\n",
" N = 40 # Anzahl Titel\n",
" WIEDERHOLUNGEN = 200\n",
"\n",
" sigma_wahr = erzeuge_wahre_kovarianz(N, rng)\n",
" w_ideal = minimum_varianz_gewichte(sigma_wahr)\n",
" vola_ideal = portfoliovolatilitaet(w_ideal, sigma_wahr)\n",
"\n",
" print(\"=\" * 92)\n",
" print(\" ERROR-MAXIMIZER: WIE TEUER IST SCHAETZRAUSCHEN?\")\n",
" print(\"=\" * 92)\n",
" print(f\"Aufbau: {N} Titel, wahre Kovarianz bekannt (Ein-Faktor-Modell).\")\n",
" print(f\"Bestmoegliche Volatilitaet bei perfektem Wissen: \"\n",
" f\"{vola_ideal*100:.2f} % p.a.\\n\")\n",
"\n",
" print(f\"{'T (Tage)':>9} | {'T/N':>5} | {'Stichprobe':>22} | {'Ledoit-Wolf':>22} | \"\n",
" f\"{'Shrink':>7}\")\n",
" print(f\"{'':>9} | {'':>5} | {'Vola Aufschlag':>22} | \"\n",
" f\"{'Vola Aufschlag':>22} | {'delta':>7}\")\n",
" print(\"-\" * 92)\n",
"\n",
" for T in [60, 120, 252, 504, 1260]:\n",
" vola_stichprobe, vola_lw, deltas = [], [], []\n",
"\n",
" for _ in range(WIEDERHOLUNGEN):\n",
" # Daten aus der WAHREN Verteilung ziehen (taeglich)\n",
" daten = rng.multivariate_normal(np.zeros(N), sigma_wahr / 252, size=T)\n",
"\n",
" # (a) Stichproben-Kovarianz\n",
" s_stich = np.cov(daten, rowvar=False, ddof=1) * 252\n",
" w_stich = minimum_varianz_gewichte(s_stich)\n",
"\n",
" # (b) Ledoit-Wolf-Shrinkage\n",
" lw = LedoitWolf().fit(daten)\n",
" s_lw = lw.covariance_ * 252\n",
" w_lw = minimum_varianz_gewichte(s_lw)\n",
" deltas.append(lw.shrinkage_)\n",
"\n",
" # Bewertung IMMER mit der wahren Kovarianz - das ist der Punkt\n",
" vola_stichprobe.append(portfoliovolatilitaet(w_stich, sigma_wahr))\n",
" vola_lw.append(portfoliovolatilitaet(w_lw, sigma_wahr))\n",
"\n",
" m_stich, m_lw = np.mean(vola_stichprobe), np.mean(vola_lw)\n",
" print(f\"{T:>9} | {T/N:>5.1f} | {m_stich*100:>8.2f} % \"\n",
" f\"{(m_stich/vola_ideal-1)*100:>+9.1f} % | \"\n",
" f\"{m_lw*100:>8.2f} % {(m_lw/vola_ideal-1)*100:>+9.1f} % | \"\n",
" f\"{np.mean(deltas):>7.3f}\")\n",
"\n",
" print(\"-\" * 92)\n",
" print(\"'Aufschlag' = wie viel mehr Risiko das Portfolio in der WAHREN Welt traegt,\")\n",
" print(\"verglichen mit dem Portfolio bei perfektem Wissen.\")\n",
" print(\"Lesart: Je kleiner T/N, desto teurer das Schaetzrauschen - und desto mehr\")\n",
" print(\"schrumpft Ledoit-Wolf (delta steigt).\")\n",
" print(\"=\" * 92)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Praxis: Datenpipeline mit korrekter Spaltenreihenfolge\n",
"\n",
"`Finanzdaten_Ledoit_Wolf.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Finanzdaten_Ledoit_Wolf.py\n",
"\"\"\"\n",
"Kapitel Finanzdaten: Automatisierte Finanzdaten-Pipeline und Kovarianz-Shrinkage.\n",
"\n",
"Eigenschaften:\n",
" * Spaltenreihenfolge wird erzwungen und per assert geprueft\n",
" * beide Shrinkage-Ziele werden berechnet und verglichen\n",
" * Datenqualitaetspruefungen (Luecken, Ausreisser, Mindestlaenge)\n",
" * Eigenwertspektrum wird ausgewiesen, nicht nur die Konditionszahl\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"import pandas as pd\n",
"import yfinance as yf\n",
"from sklearn.covariance import LedoitWolf\n",
"\n",
"HANDELSTAGE = 252\n",
"\n",
"\n",
"def lade_kurse(tickers: list[str], start, ende) -> pd.DataFrame:\n",
" \"\"\"Laedt bereinigte Schlusskurse und garantiert die Spaltenreihenfolge.\"\"\"\n",
" print(f\"Lade {len(tickers)} Ticker von {start} bis {ende} ...\")\n",
" roh = yf.download(tickers, start=start, end=ende, auto_adjust=True, progress=False)\n",
" if roh.empty:\n",
" raise SystemExit(\"Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).\")\n",
"\n",
" if isinstance(roh.columns, pd.MultiIndex):\n",
" # [tickers] erzwingt die gewuenschte Spaltenreihenfolge\n",
" kurse = roh[\"Close\"][tickers].dropna()\n",
" else:\n",
" kurse = roh[[\"Close\"]].dropna()\n",
" kurse.columns = tickers\n",
"\n",
" assert list(kurse.columns) == tickers, (\n",
" f\"Spaltenreihenfolge weicht ab!\\n erwartet: {tickers}\\n\"\n",
" f\" erhalten: {list(kurse.columns)}\")\n",
" return kurse\n",
"\n",
"\n",
"def pruefe_datenqualitaet(kurse: pd.DataFrame, mindest_tage: int = 200) -> None:\n",
" \"\"\"Faengt die haeufigsten Datenprobleme ab, bevor sie ins Modell gelangen.\"\"\"\n",
" T, N = kurse.shape\n",
" print(f\"\\nDatenqualitaet: {T} Handelstage, {N} Titel (T/N = {T/N:.1f})\")\n",
" if T < mindest_tage:\n",
" raise SystemExit(f\"Zu wenige Beobachtungen ({T} < {mindest_tage}).\")\n",
" if T < N:\n",
" print(\" WARNUNG: T < N - die Stichprobenkovarianz ist singulaer!\")\n",
" elif T < 3 * N:\n",
" print(\" WARNUNG: T < 3N - erhebliches Schaetzrauschen zu erwarten.\")\n",
"\n",
" renditen = kurse.pct_change().dropna()\n",
" extreme = (renditen.abs() > 0.25).sum().sum()\n",
" if extreme:\n",
" print(f\" Hinweis: {extreme} Tagesrenditen ueber 25 % \"\n",
" f\"(Splits, Sondersituationen oder Datenfehler pruefen).\")\n",
" luecken = kurse.isna().sum().sum()\n",
" print(f\" Fehlende Werte nach dropna: {luecken}\")\n",
"\n",
"\n",
"def ziel_konstante_korrelation(renditen: np.ndarray) -> np.ndarray:\n",
" \"\"\"\n",
" Shrinkage-Ziel nach Ledoit/Wolf 2003: Einzelvarianzen behalten,\n",
" alle Korrelationen durch ihren Mittelwert ersetzen.\n",
" (sklearn nutzt stattdessen die skalierte Einheitsmatrix - siehe oben.)\n",
" \"\"\"\n",
" S = np.cov(renditen, rowvar=False, ddof=1)\n",
" d = np.sqrt(np.diag(S))\n",
" korrelation = S / np.outer(d, d)\n",
" n = len(S)\n",
" r_quer = (korrelation.sum() - n) / (n * (n - 1)) # Mittel ohne Diagonale\n",
" F = r_quer * np.outer(d, d)\n",
" np.fill_diagonal(F, np.diag(S))\n",
" return F\n",
"\n",
"\n",
"def analysiere(kurse: pd.DataFrame):\n",
" renditen = kurse.pct_change().dropna()\n",
" T, N = renditen.shape\n",
"\n",
" mu = renditen.mean().values * HANDELSTAGE\n",
" sigma_stichprobe = renditen.cov().values * HANDELSTAGE\n",
"\n",
" lw = LedoitWolf(assume_centered=False).fit(renditen.values)\n",
" sigma_lw = lw.covariance_ * HANDELSTAGE\n",
" delta = lw.shrinkage_\n",
"\n",
" print(\"\\n\" + \"=\" * 84)\n",
" print(\" FINANZDATEN-PIPELINE UND MATRIX-KONDITIONIERUNG\")\n",
" print(\"=\" * 84)\n",
" print(f\"Beobachtungen T: {T}\")\n",
" print(f\"Titel N: {N}\")\n",
" print(f\"Optimales Shrinkage delta: {delta:.4f} \"\n",
" f\"({delta*100:.1f} % Gewicht auf dem strukturierten Ziel)\")\n",
"\n",
" eig_stich = np.linalg.eigvalsh(sigma_stichprobe)\n",
" eig_lw = np.linalg.eigvalsh(sigma_lw)\n",
"\n",
" print(\"\\n--- Eigenwertspektrum (annualisiert) ---\")\n",
" print(f\"{'':<14} {'kleinster':>12} {'Median':>12} {'groesster':>12} \"\n",
" f\"{'Kondition':>12}\")\n",
" for name, eig in [(\"Stichprobe\", eig_stich), (\"Ledoit-Wolf\", eig_lw)]:\n",
" kondition = eig.max() / max(eig.min(), 1e-12)\n",
" print(f\"{name:<14} {eig.min():>12.6f} {np.median(eig):>12.6f} \"\n",
" f\"{eig.max():>12.6f} {kondition:>12.1f}\")\n",
"\n",
" print(\"\\nDie Konditionszahl misst, wie stark sich kleine Datenaenderungen auf\")\n",
" print(\"die Inverse auswirken. Je kleiner, desto stabiler die Portfoliogewichte.\")\n",
"\n",
" # --- Vergleich der beiden Shrinkage-Ziele ----------------------------\n",
" S_taeglich = np.cov(renditen.values, rowvar=False, ddof=1)\n",
" F_identitaet = np.eye(N) * np.trace(S_taeglich) / N\n",
" F_korrelation = ziel_konstante_korrelation(renditen.values)\n",
"\n",
" print(\"\\n--- Die beiden Shrinkage-Ziele im Vergleich ---\")\n",
" for name, F in [(\"skalierte Einheitsmatrix (sklearn)\", F_identitaet),\n",
" (\"konstante Korrelation (LW 2003)\", F_korrelation)]:\n",
" gemischt = ((1 - delta) * S_taeglich + delta * F) * HANDELSTAGE\n",
" eig = np.linalg.eigvalsh(gemischt)\n",
" print(f\" {name:<36}: Kondition {eig.max()/max(eig.min(),1e-12):8.1f}, \"\n",
" f\"kleinster EW {eig.min():.6f}\")\n",
"\n",
" print(\"\\n--- Erwartete Renditen (annualisiert) ---\")\n",
" uebersicht = pd.DataFrame({\n",
" \"Ticker\": kurse.columns,\n",
" \"Rendite p.a.\": [f\"{r*100:+7.2f} %\" for r in mu],\n",
" \"Volatilitaet p.a.\": [f\"{np.sqrt(sigma_lw[i, i])*100:6.2f} %\" for i in range(N)],\n",
" })\n",
" print(uebersicht.to_string(index=False))\n",
" print(\"=\" * 84)\n",
" return renditen, mu, sigma_stichprobe, sigma_lw\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" UNIVERSUM = [\"AAPL\", \"MSFT\", \"NVDA\", \"AMZN\", # Technologie\n",
" \"JNJ\", \"PFE\", # Gesundheit\n",
" \"JPM\", \"GS\", # Banken\n",
" \"XOM\", \"CVX\"] # Energie\n",
"\n",
" # Relatives 2-Jahres-Fenster, damit das Beispiel nicht \"altert\"\n",
" ende = pd.Timestamp.today().normalize()\n",
" start = ende - pd.DateOffset(years=2)\n",
"\n",
" kurse = lade_kurse(UNIVERSUM, start.strftime(\"%Y-%m-%d\"), ende.strftime(\"%Y-%m-%d\"))\n",
" pruefe_datenqualitaet(kurse)\n",
" analysiere(kurse)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Finde den Denkfehler\n",
"\n",
"`Kovarianz_Falle.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Kovarianz_Falle.py\n",
"\"\"\"\n",
"Kapitel Finanzdaten: Warum die historische Kovarianzmatrix den Optimierer belaeugt.\n",
"\n",
"Ein Portfolio aus 30 Anlagen soll das Risiko minimieren. Geschaetzt wird die\n",
"Kovarianzmatrix aus 20 Beobachtungen - in der Praxis ein voellig ueblicher\n",
"Fall (30 Titel, ein Quartal Wochendaten).\n",
"\n",
"Das Ergebnis ist ein Portfolio mit einem gemessenen Risiko von 0,000 %. Der\n",
"Optimierer haelt es fuer risikofrei. Ausserhalb des Schaetzzeitraums ist es\n",
"das schlechteste der drei untersuchten Portfolios - schlechter sogar als\n",
"blosse Gleichgewichtung.\n",
"\n",
"Der Grund ist nicht Zufall, sondern Struktur: Bei N Anlagen und T < N\n",
"Beobachtungen hat die Stichproben-Kovarianzmatrix hoechstens Rang T-1. Es gibt\n",
"dann ganze Richtungen im Gewichtsraum, in denen sie exakt null Varianz misst -\n",
"Richtungen, die es in Wirklichkeit nicht gibt. Der Optimierer findet sie\n",
"zuverlaessig, denn er sucht ja genau danach.\n",
"\n",
"Die Daten sind synthetisch (ein Marktfaktor plus Eigenrauschen), damit das\n",
"Ergebnis reproduzierbar ist und kein Internetzugang noetig wird. Mit echten\n",
"Kursdaten sieht es genauso aus.\n",
"\n",
"Benoetigt: numpy, cvxpy, scikit-learn\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import numpy as np\n",
"import cvxpy as cp\n",
"from sklearn.covariance import LedoitWolf\n",
"\n",
"ANZAHL_ANLAGEN = 30\n",
"BEOBACHTUNGEN_SCHAETZUNG = 20 # weniger als Anlagen - genau darum geht es\n",
"BEOBACHTUNGEN_TEST = 2000 # der \"spaetere Verlauf\"\n",
"\n",
"RNG = np.random.default_rng(11)\n",
"# Jede Anlage reagiert unterschiedlich stark auf den Gesamtmarkt.\n",
"BETA = RNG.uniform(0.6, 1.4, ANZAHL_ANLAGEN)\n",
"\n",
"\n",
"def erzeuge_renditen(perioden: int) -> np.ndarray:\n",
" \"\"\"Ein Marktfaktor, auf den alle reagieren, plus Eigenrauschen je Anlage.\"\"\"\n",
" markt = RNG.normal(0.0, 0.010, perioden)\n",
" eigen = RNG.normal(0.0, 0.012, (perioden, ANZAHL_ANLAGEN))\n",
" return np.outer(markt, BETA) + eigen\n",
"\n",
"\n",
"def minimales_risiko(kovarianz: np.ndarray) -> tuple[np.ndarray, float]:\n",
" \"\"\"Minimum-Varianz-Portfolio: Gewichte summieren sich zu 1, sonst frei.\n",
"\n",
" cp.psd_wrap() sagt CVXPY: 'Ich weiss, dass diese Matrix numerisch nicht\n",
" exakt positiv semidefinit ist - rechne trotzdem.' Das ist hier bewusst so\n",
" gewaehlt, denn genau diese Eigenschaft wollen wir vorfuehren. Im\n",
" Produktivbetrieb waere die DCP-Fehlermeldung das Warnsignal, dem man\n",
" nachgehen muss (siehe Denkfehler 8.1).\n",
" \"\"\"\n",
" gewichte = cp.Variable(ANZAHL_ANLAGEN)\n",
" problem = cp.Problem(cp.Minimize(cp.quad_form(gewichte, cp.psd_wrap(kovarianz))),\n",
" [cp.sum(gewichte) == 1])\n",
" problem.solve()\n",
" return np.array(gewichte.value).ravel(), max(problem.value, 0.0)\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" schaetzzeitraum = erzeuge_renditen(BEOBACHTUNGEN_SCHAETZUNG)\n",
" spaeterer_verlauf = erzeuge_renditen(BEOBACHTUNGEN_TEST)\n",
"\n",
" stichprobe = np.cov(schaetzzeitraum, rowvar=False)\n",
" ledoit_wolf = LedoitWolf().fit(schaetzzeitraum)\n",
"\n",
" print(\"=\" * 78)\n",
" print(\" DIE KOVARIANZ-FALLE\")\n",
" print(\"=\" * 78)\n",
" print(f\"{ANZAHL_ANLAGEN} Anlagen, geschaetzt aus {BEOBACHTUNGEN_SCHAETZUNG} \"\n",
" f\"Beobachtungen.\")\n",
" print(f\"Rang der Stichprobenmatrix: \"\n",
" f\"{np.linalg.matrix_rank(stichprobe)} - noetig waeren {ANZAHL_ANLAGEN}.\")\n",
" print(f\"Kleinster Eigenwert: {np.linalg.eigvalsh(stichprobe).min():.2e}\")\n",
" print()\n",
" print(\"Es gibt also Richtungen, in denen diese Matrix EXAKT null Varianz\")\n",
" print(\"misst. Genau dort sucht ein Minimum-Varianz-Optimierer.\")\n",
" print()\n",
"\n",
" print(f\"{'Schaetzer':<26} {'Risiko im Zeitraum':>19} \"\n",
" f\"{'Risiko spaeter':>16} {'Hebel':>7}\")\n",
" print(\"-\" * 78)\n",
"\n",
" ergebnisse = {}\n",
" for name, matrix in [\n",
" (\"Stichprobe (roh)\", stichprobe),\n",
" (f\"Ledoit-Wolf (d={ledoit_wolf.shrinkage_:.2f})\", ledoit_wolf.covariance_)]:\n",
" gewichte, varianz_intern = minimales_risiko(matrix)\n",
" risiko_spaeter = float(np.std(spaeterer_verlauf @ gewichte))\n",
" hebel = float(np.abs(gewichte).sum())\n",
" ergebnisse[name] = (risiko_spaeter, gewichte)\n",
" print(f\"{name:<26} {np.sqrt(varianz_intern) * 100:>18.3f} % \"\n",
" f\"{risiko_spaeter * 100:>15.3f} % {hebel:>7.1f}\")\n",
"\n",
" gleich = np.full(ANZAHL_ANLAGEN, 1.0 / ANZAHL_ANLAGEN)\n",
" print(f\"{'Gleichgewichtung 1/N':<26} \"\n",
" f\"{float(np.std(schaetzzeitraum @ gleich)) * 100:>18.3f} % \"\n",
" f\"{float(np.std(spaeterer_verlauf @ gleich)) * 100:>15.3f} % {1.0:>7.1f}\")\n",
" print(\"-\" * 78)\n",
"\n",
" print(\"\\nDrei Beobachtungen:\")\n",
" print(\"1. Die rohe Schaetzung meldet 0,000 % Risiko - und liefert spaeter das\")\n",
" print(\" SCHLECHTESTE Ergebnis der drei. Sie hat kein Portfolio optimiert,\")\n",
" print(\" sondern eine Luecke in den eigenen Daten gefunden.\")\n",
" print(\"2. Die Gleichgewichtung, die gar nicht optimiert, schlaegt den rohen\")\n",
" print(\" Optimierer deutlich. Das ist kein Zufallsbefund, sondern in der\")\n",
" print(\" Literatur breit belegt.\")\n",
" print(\"3. Ledoit-Wolf ist im Schaetzzeitraum EHRLICHER (0,441 statt 0,000 %)\")\n",
" print(\" und spaeter am besten. Der Hebel faellt von 3,8 auf 1,8 - das\")\n",
" print(\" Portfolio wird auch praktisch handelbarer.\")\n",
" print()\n",
" print(\"Merksatz: Ein Optimierer glaubt seinen Eingabedaten vollstaendig.\")\n",
" print(\"Je mehr Freiheit Sie ihm geben, desto gruendlicher findet er deren\")\n",
" print(\"Fehler. Das gilt fuer Kovarianzmatrizen wie fuer Lieferzeiten,\")\n",
" print(\"Ausfallraten und jede andere geschaetzte Groesse.\")\n",
" print(\"=\" * 78)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -0,0 +1,579 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 2: Das mathematische Fundament — Vektoren, Matrizen, Konvexität\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Dasselbe in Python\n",
"\n",
"`Matrixform.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Matrixform.py\n",
"\"\"\"\n",
"Kapitel Fundament: Von der ausgeschriebenen Form zur Matrixform - und zurück.\n",
"Zeigt, dass beide Schreibweisen dasselbe Modell beschreiben.\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"from scipy.optimize import linprog\n",
"\n",
"# --- Modell in Matrixform -------------------------------------------------\n",
"# max 3*x1 + 5*x2 u.d.N. x1 <= 4, 2*x2 <= 12, 3*x1 + 2*x2 <= 18, x >= 0\n",
"c = np.array([3.0, 5.0]) # Ertragsvektor (Maximierung)\n",
"A = np.array([[1.0, 0.0], # Zeile 1: nur x1 kommt vor\n",
" [0.0, 2.0], # Zeile 2: nur x2 kommt vor\n",
" [3.0, 2.0]]) # Zeile 3: beide\n",
"b = np.array([4.0, 12.0, 18.0])\n",
"namen = [\"Rohstoff A\", \"Rohstoff B\", \"Maschinenzeit\"]\n",
"\n",
"# --- Ausgeschriebene Form maschinell erzeugen ------------------------------\n",
"def zeige_ausgeschrieben(c, A, b, namen):\n",
" \"\"\"Druckt die Matrixform als lesbares Ungleichungssystem.\"\"\"\n",
" terme = \" + \".join(f\"{c[j]:g}*x{j+1}\" for j in range(len(c)))\n",
" print(f\"max {terme}\")\n",
" print(\"u.d.N.\")\n",
" for i in range(A.shape[0]):\n",
" summanden = \" + \".join(f\"{A[i, j]:g}*x{j+1}\"\n",
" for j in range(A.shape[1]) if A[i, j] != 0)\n",
" print(f\" {summanden:<24} <= {b[i]:>5g} ({namen[i]})\")\n",
" print(f\" x1, ..., x{len(c)} >= 0\")\n",
"\n",
"zeige_ausgeschrieben(c, A, b, namen)\n",
"\n",
"# --- Zulässigkeit eines Punktes prüfen ------------------------------------\n",
"def ist_zulaessig(x, A, b, toleranz=1e-9):\n",
" \"\"\"Prüft A x <= b und x >= 0 komponentenweise.\"\"\"\n",
" verbrauch = A @ x # Matrix-Vektor-Produkt: alle Zeilen auf einmal\n",
" return bool(np.all(verbrauch <= b + toleranz) and np.all(x >= -toleranz))\n",
"\n",
"for kandidat in [np.array([2.0, 6.0]), np.array([4.0, 3.0]), np.array([4.0, 6.0])]:\n",
" zulaessig = ist_zulaessig(kandidat, A, b)\n",
" zielwert = c @ kandidat\n",
" verbrauch = A @ kandidat\n",
" print(f\"\\nx = {kandidat} -> A x = {verbrauch} \"\n",
" f\"{'zulaessig' if zulaessig else 'UNZULAESSIG'}, Z = {zielwert:g}\")\n",
"\n",
"# --- Lösen: linprog minimiert, also c negieren -----------------------------\n",
"ergebnis = linprog(c=-c, A_ub=A, b_ub=b, bounds=[(0, None)] * len(c), method=\"highs\")\n",
"print(\"\\n\" + \"-\" * 60)\n",
"print(f\"Optimale Loesung: x* = {np.round(ergebnis.x, 4)}\")\n",
"print(f\"Optimaler Wert: Z* = {-ergebnis.fun:g}\")\n",
"print(\"Hinweis: linprog minimiert, deshalb wurde c negiert und das\")\n",
"print(\" Ergebnis am Ende wieder mit -1 multipliziert.\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Konvexität sichtbar machen\n",
"\n",
"`Konvexitaet_Demo.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Konvexitaet_Demo.py\n",
"\"\"\"\n",
"Kapitel Fundament: Konvexität praktisch erfahrbar machen.\n",
"Teil 1: Die Sehnen-Bedingung numerisch nachprüfen.\n",
"Teil 2: Zeigen, warum lokale Suche bei nicht-konvexen Funktionen scheitert.\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"from scipy.optimize import minimize\n",
"\n",
"# --- Teil 1: Sehnen-Test ---------------------------------------------------\n",
"def ist_konvex_numerisch(f, unten, oben, proben=2000, seed=0):\n",
" \"\"\"\n",
" Prüft die Konvexitätsdefinition an zufälligen Punktepaaren:\n",
" f(theta*x + (1-theta)*y) <= theta*f(x) + (1-theta)*f(y) ?\n",
" Findet Gegenbeispiele - beweist aber KEINE Konvexität.\n",
" \"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" schlimmste_verletzung = 0.0\n",
" for _ in range(proben):\n",
" x, y = rng.uniform(unten, oben, size=2)\n",
" theta = rng.uniform(0.0, 1.0)\n",
" links = f(theta * x + (1 - theta) * y)\n",
" rechts = theta * f(x) + (1 - theta) * f(y)\n",
" schlimmste_verletzung = max(schlimmste_verletzung, links - rechts)\n",
" return schlimmste_verletzung\n",
"\n",
"\n",
"funktionen = {\n",
" \"f(x) = x^2 (konvex)\": lambda x: x ** 2,\n",
" \"f(x) = |x| (konvex)\": lambda x: abs(x),\n",
" \"f(x) = e^x (konvex)\": lambda x: np.exp(x),\n",
" \"f(x) = x^3 (NICHT konvex)\": lambda x: x ** 3,\n",
" \"f(x) = x^2+3sin(3x) (NICHT konvex)\": lambda x: x ** 2 + 3 * np.sin(3 * x),\n",
"}\n",
"\n",
"print(\"=\" * 68)\n",
"print(\" TEIL 1: SEHNEN-TEST (positive Zahl = Konvexität verletzt)\")\n",
"print(\"=\" * 68)\n",
"for name, f in funktionen.items():\n",
" verletzung = ist_konvex_numerisch(f, -3.0, 3.0)\n",
" urteil = \"konvex (keine Verletzung gefunden)\" if verletzung < 1e-9 \\\n",
" else f\"NICHT konvex (Verletzung bis {verletzung:.3f})\"\n",
" print(f\"{name:<34} -> {urteil}\")\n",
"\n",
"# --- Teil 2: Lokale Suche von verschiedenen Startpunkten -------------------\n",
"def wellige_funktion(x):\n",
" \"\"\"Nicht-konvex: eine Parabel mit aufmodulierter Welle -> viele lokale Minima.\"\"\"\n",
" return x[0] ** 2 + 3.0 * np.sin(3.0 * x[0])\n",
"\n",
"print(\"\\n\" + \"=\" * 68)\n",
"print(\" TEIL 2: LOKALE SUCHE BEI NICHT-KONVEXER FUNKTION\")\n",
"print(\"=\" * 68)\n",
"print(f\"{'Startpunkt':>12} | {'gefundenes Minimum':>20} | {'Funktionswert':>14}\")\n",
"print(\"-\" * 68)\n",
"\n",
"ergebnisse = []\n",
"for start in [-3.0, -1.5, 0.0, 1.5, 3.0]:\n",
" res = minimize(wellige_funktion, x0=[start], method=\"BFGS\")\n",
" ergebnisse.append((start, res.x[0], res.fun))\n",
" print(f\"{start:>12.1f} | {res.x[0]:>20.4f} | {res.fun:>14.4f}\")\n",
"\n",
"bester = min(ergebnisse, key=lambda t: t[2])\n",
"print(\"-\" * 68)\n",
"print(f\"Je nach Startpunkt landet derselbe Algorithmus in \"\n",
" f\"{len({round(e[1], 3) for e in ergebnisse})} verschiedenen Minima.\")\n",
"print(f\"Das beste gefundene: x = {bester[1]:.4f} mit f = {bester[2]:.4f} \"\n",
" f\"(Start bei {bester[0]:.1f})\")\n",
"print(\"Bei einer KONVEXEN Funktion waeren alle Zeilen identisch --\")\n",
"print(\"der Startpunkt waere voellig gleichgueltig.\")\n",
"print(\"=\" * 68)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Geometrische Visualisierung des Lösungsraums\n",
"\n",
"`Visualisierung_Loesungsraum.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Visualisierung_Loesungsraum.py\n",
"\"\"\"\n",
"Kapitel Fundament: Geometrische Visualisierung eines 2D-Optimierungsraums.\n",
"\n",
"Ecken werden berechnet, auf Zulässigkeit geprüft, bewertet und eingezeichnet.\n",
"Zusätzlich wird das ganzzahlige Optimum systematisch bestimmt statt behauptet.\n",
"\"\"\"\n",
"\n",
"import itertools\n",
"import os\n",
"\n",
"import numpy as np\n",
"import matplotlib\n",
"matplotlib.use(\"Agg\") # kein Bildschirm nötig\n",
"import matplotlib.pyplot as plt\n",
"\n",
"OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"output\")\n",
"os.makedirs(OUTPUT_DIR, exist_ok=True)\n",
"\n",
"# --- Modell (identisch zum Kapitel Einfuehrung) ----------------------------\n",
"# max 150*xA + 250*xB\n",
"# u.d.N. 2*xA + 5*xB <= 40 (vCPU)\n",
"# 4*xA + 6*xB <= 60 (RAM)\n",
"# 1*xA + 0*xB <= 8 (Marktliquidität)\n",
"c = np.array([150.0, 250.0])\n",
"A = np.array([[2.0, 5.0], [4.0, 6.0], [1.0, 0.0]])\n",
"b = np.array([40.0, 60.0, 8.0])\n",
"restriktionsnamen = [\"vCPU\", \"RAM\", \"Marktlimit\"]\n",
"\n",
"\n",
"def ist_zulaessig(punkt, tol=1e-7):\n",
" return np.all(A @ punkt <= b + tol) and np.all(punkt >= -tol)\n",
"\n",
"\n",
"def berechne_ecken():\n",
" \"\"\"\n",
" Ecken = Schnittpunkte je zweier Begrenzungsgeraden, die zulässig sind.\n",
" Begrenzungen sind die 3 Restriktionen plus die beiden Achsen xA=0, xB=0.\n",
" \"\"\"\n",
" geraden = [(A[i], b[i]) for i in range(len(b))]\n",
" geraden.append((np.array([1.0, 0.0]), 0.0)) # xA = 0\n",
" geraden.append((np.array([0.0, 1.0]), 0.0)) # xB = 0\n",
"\n",
" ecken = []\n",
" for (n1, d1), (n2, d2) in itertools.combinations(geraden, 2):\n",
" M = np.array([n1, n2])\n",
" if abs(np.linalg.det(M)) < 1e-9: # parallel -> kein Schnittpunkt\n",
" continue\n",
" p = np.linalg.solve(M, np.array([d1, d2]))\n",
" if ist_zulaessig(p) and not any(np.allclose(p, e) for e in ecken):\n",
" ecken.append(p)\n",
" return np.array(ecken)\n",
"\n",
"\n",
"def bestes_ganzzahliges():\n",
" \"\"\"Vollständige Suche über das kleine Gitter - hier zulässig, weil winzig.\"\"\"\n",
" bester_wert, bester_punkt = -np.inf, None\n",
" for xa in range(0, 21):\n",
" for xb in range(0, 21):\n",
" p = np.array([float(xa), float(xb)])\n",
" if ist_zulaessig(p) and c @ p > bester_wert:\n",
" bester_wert, bester_punkt = c @ p, p\n",
" return bester_punkt, bester_wert\n",
"\n",
"\n",
"# --- Analyse ---------------------------------------------------------------\n",
"ecken = berechne_ecken()\n",
"werte = ecken @ c\n",
"reihenfolge = np.argsort(-werte)\n",
"\n",
"print(\"=\" * 62)\n",
"print(\" ECKEN DES ZULÄSSIGEN POLYEDERS (nach Zielwert sortiert)\")\n",
"print(\"=\" * 62)\n",
"print(f\"{'x_A':>8} {'x_B':>8} {'Z = 150 xA + 250 xB':>24}\")\n",
"print(\"-\" * 62)\n",
"for i in reihenfolge:\n",
" print(f\"{ecken[i, 0]:>8.2f} {ecken[i, 1]:>8.2f} {werte[i]:>24,.2f} EUR\")\n",
"\n",
"lp_punkt, lp_wert = ecken[reihenfolge[0]], werte[reihenfolge[0]]\n",
"ip_punkt, ip_wert = bestes_ganzzahliges()\n",
"\n",
"print(\"-\" * 62)\n",
"print(f\"Kontinuierliches Optimum (LP): x = ({lp_punkt[0]:.2f}, {lp_punkt[1]:.2f}), \"\n",
" f\"Z = {lp_wert:,.2f} EUR\")\n",
"print(f\"Ganzzahliges Optimum (IP): x = ({ip_punkt[0]:.0f}, {ip_punkt[1]:.0f}), \"\n",
" f\"Z = {ip_wert:,.2f} EUR\")\n",
"print(f\"Preis der Ganzzahligkeit: {lp_wert - ip_wert:,.2f} EUR \"\n",
" f\"({(1 - ip_wert / lp_wert) * 100:.2f} %)\")\n",
"print(\"=\" * 62)\n",
"\n",
"# --- Zeichnung -------------------------------------------------------------\n",
"gitter = np.linspace(0, 15, 400)\n",
"xa_gitter, xb_gitter = np.meshgrid(gitter, gitter)\n",
"\n",
"plt.figure(figsize=(10, 8))\n",
"\n",
"# Restriktionsgeraden\n",
"plt.plot(gitter, (40 - 2 * gitter) / 5, color=\"tab:blue\", lw=2,\n",
" label=r\"$2x_A + 5x_B \\leq 40$ (vCPU)\")\n",
"plt.plot(gitter, (60 - 4 * gitter) / 6, color=\"tab:green\", lw=2,\n",
" label=r\"$4x_A + 6x_B \\leq 60$ (RAM)\")\n",
"plt.axvline(x=8, color=\"tab:orange\", lw=2, label=r\"$x_A \\leq 8$ (Marktlimit)\")\n",
"\n",
"# Zulässiger Bereich\n",
"maske = ((2 * xa_gitter + 5 * xb_gitter <= 40) & (4 * xa_gitter + 6 * xb_gitter <= 60)\n",
" & (xa_gitter <= 8) & (xa_gitter >= 0) & (xb_gitter >= 0))\n",
"plt.imshow(maske.astype(int), extent=(0, 15, 0, 15), origin=\"lower\",\n",
" cmap=\"Greys\", alpha=0.25, aspect=\"auto\")\n",
"\n",
"# Höhenlinien der Zielfunktion\n",
"Z = 150 * xa_gitter + 250 * xb_gitter\n",
"hoehen = plt.contour(xa_gitter, xb_gitter, Z, levels=[500, 1000, 1500, 2000, 2375],\n",
" colors=\"purple\", linestyles=\"--\", alpha=0.7)\n",
"plt.clabel(hoehen, inline=True, fontsize=9, fmt=\"Z = %1.0f EUR\")\n",
"\n",
"# Ecken einzeichnen - jetzt werden sie tatsächlich benutzt\n",
"plt.scatter(ecken[:, 0], ecken[:, 1], s=70, facecolors=\"white\",\n",
" edgecolors=\"black\", zorder=4, label=\"Ecken des Polyeders\")\n",
"for e, w in zip(ecken, werte):\n",
" plt.annotate(f\"({e[0]:.1f}, {e[1]:.1f})\\nZ={w:,.0f}\", (e[0], e[1]),\n",
" textcoords=\"offset points\", xytext=(6, 6), fontsize=8)\n",
"\n",
"plt.scatter([lp_punkt[0]], [lp_punkt[1]], color=\"purple\", marker=\"D\", s=110, zorder=5,\n",
" label=f\"LP-Optimum ({lp_punkt[0]:.1f}, {lp_punkt[1]:.1f})\")\n",
"plt.scatter([ip_punkt[0]], [ip_punkt[1]], color=\"red\", s=170, zorder=6,\n",
" label=f\"Ganzzahliges Optimum ({ip_punkt[0]:.0f}, {ip_punkt[1]:.0f})\")\n",
"\n",
"plt.xlim(0, 12)\n",
"plt.ylim(0, 10)\n",
"plt.xlabel(\"Anzahl Arbitrage-Bots ($x_A$)\", fontsize=11)\n",
"plt.ylabel(\"Anzahl Trendfolge-Bots ($x_B$)\", fontsize=11)\n",
"plt.title(\"Polyeder des zulässigen Bereichs mit Niveaulinien der Zielfunktion\", fontsize=13)\n",
"plt.grid(True, linestyle=\":\", alpha=0.6)\n",
"plt.legend(loc=\"upper right\", framealpha=0.9)\n",
"plt.tight_layout()\n",
"ziel = os.path.join(OUTPUT_DIR, \"feasible_region_2d.png\")\n",
"plt.savefig(ziel, dpi=150)\n",
"print(f\"Visualisierung gespeichert unter '{ziel}'\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Das Experiment\n",
"\n",
"`Skalierung_Kondition.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Skalierung_Kondition.py\n",
"\"\"\"\n",
"Kapitel Fundament: Was die Konditionszahl kappa(A) praktisch bedeutet - und wie man\n",
"ein schlecht skaliertes Modell wieder gesund rechnet.\n",
"\n",
"Drei Experimente:\n",
"\n",
" 1. Fehlerverstaerkung: Wie stark schlaegt eine winzige Datenunsicherheit auf\n",
" die Loesung durch? kappa(A) ist genau die Obergrenze dieses Faktors.\n",
" 2. Ruiz-Equilibrierung: Ein Modell, in dem Euro-Betraege (1e7) und\n",
" Tonnen-Angaben (1e-3) in derselben Matrix stehen, wird durch Zeilen- und\n",
" Spaltenskalierung um Groessenordnungen besser konditioniert.\n",
" 3. Toleranzen: Warum eine Binaervariable mit dem Wert 0.99999998 niemals\n",
" mit int() gerundet werden darf.\n",
"\n",
"Benoetigt: numpy, scipy\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import numpy as np\n",
"from scipy.optimize import linprog\n",
"\n",
"RNG = np.random.default_rng(42)\n",
"\n",
"\n",
"# --- Experiment 1: kappa(A) als Fehlerverstaerker ---------------------------\n",
"\n",
"def fehlerverstaerkung(A: np.ndarray, versuche: int = 200,\n",
" stoerung: float = 1e-10) -> tuple[float, float]:\n",
" \"\"\"Stoert die rechte Seite b relativ um 'stoerung' in zufaellige Richtungen\n",
" und misst, um welchen Faktor sich der Fehler in der Loesung x vergroessert.\n",
"\n",
" Liefert (Median, Maximum) der Verstaerkung. Die Theorie sagt: Das Maximum\n",
" kann bis kappa(A) betragen - und nur bis dahin.\n",
" \"\"\"\n",
" x_wahr = np.ones(A.shape[1])\n",
" b = A @ x_wahr\n",
" faktoren = []\n",
" for _ in range(versuche):\n",
" richtung = RNG.normal(size=b.size)\n",
" richtung /= np.linalg.norm(richtung)\n",
" b_gestoert = b + stoerung * np.linalg.norm(b) * richtung\n",
" x_gestoert = np.linalg.solve(A, b_gestoert)\n",
" rel_x = np.linalg.norm(x_gestoert - x_wahr) / np.linalg.norm(x_wahr)\n",
" faktoren.append(rel_x / stoerung)\n",
" return float(np.median(faktoren)), float(np.max(faktoren))\n",
"\n",
"\n",
"def zeige_experiment_1() -> None:\n",
" print(\"=\" * 74)\n",
" print(\" 1. KONDITIONSZAHL ALS FEHLERVERSTAERKER\")\n",
" print(\"=\" * 74)\n",
" print(\"Zwei Gleichungssysteme, beide exakt loesbar mit x = (1, 1).\")\n",
" print(\"Die rechte Seite wird um relativ 1e-10 gestoert - so viel Unsicherheit\")\n",
" print(\"steckt in JEDER gemessenen Betriebszahl allemal.\\n\")\n",
"\n",
" modelle = {\n",
" \"gut konditioniert\": np.array([[2.0, 1.0], [1.0, 3.0]]),\n",
" \"fast parallele Zeilen\": np.array([[1.0, 1.0], [1.0, 1.0 + 1e-8]]),\n",
" }\n",
" print(f\"{'Matrix':<24} {'kappa(A)':>12} {'Verst. median':>14} {'Verst. max':>12}\")\n",
" print(\"-\" * 74)\n",
" for name, A in modelle.items():\n",
" median, maximum = fehlerverstaerkung(A)\n",
" print(f\"{name:<24} {np.linalg.cond(A):>12.2e} {median:>14.2e} {maximum:>12.2e}\")\n",
"\n",
" print(\"\\nLesart: Bei der zweiten Matrix wird aus einem Datenfehler in der\")\n",
" print(\"10. Nachkommastelle ein Loesungsfehler in der 2. Nachkommastelle.\")\n",
" print(\"Das Modell ist mathematisch korrekt - und praktisch wertlos.\")\n",
"\n",
"\n",
"# --- Experiment 2: Ruiz-Equilibrierung --------------------------------------\n",
"\n",
"def ruiz_equilibrierung(A: np.ndarray, durchlaeufe: int = 20\n",
" ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:\n",
" \"\"\"Skaliert A iterativ so, dass alle Zeilen- und Spaltenmaxima nahe 1\n",
" liegen (Ruiz 2001).\n",
"\n",
" In jedem Durchlauf wird jede Zeile durch die Wurzel ihres Betragsmaximums\n",
" geteilt, danach jede Spalte. Das Verfahren konvergiert schnell und braucht\n",
" keinerlei Wissen ueber die Bedeutung der Zahlen - genau deshalb steckt es\n",
" in jedem ernsthaften Solver als Vorverarbeitung.\n",
"\n",
" Liefert (A_skaliert, zeilenfaktor r, spaltenfaktor c) mit\n",
" A_skaliert = diag(r) @ A @ diag(c)\n",
" \"\"\"\n",
" m, n = A.shape\n",
" r = np.ones(m)\n",
" c = np.ones(n)\n",
" A_s = A.astype(float).copy()\n",
"\n",
" for _ in range(durchlaeufe):\n",
" zeilen_max = np.abs(A_s).max(axis=1)\n",
" zeilen_max[zeilen_max == 0] = 1.0\n",
" d_r = 1.0 / np.sqrt(zeilen_max)\n",
" A_s = d_r[:, None] * A_s\n",
" r *= d_r\n",
"\n",
" spalten_max = np.abs(A_s).max(axis=0)\n",
" spalten_max[spalten_max == 0] = 1.0\n",
" d_c = 1.0 / np.sqrt(spalten_max)\n",
" A_s = A_s * d_c[None, :]\n",
" c *= d_c\n",
"\n",
" return A_s, r, c\n",
"\n",
"\n",
"def zeige_experiment_2() -> None:\n",
" print(\"\\n\" + \"=\" * 74)\n",
" print(\" 2. RUIZ-EQUILIBRIERUNG: EINHEITEN GERADERUECKEN\")\n",
" print(\"=\" * 74)\n",
" print(\"Ein Produktionsmodell, in dem vier Ressourcen in voellig\")\n",
" print(\"verschiedenen Einheiten gemessen werden:\")\n",
" print(\" Zeile 1: Kapitalbindung in Euro (Groessenordnung 1e7)\")\n",
" print(\" Zeile 2: Katalysatorverbrauch in Tonnen (Groessenordnung 1e-3)\")\n",
" print(\" Zeile 3: Energie in Wattsekunden (Groessenordnung 1e5)\")\n",
" print(\" Zeile 4: Ausschussquote als Anteil (Groessenordnung 1e-2)\\n\")\n",
"\n",
" # Quadratisch gewaehlt, damit die Loesung eindeutig ist und die\n",
" # Ruecktransformation unten wirklich etwas beweist.\n",
" n = 4\n",
" grundmatrix = RNG.uniform(0.5, 2.0, size=(n, n))\n",
" einheiten = np.array([1e7, 1e-3, 1e5, 1e-2])\n",
" A = grundmatrix * einheiten[:, None]\n",
"\n",
" A_s, r, c = ruiz_equilibrierung(A)\n",
"\n",
" print(f\"{'':<28} {'kappa(A)':>12} {'groesster Eintrag':>18} \"\n",
" f\"{'kleinster Eintrag':>18}\")\n",
" print(\"-\" * 74)\n",
" for name, matrix in [(\"vor der Skalierung\", A), (\"nach Ruiz-Equilibrierung\", A_s)]:\n",
" betraege = np.abs(matrix)\n",
" print(f\"{name:<28} {np.linalg.cond(matrix):>12.2e} \"\n",
" f\"{betraege.max():>18.2e} {betraege.min():>18.2e}\")\n",
"\n",
" # Gegenprobe: Das skalierte Modell beschreibt dasselbe Problem. Wer x_s\n",
" # loest, erhaelt die urspruengliche Loesung durch x = c * x_s.\n",
" x_wahr = RNG.uniform(1.0, 5.0, size=n)\n",
" b = A @ x_wahr\n",
" b_s = r * b\n",
" x_s = np.linalg.solve(A_s, b_s)\n",
" x_zurueck = c * x_s\n",
" print(f\"\\nRuecktransformation x = c * x_s: groesste Abweichung zur wahren \"\n",
" f\"Loesung {np.abs(x_zurueck - x_wahr).max():.2e}\")\n",
" print(\"Die Skalierung ist also verlustfrei - sie aendert nur die Zahlen,\")\n",
" print(\"nicht das Problem.\")\n",
"\n",
"\n",
"# --- Experiment 3: Toleranzen und der int()-Fehler --------------------------\n",
"\n",
"def zeige_experiment_3() -> None:\n",
" print(\"\\n\" + \"=\" * 74)\n",
" print(\" 3. TOLERANZEN: WARUM int() DIE FALSCHE RUNDUNG IST\")\n",
" print(\"=\" * 74)\n",
"\n",
" # Ein LP, dessen Optimum bei x = 1 liegt, aber vom Solver nur bis auf\n",
" # seine Toleranz getroffen wird.\n",
" ergebnis = linprog(c=[-1.0], A_ub=[[1.0]], b_ub=[1.0],\n",
" bounds=[(0, None)], method=\"highs\")\n",
" wert = float(ergebnis.x[0])\n",
" print(f\"Solver liefert x = {wert!r}\")\n",
"\n",
" # Typische Werte, wie sie aus MILP-Solvern zurueckkommen.\n",
" beispiele = [0.99999998, 1.00000002, 0.49999999, 2.9999999]\n",
" print(f\"\\n{'Solverwert':>14} {'int()':>8} {'round()':>9} {'Kommentar'}\")\n",
" print(\"-\" * 74)\n",
" kommentare = {\n",
" 0.99999998: \"int() macht aus einer JA- eine NEIN-Entscheidung\",\n",
" 1.00000002: \"hier ginge int() zufaellig gut - Verlass ist keiner\",\n",
" 0.49999999: \"echt unentschieden: Modell oder Toleranz pruefen!\",\n",
" 2.9999999: \"3 Maschinen werden zu 2 - der Plan geht nicht auf\",\n",
" }\n",
" for wert_b in beispiele:\n",
" print(f\"{wert_b:>14.8f} {int(wert_b):>8} {round(wert_b):>9} \"\n",
" f\"{kommentare[wert_b]}\")\n",
"\n",
" print(\"\\nRichtige Vorgehensweise: gegen die Solver-Toleranz pruefen,\")\n",
" print(\"dann erst runden - und den Zweifelsfall melden statt still zu raten.\")\n",
"\n",
" def sichere_ganzzahl(wert: float, toleranz: float = 1e-6) -> int:\n",
" naechste = round(wert)\n",
" if abs(wert - naechste) > toleranz:\n",
" raise ValueError(\n",
" f\"{wert} ist {abs(wert - naechste):.2e} von der naechsten ganzen \"\n",
" f\"Zahl entfernt - das ist mehr als die Toleranz {toleranz}. \"\n",
" \"Ganzzahligkeit im Modell pruefen.\")\n",
" return naechste\n",
"\n",
" for wert_b in beispiele:\n",
" try:\n",
" print(f\" sichere_ganzzahl({wert_b}) = {sichere_ganzzahl(wert_b)}\")\n",
" except ValueError as fehler:\n",
" print(f\" sichere_ganzzahl({wert_b}) -> ValueError: {fehler}\")\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" zeige_experiment_1()\n",
" zeige_experiment_2()\n",
" zeige_experiment_3()\n",
" print(\"\\n\" + \"=\" * 74)\n",
" print(\"Merksatz: Skalieren Sie Ihre Daten, BEVOR der Solver sie sieht -\")\n",
" print(\"und runden Sie Solver-Ergebnisse NIE ohne Toleranzpruefung.\")\n",
" print(\"=\" * 74)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -0,0 +1,644 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 8: Graphen, Flüsse und Touren — Min-Cost-Flow, Matching und VRP\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Das Minimum-Cost-Flow-Problem (MCNFP)\n",
"\n",
"`Min_Cost_Flow.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Min_Cost_Flow.py\n",
"\"\"\"\n",
"Kapitel Graphen: Kostenminimaler Fluss durch ein Netzwerk.\n",
"\n",
"Loest dasselbe Problem zweimal:\n",
" (1) als allgemeines LP mit scipy -> zeigt die Modellstruktur\n",
" (2) mit dem spezialisierten Netzwerk-Solver von OR-Tools -> zeigt den\n",
" Geschwindigkeitsvorteil eines Verfahrens, das die Struktur ausnutzt\n",
"\n",
"Beide laufen in eigenen Prozessen nicht noetig: scipy und ortools vertragen\n",
"sich (nur ortools + highspy kollidieren, siehe Kapitel Oekosystem).\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"from scipy.optimize import linprog\n",
"\n",
"# --- Netzwerk definieren ---------------------------------------------------\n",
"KNOTEN = [\"Werk_A\", \"Werk_B\", \"Umschlag\", \"Kunde_1\", \"Kunde_2\"]\n",
"# (von, nach, Kosten je Einheit, Kapazitaet)\n",
"KANTEN = [\n",
" (\"Werk_A\", \"Umschlag\", 2.0, 15),\n",
" (\"Werk_A\", \"Kunde_1\", 5.0, 10),\n",
" (\"Werk_B\", \"Umschlag\", 4.0, 10),\n",
" (\"Werk_B\", \"Kunde_2\", 6.0, 10),\n",
" (\"Umschlag\", \"Kunde_1\", 1.0, 20),\n",
" (\"Umschlag\", \"Kunde_2\", 3.0, 10),\n",
"]\n",
"# Angebot (+) bzw. Bedarf (-) je Knoten\n",
"SALDO = {\"Werk_A\": 20, \"Werk_B\": 10, \"Umschlag\": 0, \"Kunde_1\": -15, \"Kunde_2\": -15}\n",
"\n",
"\n",
"def loese_als_lp():\n",
" \"\"\"Flussproblem als allgemeines lineares Programm.\"\"\"\n",
" n_kanten = len(KANTEN)\n",
" knoten_index = {k: i for i, k in enumerate(KNOTEN)}\n",
"\n",
" # Zielfunktion: Summe der Transportkosten\n",
" kosten = np.array([k[2] for k in KANTEN])\n",
"\n",
" # Flusserhaltung als Gleichungssystem: A_eq @ x = b_eq\n",
" A_eq = np.zeros((len(KNOTEN), n_kanten))\n",
" for e, (von, nach, _, _) in enumerate(KANTEN):\n",
" A_eq[knoten_index[von], e] = +1.0 # fliesst hinaus\n",
" A_eq[knoten_index[nach], e] = -1.0 # fliesst hinein\n",
" b_eq = np.array([SALDO[k] for k in KNOTEN], dtype=float)\n",
"\n",
" schranken = [(0, k[3]) for k in KANTEN] # 0 <= x_ij <= u_ij\n",
"\n",
" ergebnis = linprog(c=kosten, A_eq=A_eq, b_eq=b_eq, bounds=schranken, method=\"highs\")\n",
" if not ergebnis.success:\n",
" raise SystemExit(f\"Nicht loesbar: {ergebnis.message}\")\n",
" return ergebnis.fun, ergebnis.x, ergebnis.eqlin.marginals\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" # Vorabpruefung: Angebot muss Bedarf entsprechen\n",
" gesamt = sum(SALDO.values())\n",
" print(\"=\" * 78)\n",
" print(\" KOSTENMINIMALER FLUSS DURCH EIN TRANSPORTNETZ\")\n",
" print(\"=\" * 78)\n",
" print(f\"Angebot gesamt: {sum(v for v in SALDO.values() if v > 0)} | \"\n",
" f\"Bedarf gesamt: {-sum(v for v in SALDO.values() if v < 0)} | \"\n",
" f\"Saldo: {gesamt}\")\n",
" if gesamt != 0:\n",
" raise SystemExit(\"Angebot und Bedarf stimmen nicht ueberein - unloesbar!\")\n",
"\n",
" kosten_gesamt, fluss, knotenpreise = loese_als_lp()\n",
"\n",
" print(f\"\\nMinimale Transportkosten: {kosten_gesamt:,.2f} EUR\\n\")\n",
" print(f\"{'Kante':<24} {'Fluss':>7} {'Kapazitaet':>11} {'Kosten/E':>9} {'Kosten':>9}\")\n",
" print(\"-\" * 78)\n",
" for e, (von, nach, c, u) in enumerate(KANTEN):\n",
" menge = fluss[e] + 0.0 if abs(fluss[e]) > 1e-9 else 0.0 # vermeidet \"-0.0\"\n",
" ausgelastet = \" (VOLL)\" if abs(menge - u) < 1e-6 else \"\"\n",
" print(f\"{von + ' -> ' + nach:<24} {menge:>7.1f} {u:>11} \"\n",
" f\"{c:>9.2f} {menge * c:>9.2f}{ausgelastet}\")\n",
"\n",
" # --- Flusserhaltung nachpruefen --------------------------------------\n",
" print(\"\\n--- Pruefung der Flusserhaltung je Knoten ---\")\n",
" for k in KNOTEN:\n",
" hinaus = sum(fluss[e] for e, (v, n, _, _) in enumerate(KANTEN) if v == k)\n",
" hinein = sum(fluss[e] for e, (v, n, _, _) in enumerate(KANTEN) if n == k)\n",
" netto = hinaus - hinein\n",
" art = \"Quelle\" if SALDO[k] > 0 else (\"Senke\" if SALDO[k] < 0 else \"Umschlag\")\n",
" print(f\" {k:<10} ({art:<8}): hinaus {hinaus:5.1f} - hinein {hinein:5.1f} \"\n",
" f\"= {netto:+6.1f} (gefordert: {SALDO[k]:+d})\")\n",
" assert abs(netto - SALDO[k]) < 1e-6, f\"Flusserhaltung verletzt bei {k}!\"\n",
"\n",
" # --- Knotenpreise (Dualwerte) interpretieren -------------------------\n",
" print(\"\\n--- Knotenpreise (Dualwerte der Flusserhaltung) ---\")\n",
" print(\" Differenz zweier Knotenpreise = Grenzkosten einer zusaetzlichen Einheit\")\n",
" print(\" auf dem guenstigsten Weg zwischen ihnen.\")\n",
" for k, preis in zip(KNOTEN, knotenpreise):\n",
" print(f\" {k:<10}: {preis:7.2f}\")\n",
" print(\"=\" * 78)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Der Satz von Birkhoff und von Neumann\n",
"\n",
"`Zuordnung_Ungarisch.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Zuordnung_Ungarisch.py\n",
"\"\"\"\n",
"Kapitel Graphen: Das Zuordnungsproblem, dreifach geloest.\n",
"\n",
" (1) Ungarischer Algorithmus (scipy.optimize.linear_sum_assignment) - O(n^3)\n",
" (2) als LP OHNE Ganzzahligkeitsforderung -> liefert trotzdem 0/1 (Birkhoff)\n",
" (3) als MILP MIT Ganzzahligkeitsforderung -> gleiches Ergebnis, mehr Aufwand\n",
"\n",
"Zeigt damit die praktische Bedeutung der totalen Unimodularitaet.\n",
"\"\"\"\n",
"\n",
"import time\n",
"\n",
"import numpy as np\n",
"from scipy.optimize import linear_sum_assignment, linprog\n",
"\n",
"\n",
"def erzeuge_kosten(n, seed=11):\n",
" rng = np.random.default_rng(seed)\n",
" return rng.integers(10, 99, size=(n, n)).astype(float)\n",
"\n",
"\n",
"def loese_ungarisch(kosten):\n",
" zeilen, spalten = linear_sum_assignment(kosten)\n",
" return kosten[zeilen, spalten].sum(), spalten\n",
"\n",
"\n",
"def baue_lp(kosten):\n",
" \"\"\"Gemeinsame LP-Struktur fuer Variante 2 und 3.\"\"\"\n",
" n = len(kosten)\n",
" c = kosten.flatten() # x_ij in Zeilenreihenfolge\n",
" A_eq = np.zeros((2 * n, n * n))\n",
" for i in range(n): # jede Person genau eine Aufgabe\n",
" A_eq[i, i * n:(i + 1) * n] = 1.0\n",
" for j in range(n): # jede Aufgabe genau einer Person\n",
" A_eq[n + j, j::n] = 1.0\n",
" b_eq = np.ones(2 * n)\n",
" return c, A_eq, b_eq\n",
"\n",
"\n",
"def loese_lp(kosten, ganzzahlig):\n",
" n = len(kosten)\n",
" c, A_eq, b_eq = baue_lp(kosten)\n",
" ergebnis = linprog(c=c, A_eq=A_eq, b_eq=b_eq, bounds=[(0, 1)] * (n * n),\n",
" integrality=np.ones(n * n) if ganzzahlig else None,\n",
" method=\"highs\")\n",
" x = ergebnis.x.reshape(n, n)\n",
" return ergebnis.fun, x\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 84)\n",
" print(\" ZUORDNUNGSPROBLEM: DREI WEGE ZUM SELBEN ERGEBNIS\")\n",
" print(\"=\" * 84)\n",
"\n",
" # --- Kleines Beispiel zum Nachvollziehen ------------------------------\n",
" kosten = np.array([[82., 83., 69., 92.],\n",
" [77., 37., 49., 92.],\n",
" [11., 69., 5., 86.],\n",
" [8., 9., 98., 23.]])\n",
" namen = [\"Anna\", \"Ben\", \"Carla\", \"David\"]\n",
" aufgaben = [\"Auftrag W\", \"Auftrag X\", \"Auftrag Y\", \"Auftrag Z\"]\n",
"\n",
" print(\"\\nKostenmatrix (wer bearbeitet was zu welchen Kosten?):\")\n",
" print(f\"{'':<8}\" + \"\".join(f\"{a:>12}\" for a in aufgaben))\n",
" for i, name in enumerate(namen):\n",
" print(f\"{name:<8}\" + \"\".join(f\"{kosten[i, j]:>12.0f}\" for j in range(4)))\n",
"\n",
" wert, zuordnung = loese_ungarisch(kosten)\n",
" print(f\"\\nOptimale Zuordnung (Gesamtkosten {wert:.0f}):\")\n",
" for i, j in enumerate(zuordnung):\n",
" print(f\" {namen[i]:<8} -> {aufgaben[j]:<12} ({kosten[i, j]:.0f} EUR)\")\n",
"\n",
" # --- Nachweis: LP ohne Ganzzahligkeit liefert trotzdem 0/1 -----------\n",
" wert_lp, x_lp = loese_lp(kosten, ganzzahlig=False)\n",
" ist_binaer = np.all((np.abs(x_lp) < 1e-9) | (np.abs(x_lp - 1) < 1e-9))\n",
" print(f\"\\nLP OHNE Ganzzahligkeitsforderung: Kosten {wert_lp:.0f}, \"\n",
" f\"Loesung ist {'0/1-wertig' if ist_binaer else 'GEBROCHEN'}\")\n",
" print(\" -> Satz von Birkhoff/von Neumann bestaetigt: Die Ecken sind Permutationen.\")\n",
"\n",
" # --- Laufzeitvergleich bei wachsender Groesse ------------------------\n",
" print(\"\\n\" + \"-\" * 84)\n",
" print(f\"{'n':>4} | {'Ungarisch':>12} | {'LP (kontinuierlich)':>21} | \"\n",
" f\"{'MILP (ganzzahlig)':>19} | {'gleich?':>8}\")\n",
" print(\"-\" * 84)\n",
" for n in [10, 25, 50, 100]:\n",
" k = erzeuge_kosten(n)\n",
"\n",
" t0 = time.perf_counter(); w1, _ = loese_ungarisch(k); t1 = time.perf_counter() - t0\n",
" t0 = time.perf_counter(); w2, _ = loese_lp(k, False); t2 = time.perf_counter() - t0\n",
" if n <= 50:\n",
" t0 = time.perf_counter(); w3, _ = loese_lp(k, True); t3 = time.perf_counter() - t0\n",
" t3_text, gleich = f\"{t3*1000:>16.1f} ms\", abs(w1 - w3) < 1e-6\n",
" else:\n",
" t3_text, gleich = f\"{'uebersprungen':>19}\", abs(w1 - w2) < 1e-6\n",
"\n",
" print(f\"{n:>4} | {t1*1000:>9.1f} ms | {t2*1000:>18.1f} ms | {t3_text} | \"\n",
" f\"{'ja' if gleich else 'NEIN':>8}\")\n",
"\n",
" print(\"-\" * 84)\n",
" print(\"Fazit: Der spezialisierte Ungarische Algorithmus ist um Groessenordnungen\")\n",
" print(\"schneller. Nutzen Sie fuer reine Zuordnungen NIE einen MILP-Solver.\")\n",
" print(\"=\" * 84)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Praxisbeispiel: Flotten-Routing\n",
"\n",
"`VRP_Flotten_Routing.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# VRP_Flotten_Routing.py\n",
"\"\"\"\n",
"Kapitel Graphen: Capacitated Vehicle Routing Problem with Time Windows (CVRPTW)\n",
"mit der Routing-Bibliothek von Google OR-Tools.\n",
"\n",
"Eigenschaften:\n",
" * Eingabedaten werden vorab auf Plausibilitaet geprueft (Kapazitaet\n",
" ausreichend? Zeitfenster erreichbar?)\n",
" * Fahrzeit und Servicezeit werden getrennt ausgewiesen\n",
" * Ausgabe als lesbarer Tourenplan mit Ankunftszeiten\n",
" * Kennzahlen: Auslastung, Leerfahrten, Wartezeit\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"from ortools.constraint_solver import pywrapcp, routing_enums_pb2\n",
"\n",
"SERVICEZEIT = 10 # Minuten je Kundenstopp\n",
"WARTEZEIT_MAX = 60 # zulaessige Wartezeit bei zu frueher Ankunft\n",
"SCHICHTLAENGE = 600 # Minuten\n",
"\n",
"\n",
"def erzeuge_daten(seed: int = 42):\n",
" \"\"\"Synthetische, aber reproduzierbare Instanz: 1 Depot + 16 Kunden.\"\"\"\n",
" anzahl_orte = 17\n",
" rng = np.random.default_rng(seed)\n",
" koordinaten = rng.random((anzahl_orte, 2)) * 100 # 100 x 100 km Raster\n",
"\n",
" distanz = np.zeros((anzahl_orte, anzahl_orte), dtype=int)\n",
" for i in range(anzahl_orte):\n",
" for j in range(anzahl_orte):\n",
" distanz[i][j] = int(np.linalg.norm(koordinaten[i] - koordinaten[j]))\n",
"\n",
" return {\n",
" \"distanzmatrix\": distanz.tolist(),\n",
" \"zeitfenster\": [\n",
" (0, SCHICHTLAENGE), # 0: Depot\n",
" (30, 120), (60, 180), (100, 240), (150, 300), # Kunden 1-4\n",
" (60, 180), (120, 240), (200, 360), (300, 450), # Kunden 5-8\n",
" (180, 300), (240, 360), (300, 480), (360, 500), # Kunden 9-12\n",
" (60, 200), (120, 300), (240, 400), (300, 550), # Kunden 13-16\n",
" ],\n",
" \"bedarfe\": [0, 2, 3, 1, 4, 2, 2, 3, 1, 2, 4, 3, 2, 1, 2, 3, 2],\n",
" \"kapazitaeten\": [10, 10, 10, 10],\n",
" \"anzahl_fahrzeuge\": 4,\n",
" \"depot\": 0,\n",
" }\n",
"\n",
"\n",
"def pruefe_daten(daten) -> None:\n",
" \"\"\"Vorabdiagnose - fangt die haeufigsten Ursachen fuer 'keine Loesung' ab.\"\"\"\n",
" gesamtbedarf = sum(daten[\"bedarfe\"])\n",
" gesamtkapazitaet = sum(daten[\"kapazitaeten\"])\n",
" print(f\"Gesamtbedarf {gesamtbedarf} Einheiten | \"\n",
" f\"Flottenkapazitaet {gesamtkapazitaet} Einheiten | \"\n",
" f\"Auslastung {gesamtbedarf / gesamtkapazitaet * 100:.0f} %\")\n",
" if gesamtbedarf > gesamtkapazitaet:\n",
" raise SystemExit(\"UNLOESBAR: Der Bedarf uebersteigt die Flottenkapazitaet.\")\n",
"\n",
" d = daten[\"distanzmatrix\"]\n",
" for kunde, (fruehestens, spaetestens) in enumerate(daten[\"zeitfenster\"]):\n",
" if kunde == 0:\n",
" continue\n",
" direktfahrt = d[0][kunde]\n",
" if direktfahrt > spaetestens:\n",
" raise SystemExit(\n",
" f\"UNLOESBAR: Kunde {kunde} ist erst nach {direktfahrt} min erreichbar, \"\n",
" f\"sein Zeitfenster endet aber bei {spaetestens} min.\")\n",
" print(\"Vorabpruefung bestanden: Kapazitaet und Zeitfenster sind grundsaetzlich machbar.\")\n",
"\n",
"\n",
"def loese_cvrptw(zeitlimit_s: int = 5):\n",
" daten = erzeuge_daten()\n",
" pruefe_daten(daten)\n",
"\n",
" manager = pywrapcp.RoutingIndexManager(\n",
" len(daten[\"distanzmatrix\"]), daten[\"anzahl_fahrzeuge\"], daten[\"depot\"])\n",
" routing = pywrapcp.RoutingModel(manager)\n",
"\n",
" # --- Fahrzeit + Servicezeit als Kantengewicht ------------------------\n",
" def zeit_callback(von_index, nach_index):\n",
" von = manager.IndexToNode(von_index)\n",
" nach = manager.IndexToNode(nach_index)\n",
" service = SERVICEZEIT if von != daten[\"depot\"] else 0\n",
" return daten[\"distanzmatrix\"][von][nach] + service\n",
"\n",
" zeit_index = routing.RegisterTransitCallback(zeit_callback)\n",
" routing.SetArcCostEvaluatorOfAllVehicles(zeit_index)\n",
"\n",
" # --- Kapazitaetsdimension ---------------------------------------------\n",
" def bedarf_callback(von_index):\n",
" return daten[\"bedarfe\"][manager.IndexToNode(von_index)]\n",
"\n",
" bedarf_index = routing.RegisterUnaryTransitCallback(bedarf_callback)\n",
" routing.AddDimensionWithVehicleCapacity(\n",
" bedarf_index, 0, daten[\"kapazitaeten\"], True, \"Kapazitaet\")\n",
"\n",
" # --- Zeitdimension mit Zeitfenstern -----------------------------------\n",
" routing.AddDimension(zeit_index, WARTEZEIT_MAX, SCHICHTLAENGE, False, \"Zeit\")\n",
" zeit_dimension = routing.GetDimensionOrDie(\"Zeit\")\n",
" for ort, (fruehestens, spaetestens) in enumerate(daten[\"zeitfenster\"]):\n",
" zeit_dimension.CumulVar(manager.NodeToIndex(ort)).SetRange(fruehestens, spaetestens)\n",
"\n",
" # --- Suchparameter -----------------------------------------------------\n",
" parameter = pywrapcp.DefaultRoutingSearchParameters()\n",
" parameter.first_solution_strategy = (\n",
" routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC)\n",
" parameter.local_search_metaheuristic = (\n",
" routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH)\n",
" parameter.time_limit.seconds = zeitlimit_s\n",
"\n",
" loesung = routing.SolveWithParameters(parameter)\n",
" if not loesung:\n",
" print(\"Keine zulaessige Routenfuehrung gefunden.\")\n",
" return\n",
"\n",
" # --- Auswertung --------------------------------------------------------\n",
" print(\"\\n\" + \"=\" * 84)\n",
" print(\" OPTIMIERTER TOURENPLAN (CVRPTW)\")\n",
" print(\"=\" * 84)\n",
"\n",
" gesamtzeit = gesamtfracht = gesamtdistanz = 0\n",
" kapazitaet = daten[\"kapazitaeten\"]\n",
"\n",
" for fahrzeug in range(daten[\"anzahl_fahrzeuge\"]):\n",
" index = routing.Start(fahrzeug)\n",
" if routing.IsEnd(loesung.Value(routing.NextVar(index))):\n",
" print(f\"\\nFahrzeug {fahrzeug + 1}: nicht eingesetzt\")\n",
" continue\n",
"\n",
" stationen, fracht, distanz = [], 0, 0\n",
" while not routing.IsEnd(index):\n",
" knoten = manager.IndexToNode(index)\n",
" ankunft = loesung.Min(zeit_dimension.CumulVar(index))\n",
" fracht += daten[\"bedarfe\"][knoten]\n",
" bezeichnung = \"Depot\" if knoten == 0 else f\"K{knoten}\"\n",
" stationen.append(f\"{bezeichnung}@{ankunft}\")\n",
" naechster = loesung.Value(routing.NextVar(index))\n",
" distanz += daten[\"distanzmatrix\"][knoten][manager.IndexToNode(naechster)]\n",
" index = naechster\n",
"\n",
" endzeit = loesung.Min(zeit_dimension.CumulVar(index))\n",
" stationen.append(f\"Depot@{endzeit}\")\n",
" gesamtzeit += endzeit\n",
" gesamtfracht += fracht\n",
" gesamtdistanz += distanz\n",
"\n",
" print(f\"\\nFahrzeug {fahrzeug + 1}:\")\n",
" print(\" \" + \" -> \".join(stationen))\n",
" print(f\" Schichtzeit {endzeit} min | Fahrstrecke {distanz} km | \"\n",
" f\"Fracht {fracht}/{kapazitaet[fahrzeug]} \"\n",
" f\"({fracht / kapazitaet[fahrzeug] * 100:.0f} % Auslastung)\")\n",
"\n",
" print(\"\\n\" + \"-\" * 84)\n",
" print(f\"Summe Schichtzeiten: {gesamtzeit} min\")\n",
" print(f\"Summe Fahrstrecken: {gesamtdistanz} km\")\n",
" print(f\"Transportierte Fracht: {gesamtfracht} von {sum(daten['bedarfe'])} Einheiten\")\n",
" assert gesamtfracht == sum(daten[\"bedarfe\"]), \"Nicht alle Kunden wurden beliefert!\"\n",
" print(\"Alle Kunden wurden innerhalb ihrer Zeitfenster beliefert.\")\n",
" print(\"=\" * 84)\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" loese_cvrptw()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Finde den Denkfehler\n",
"\n",
"`VRP_Kapazitaetsfalle.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# VRP_Kapazitaetsfalle.py\n",
"\"\"\"\n",
"Kapitel Graphen: Die vergessene Dimension.\n",
"\n",
"Die Routing-Bibliothek von OR-Tools kennt keine \"Kapazitaet\" von sich aus.\n",
"Sie kennt nur DIMENSIONEN - benannte Groessen, die sich entlang einer Tour\n",
"aufsummieren und begrenzt werden koennen. Distanz ist eine, Zeit ist eine,\n",
"Ladung ist eine. Wer eine davon nicht anlegt, bekommt trotzdem eine Loesung:\n",
"eine schoene, kurze, guenstige - und unfahrbare.\n",
"\n",
"Dieses Programm loest dieselbe Instanz zweimal und prueft beide Ergebnisse\n",
"gegen die tatsaechlichen Lademengen.\n",
"\n",
"Instanz: 1 Depot, 16 Kunden, 4 Fahrzeuge zu je 10 Paletten.\n",
"Gesamtbedarf 37 Paletten bei 40 Paletten Flottenkapazitaet - es ist also\n",
"knapp, aber machbar.\n",
"\n",
"Benoetigt: numpy, ortools\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import numpy as np\n",
"from ortools.constraint_solver import pywrapcp, routing_enums_pb2\n",
"\n",
"# Dieselbe Instanz wie VRP_Flotten_Routing.py\n",
"BEDARFE = [0, 2, 3, 1, 4, 2, 2, 3, 1, 2, 4, 3, 2, 1, 2, 3, 2]\n",
"KAPAZITAETEN = [10, 10, 10, 10]\n",
"ANZAHL_FAHRZEUGE = 4\n",
"DEPOT = 0\n",
"\n",
"\n",
"def distanzmatrix(seed: int = 42) -> list[list[int]]:\n",
" rng = np.random.default_rng(seed)\n",
" koordinaten = rng.random((len(BEDARFE), 2)) * 100 # 100 x 100 km\n",
" n = len(BEDARFE)\n",
" return [[int(np.linalg.norm(koordinaten[i] - koordinaten[j]))\n",
" for j in range(n)] for i in range(n)]\n",
"\n",
"\n",
"def plane(mit_kapazitaet: bool, zeitlimit: int = 5) -> dict:\n",
" \"\"\"Loest die Tourenplanung - wahlweise mit oder ohne Ladungsdimension.\"\"\"\n",
" distanz = distanzmatrix()\n",
" manager = pywrapcp.RoutingIndexManager(len(distanz), ANZAHL_FAHRZEUGE, DEPOT)\n",
" routing = pywrapcp.RoutingModel(manager)\n",
"\n",
" def entfernung(von_index, nach_index):\n",
" return distanz[manager.IndexToNode(von_index)][manager.IndexToNode(nach_index)]\n",
"\n",
" kosten_id = routing.RegisterTransitCallback(entfernung)\n",
" routing.SetArcCostEvaluatorOfAllVehicles(kosten_id)\n",
"\n",
" # DIE entscheidende Stelle. Ohne diesen Block existiert im Modell keine\n",
" # Ladung - die Fahrzeuge sind dann unendlich gross.\n",
" if mit_kapazitaet:\n",
" def bedarf(index):\n",
" return BEDARFE[manager.IndexToNode(index)]\n",
"\n",
" bedarf_id = routing.RegisterUnaryTransitCallback(bedarf)\n",
" routing.AddDimensionWithVehicleCapacity(\n",
" bedarf_id,\n",
" 0, # kein Zwischenpuffer\n",
" KAPAZITAETEN, # Obergrenze je Fahrzeug\n",
" True, # Ladung startet bei 0\n",
" \"Ladung\")\n",
"\n",
" parameter = pywrapcp.DefaultRoutingSearchParameters()\n",
" parameter.first_solution_strategy = (\n",
" routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC)\n",
" parameter.local_search_metaheuristic = (\n",
" routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH)\n",
" parameter.time_limit.FromSeconds(zeitlimit)\n",
"\n",
" loesung = routing.SolveWithParameters(parameter)\n",
" if loesung is None:\n",
" raise RuntimeError(\"Keine Loesung gefunden\")\n",
"\n",
" touren, strecken, ladungen = [], [], []\n",
" for fahrzeug in range(ANZAHL_FAHRZEUGE):\n",
" index = routing.Start(fahrzeug)\n",
" tour, strecke, ladung = [], 0, 0\n",
" while not routing.IsEnd(index):\n",
" knoten = manager.IndexToNode(index)\n",
" tour.append(knoten)\n",
" ladung += BEDARFE[knoten]\n",
" vorher = index\n",
" index = loesung.Value(routing.NextVar(index))\n",
" strecke += routing.GetArcCostForVehicle(vorher, index, fahrzeug)\n",
" tour.append(manager.IndexToNode(index))\n",
" touren.append(tour)\n",
" strecken.append(strecke)\n",
" ladungen.append(ladung)\n",
"\n",
" return {\"touren\": touren, \"strecken\": strecken, \"ladungen\": ladungen,\n",
" \"gesamtstrecke\": sum(strecken)}\n",
"\n",
"\n",
"def pruefe(ergebnis: dict) -> list[str]:\n",
" \"\"\"Prueft den Plan gegen die Wirklichkeit - unabhaengig vom Modell.\n",
"\n",
" Genau diese Trennung ist der Punkt: Die Pruefung darf nicht dieselben\n",
" Annahmen benutzen wie das Modell, sonst prueft sie nichts.\n",
" \"\"\"\n",
" beanstandungen = []\n",
" for fahrzeug, (ladung, kapazitaet) in enumerate(\n",
" zip(ergebnis[\"ladungen\"], KAPAZITAETEN)):\n",
" if ladung > kapazitaet:\n",
" beanstandungen.append(\n",
" f\"Fahrzeug {fahrzeug + 1}: {ladung} Paletten geladen, \"\n",
" f\"Kapazitaet {kapazitaet} ({ladung - kapazitaet} zu viel)\")\n",
"\n",
" beliefert = sorted(k for tour in ergebnis[\"touren\"] for k in tour[1:-1])\n",
" erwartet = list(range(1, len(BEDARFE)))\n",
" if beliefert != erwartet:\n",
" fehlend = set(erwartet) - set(beliefert)\n",
" if fehlend:\n",
" beanstandungen.append(f\"nicht beliefert: {sorted(fehlend)}\")\n",
" return beanstandungen\n",
"\n",
"\n",
"def zeige(titel: str, ergebnis: dict) -> None:\n",
" print(f\"\\n{titel}\")\n",
" print(f\" Gesamtstrecke {ergebnis['gesamtstrecke']} km\")\n",
" print(f\" {'Fahrzeug':<10} {'Stopps':>7} {'Strecke':>9} {'Ladung':>8} \"\n",
" f\"{'Kapazitaet':>11}\")\n",
" for i, (tour, strecke, ladung) in enumerate(\n",
" zip(ergebnis[\"touren\"], ergebnis[\"strecken\"], ergebnis[\"ladungen\"])):\n",
" markierung = \" <-- ueberladen\" if ladung > KAPAZITAETEN[i] else \"\"\n",
" print(f\" {i + 1:<10} {len(tour) - 2:>7} {strecke:>8} km {ladung:>8} \"\n",
" f\"{KAPAZITAETEN[i]:>11}{markierung}\")\n",
"\n",
" beanstandungen = pruefe(ergebnis)\n",
" if beanstandungen:\n",
" print(\" PRUEFUNG: DURCHGEFALLEN\")\n",
" for text in beanstandungen:\n",
" print(f\" - {text}\")\n",
" else:\n",
" print(\" PRUEFUNG: bestanden\")\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 78)\n",
" print(\" DIE VERGESSENE DIMENSION\")\n",
" print(\"=\" * 78)\n",
" print(f\"16 Kunden, Gesamtbedarf {sum(BEDARFE)} Paletten, \"\n",
" f\"{ANZAHL_FAHRZEUGE} Fahrzeuge zu je {KAPAZITAETEN[0]} \"\n",
" f\"= {sum(KAPAZITAETEN)} Paletten Flottenkapazitaet.\")\n",
"\n",
" ohne = plane(mit_kapazitaet=False)\n",
" zeige(\"[1] Ohne Ladungsdimension\", ohne)\n",
"\n",
" mit = plane(mit_kapazitaet=True)\n",
" zeige(\"[2] Mit AddDimensionWithVehicleCapacity\", mit)\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" mehr = mit[\"gesamtstrecke\"] - ohne[\"gesamtstrecke\"]\n",
" print(f\"Der korrekte Plan ist {mehr} km laenger \"\n",
" f\"({mehr / ohne['gesamtstrecke'] * 100:.1f} %).\")\n",
" print()\n",
" print(\"Und genau darin liegt die Gefahr: Lauf [1] sieht BESSER aus. Wer\")\n",
" print(\"beide Zahlen nebeneinander legt, ohne die Ladung zu pruefen, haelt\")\n",
" print(\"die unfahrbare Loesung fuer die bessere Optimierung - und den\")\n",
" print(\"korrekten Plan fuer schlechte Arbeit.\")\n",
" print()\n",
" print(\"Die Routing-Bibliothek kennt keine 'Kapazitaet'. Sie kennt nur\")\n",
" print(\"Dimensionen, die man ihr anlegt. Was nicht als Dimension existiert,\")\n",
" print(\"wird nicht begrenzt - und faellt niemandem auf, weil das Ergebnis\")\n",
" print(\"plausibel aussieht.\")\n",
" print(\"=\" * 78)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -0,0 +1,623 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 21: Die vollständige quantitative Handelsmaschine\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Die Engine\n",
"\n",
"`QuantitativeTradingEngine.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# QuantitativeTradingEngine.py\n",
"\"\"\"\n",
"Kapitel Handelsmaschine: Vollstaendige quantitative Handelsmaschine mit\n",
"Walk-Forward-Backtest und OR-Optimierungsschicht.\n",
"\n",
"Eigenschaften:\n",
" * Rebalancing-Termine sind garantiert Handelstage; die Zahl der\n",
" tatsaechlich ausgefuehrten Umschichtungen wird ausgewiesen\n",
" * Spaltenreihenfolge erzwungen\n",
" * Einheiten konsistent (taegliche Groessen im Modell)\n",
" * Szenario-Nebenbedingungen vektorisiert\n",
" * Lookahead-Selbsttest eingebaut\n",
" * Zusaetzliche Kennzahlen: Calmar, Trefferquote, Turnover, Kostenanteil\n",
"\"\"\"\n",
"\n",
"import os\n",
"\n",
"import cvxpy as cp\n",
"import numpy as np\n",
"import pandas as pd\n",
"import yfinance as yf\n",
"from sklearn.covariance import LedoitWolf\n",
"import matplotlib\n",
"matplotlib.use(\"Agg\")\n",
"import matplotlib.pyplot as plt\n",
"\n",
"OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"output\")\n",
"os.makedirs(OUTPUT_DIR, exist_ok=True)\n",
"\n",
"HANDELSTAGE = 252\n",
"\n",
"\n",
"class QuantitativeHandelsmaschine:\n",
" \"\"\"Walk-Forward-Backtest mit CVaR-Optimierung und Transaktionskosten.\"\"\"\n",
"\n",
" def __init__(self, universum, benchmark=\"SPY\", jahre=5,\n",
" startkapital=100_000.0, gebuehrensatz=0.0015,\n",
" max_gewicht=0.20, alpha=0.95, risikoaversion=1.2,\n",
" warmup=HANDELSTAGE):\n",
" self.universum = list(universum)\n",
" self.benchmark = benchmark\n",
" self.jahre = jahre\n",
" self.startkapital = startkapital\n",
" self.gebuehrensatz = gebuehrensatz\n",
" self.max_gewicht = max_gewicht\n",
" self.alpha = alpha\n",
" self.risikoaversion = risikoaversion\n",
" self.warmup = warmup\n",
"\n",
" self.kurse = None\n",
" self.benchmark_kurse = None\n",
" self.renditen = None\n",
" self.rebalancing_termine = None\n",
"\n",
" # -- 1. Datenpipeline ------------------------------------------------\n",
" def lade_daten(self):\n",
" ende = pd.Timestamp.today().normalize()\n",
" start = ende - pd.DateOffset(years=self.jahre)\n",
" alle = self.universum + [self.benchmark]\n",
"\n",
" print(f\"Lade {len(self.universum)} Titel + Benchmark ({self.benchmark}) \"\n",
" f\"ueber {self.jahre} Jahre ...\")\n",
" roh = yf.download(alle, start=start, end=ende, auto_adjust=True, progress=False)\n",
" if roh.empty:\n",
" raise SystemExit(\"Download fehlgeschlagen (Netz/Ticker/Rate-Limit pruefen).\")\n",
"\n",
" if isinstance(roh.columns, pd.MultiIndex):\n",
" daten = roh[\"Close\"][alle].dropna() # erzwingt eigene Spaltenreihenfolge\n",
" else:\n",
" daten = roh[[\"Close\"]].dropna()\n",
" daten.columns = alle\n",
" assert list(daten.columns) == alle, \"Spaltenreihenfolge weicht ab!\"\n",
"\n",
" self.kurse = daten[self.universum]\n",
" self.benchmark_kurse = daten[self.benchmark]\n",
" self.renditen = self.kurse.pct_change().dropna()\n",
"\n",
" # --- erster HANDELStag je Monat, nicht Kalendermonatsanfang -----\n",
" monat = self.kurse.index.to_period(\"M\")\n",
" self.rebalancing_termine = self.kurse.index[~monat.duplicated()]\n",
" assert self.rebalancing_termine.isin(self.kurse.index).all(), \\\n",
" \"Rebalancing-Termine sind keine Handelstage!\"\n",
"\n",
" print(f\" {len(self.kurse)} Handelstage, \"\n",
" f\"{len(self.rebalancing_termine)} Rebalancing-Termine \"\n",
" f\"(alle sind Handelstage)\")\n",
"\n",
" # -- 2. Signal --------------------------------------------------------\n",
" def alpha_signal(self, stichtag, rueckblick=HANDELSTAGE):\n",
" \"\"\"\n",
" 12-1-Momentum: Kursentwicklung der letzten 12 Monate unter Auslassung\n",
" des letzten Monats (kurzfristige Umkehr herausrechnen).\n",
" Rueckgabe: TAEGLICHE Renditeerwartung.\n",
" \"\"\"\n",
" historie = self.kurse.loc[:stichtag]\n",
" if len(historie) < rueckblick:\n",
" return np.zeros(len(self.universum))\n",
"\n",
" p_alt = historie.iloc[-rueckblick]\n",
" p_neu = historie.iloc[-21] # vor ca. einem Monat\n",
" momentum = (p_neu / p_alt - 1.0).values\n",
"\n",
" # Z-Score, damit das Signal skalenfrei wird\n",
" streuung = momentum.std()\n",
" score = (momentum - momentum.mean()) / (streuung + 1e-9)\n",
"\n",
" # In eine taegliche Renditeerwartung uebersetzen:\n",
" # Basis 4 % p.a. plus 6 % p.a. je Standardabweichung Momentum.\n",
" mu_jaehrlich = np.maximum(0.04 + 0.06 * score, 0.0)\n",
" return mu_jaehrlich / HANDELSTAGE\n",
"\n",
" # -- 3./4. Risikomodell und Optimierung -------------------------------\n",
" def optimiere(self, stichtag, w_alt, rueckblick=HANDELSTAGE):\n",
" historie = self.renditen.loc[:stichtag].iloc[-rueckblick:]\n",
" if len(historie) < 100:\n",
" return w_alt, False\n",
"\n",
" R = historie.values\n",
" S, N = R.shape\n",
" mu_taeglich = self.alpha_signal(stichtag, rueckblick)\n",
"\n",
" w = cp.Variable(N, nonneg=True)\n",
" gamma = cp.Variable()\n",
" u = cp.Variable(S, nonneg=True)\n",
"\n",
" cvar = gamma + (1.0 / (S * (1.0 - self.alpha))) * cp.sum(u)\n",
" kosten = self.gebuehrensatz * cp.norm1(w - w_alt)\n",
" ziel = cp.Maximize(mu_taeglich @ w - self.risikoaversion * cvar - kosten)\n",
"\n",
" bedingungen = [cp.sum(w) == 1,\n",
" w <= self.max_gewicht,\n",
" u >= -(R @ w) - gamma] # vektorisiert\n",
" problem = cp.Problem(ziel, bedingungen)\n",
" try:\n",
" problem.solve(solver=cp.CLARABEL)\n",
" except cp.error.SolverError:\n",
" return w_alt, False\n",
"\n",
" if problem.status not in (\"optimal\", \"optimal_inaccurate\") or w.value is None:\n",
" return w_alt, False\n",
"\n",
" gewichte = np.maximum(w.value, 0.0)\n",
" return gewichte / gewichte.sum(), True\n",
"\n",
" # -- 5. Backtest -------------------------------------------------------\n",
" def backtest(self):\n",
" print(\"Starte Walk-Forward-Backtest ...\")\n",
" N = len(self.universum)\n",
" gewichte = np.ones(N) / N\n",
"\n",
" werte = [self.startkapital]\n",
" datumsliste = [self.renditen.index[self.warmup]]\n",
" gewichtshistorie = []\n",
" geplant = versucht = ausgefuehrt = 0\n",
" kosten_gesamt = 0.0\n",
" turnover_gesamt = 0.0\n",
"\n",
" for t in range(self.warmup, len(self.renditen) - 1):\n",
" heute = self.renditen.index[t]\n",
" morgen = self.renditen.index[t + 1]\n",
"\n",
" ist_termin = heute in self.rebalancing_termine or t == self.warmup\n",
" if ist_termin:\n",
" geplant += 1\n",
" versucht += 1\n",
" neue_gewichte, erfolg = self.optimiere(heute, gewichte)\n",
" if erfolg:\n",
" ausgefuehrt += 1\n",
" turnover = float(np.abs(neue_gewichte - gewichte).sum())\n",
" gebuehr = werte[-1] * turnover * self.gebuehrensatz\n",
" werte[-1] -= gebuehr\n",
" kosten_gesamt += gebuehr\n",
" turnover_gesamt += turnover\n",
" gewichte = neue_gewichte\n",
" gewichtshistorie.append((heute, gewichte.copy()))\n",
"\n",
" # Rendite von MORGEN auf die HEUTE festgelegten Gewichte anwenden\n",
" tagesrenditen = self.renditen.iloc[t + 1].values\n",
" portfoliorendite = float(gewichte @ tagesrenditen)\n",
" werte.append(werte[-1] * (1.0 + portfoliorendite))\n",
" datumsliste.append(morgen)\n",
"\n",
" # Gewichte driften mit den Kursen weiter\n",
" gedriftet = gewichte * (1.0 + tagesrenditen)\n",
" gewichte = gedriftet / gedriftet.sum()\n",
"\n",
" print(f\" Rebalancing: {geplant} geplant, {ausgefuehrt} ausgefuehrt \"\n",
" f\"({ausgefuehrt/max(geplant,1)*100:.0f} %)\")\n",
" if ausgefuehrt < geplant:\n",
" print(f\" WARNUNG: {geplant - ausgefuehrt} Optimierungen sind \"\n",
" f\"fehlgeschlagen - Ursache pruefen!\")\n",
" print(f\" Kumulierter Turnover: {turnover_gesamt*100:.0f} % | \"\n",
" f\"Transaktionskosten insgesamt: {kosten_gesamt:,.2f} EUR\")\n",
"\n",
" ergebnis = pd.DataFrame({\"Portfolio\": werte}, index=datumsliste)\n",
" benchmark = self.benchmark_kurse.loc[datumsliste]\n",
" ergebnis[\"Benchmark\"] = benchmark / benchmark.iloc[0] * self.startkapital\n",
" return ergebnis, gewichtshistorie, kosten_gesamt\n",
"\n",
" # -- 6. Auswertung -----------------------------------------------------\n",
" @staticmethod\n",
" def kennzahlen(reihe, risikofrei=0.02):\n",
" renditen = reihe.pct_change().dropna()\n",
" jahre = (reihe.index[-1] - reihe.index[0]).days / 365.25\n",
" cagr = (reihe.iloc[-1] / reihe.iloc[0]) ** (1 / jahre) - 1\n",
" vola = renditen.std() * np.sqrt(HANDELSTAGE)\n",
" sharpe = (cagr - risikofrei) / vola if vola > 0 else np.nan\n",
" drawdown = ((reihe - reihe.cummax()) / reihe.cummax()).min()\n",
" calmar = cagr / abs(drawdown) if drawdown < 0 else np.nan\n",
" trefferquote = float((renditen > 0).mean())\n",
" return {\"CAGR\": cagr, \"Volatilitaet\": vola, \"Sharpe\": sharpe,\n",
" \"Max Drawdown\": drawdown, \"Calmar\": calmar,\n",
" \"Trefferquote\": trefferquote, \"Jahre\": jahre}\n",
"\n",
" def bericht(self, ergebnis, kosten_gesamt):\n",
" k_port = self.kennzahlen(ergebnis[\"Portfolio\"])\n",
" k_bench = self.kennzahlen(ergebnis[\"Benchmark\"])\n",
"\n",
" print(\"\\n\" + \"=\" * 84)\n",
" print(\" LEISTUNGSREPORT DER QUANTITATIVEN ENGINE\")\n",
" print(\"=\" * 84)\n",
" print(f\"Zeitraum: {ergebnis.index[0]:%Y-%m-%d} bis {ergebnis.index[-1]:%Y-%m-%d} \"\n",
" f\"({k_port['Jahre']:.1f} Jahre) | Startkapital \"\n",
" f\"{self.startkapital:,.0f} EUR\\n\")\n",
"\n",
" zeilen = [\n",
" (\"Endkapital\", f\"{ergebnis['Portfolio'].iloc[-1]:,.0f} EUR\",\n",
" f\"{ergebnis['Benchmark'].iloc[-1]:,.0f} EUR\"),\n",
" (\"CAGR (Jahresrendite)\", f\"{k_port['CAGR']*100:6.2f} %\",\n",
" f\"{k_bench['CAGR']*100:6.2f} %\"),\n",
" (\"Volatilitaet p.a.\", f\"{k_port['Volatilitaet']*100:6.2f} %\",\n",
" f\"{k_bench['Volatilitaet']*100:6.2f} %\"),\n",
" (\"Sharpe Ratio (rf=2 %)\", f\"{k_port['Sharpe']:6.2f}\",\n",
" f\"{k_bench['Sharpe']:6.2f}\"),\n",
" (\"Maximum Drawdown\", f\"{k_port['Max Drawdown']*100:6.2f} %\",\n",
" f\"{k_bench['Max Drawdown']*100:6.2f} %\"),\n",
" (\"Calmar Ratio\", f\"{k_port['Calmar']:6.2f}\", f\"{k_bench['Calmar']:6.2f}\"),\n",
" (\"Trefferquote (Tage)\", f\"{k_port['Trefferquote']*100:6.1f} %\",\n",
" f\"{k_bench['Trefferquote']*100:6.1f} %\"),\n",
" ]\n",
" print(pd.DataFrame(zeilen, columns=[\"Kennzahl\", \"OR-Strategie\", \"Benchmark\"])\n",
" .to_string(index=False))\n",
"\n",
" anteil = kosten_gesamt / self.startkapital\n",
" print(f\"\\nTransaktionskosten: {kosten_gesamt:,.0f} EUR \"\n",
" f\"= {anteil*100:.2f} % des Startkapitals \"\n",
" f\"= {anteil/k_port['Jahre']*100:.2f} % p.a.\")\n",
" print(\"Ohne diese Kosten waere die ausgewiesene Rendite entsprechend hoeher -\")\n",
" print(\"genau deshalb gehoeren sie in den Backtest und nicht daneben.\")\n",
" print(\"=\" * 84)\n",
" return k_port, k_bench\n",
"\n",
" def zeichne(self, ergebnis):\n",
" plt.figure(figsize=(11, 6))\n",
" plt.plot(ergebnis.index, ergebnis[\"Portfolio\"], \"b-\", lw=2.2,\n",
" label=\"OR-Handelsmaschine\")\n",
" plt.plot(ergebnis.index, ergebnis[\"Benchmark\"], \"k--\", lw=1.5, alpha=0.75,\n",
" label=f\"Benchmark ({self.benchmark})\")\n",
" plt.title(\"Walk-Forward-Backtest: OR-Strategie gegen Benchmark\", fontsize=12)\n",
" plt.xlabel(\"Datum\")\n",
" plt.ylabel(\"Depotwert in EUR\")\n",
" plt.grid(True, linestyle=\":\", alpha=0.6)\n",
" plt.legend(loc=\"upper left\")\n",
" plt.tight_layout()\n",
" ziel = os.path.join(OUTPUT_DIR, \"trading_engine_backtest.png\")\n",
" plt.savefig(ziel, dpi=150)\n",
" print(f\"Chart gespeichert unter '{ziel}'\")\n",
"\n",
"\n",
"def lookahead_selbsttest(maschine):\n",
" \"\"\"\n",
" Prueft, dass das Signal zum Zeitpunkt t NUR Daten bis t verwendet.\n",
" Methode: kuenstlich die Zukunft veraendern und pruefen, ob sich das\n",
" Signal von heute dadurch aendert. Tut es das, liegt Lookahead vor.\n",
" \"\"\"\n",
" stichtag = maschine.kurse.index[len(maschine.kurse) // 2]\n",
" signal_original = maschine.alpha_signal(stichtag)\n",
"\n",
" original_kurse = maschine.kurse.copy()\n",
" zukunft = maschine.kurse.index > stichtag\n",
" maschine.kurse.loc[zukunft] *= 3.0 # Zukunft massiv veraendern\n",
" signal_manipuliert = maschine.alpha_signal(stichtag)\n",
" maschine.kurse = original_kurse # zuruecksetzen\n",
"\n",
" identisch = np.allclose(signal_original, signal_manipuliert)\n",
" print(f\"Lookahead-Selbsttest: Signal bleibt bei manipulierter Zukunft \"\n",
" f\"{'UNVERAENDERT (gut)' if identisch else 'VERAENDERT - LOOKAHEAD-BIAS!'}\")\n",
" assert identisch, \"Das Signal verwendet Zukunftsdaten!\"\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" UNIVERSUM = [\"AAPL\", \"MSFT\", \"NVDA\", \"GOOGL\",\n",
" \"JNJ\", \"UNH\", \"PFE\",\n",
" \"JPM\", \"BAC\", \"GS\",\n",
" \"XOM\", \"CVX\",\n",
" \"PG\", \"KO\", \"COST\"]\n",
"\n",
" maschine = QuantitativeHandelsmaschine(UNIVERSUM, benchmark=\"SPY\", jahre=5)\n",
" maschine.lade_daten()\n",
" lookahead_selbsttest(maschine)\n",
" ergebnis, gewichte, kosten = maschine.backtest()\n",
" maschine.bericht(ergebnis, kosten)\n",
" maschine.zeichne(ergebnis)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Die fünf Selbsttäuschungen des Backtestens\n",
"\n",
"`Backtest_Fallen.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Backtest_Fallen.py\n",
"\"\"\"\n",
"Kapitel Handelsmaschine: Wie leicht man sich selbst betruegt.\n",
"\n",
"Demonstriert an SIMULIERTEN Daten (also ohne jede echte Prognosekraft),\n",
"wie gross die scheinbare Ueberrendite durch typische Backtest-Fehler wird.\n",
"Weil die Daten reines Rauschen sind, ist JEDE positive Ueberrendite ein Artefakt.\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"import pandas as pd\n",
"\n",
"\n",
"def erzeuge_zufallsmarkt(tage=1260, titel=15, seed=7):\n",
" \"\"\"Reines Rauschen: kein Titel hat echte Prognosekraft.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" renditen = rng.normal(0.0003, 0.015, size=(tage, titel))\n",
" index = pd.bdate_range(\"2020-01-01\", periods=tage)\n",
" return pd.DataFrame(renditen, index=index,\n",
" columns=[f\"T{i:02d}\" for i in range(titel)])\n",
"\n",
"\n",
"def strategie(renditen, lookahead=False, survivorship=False,\n",
" ohne_kosten=False, gebuehr=0.0015):\n",
" \"\"\"Einfache Momentum-Strategie mit optional eingebauten Fehlern.\"\"\"\n",
" fenster = 60\n",
" kapital = [100.0]\n",
" gewichte = np.ones(renditen.shape[1]) / renditen.shape[1]\n",
"\n",
" daten = renditen\n",
" if survivorship:\n",
" # FEHLER: nur die im Nachhinein besten Titel ins Universum lassen\n",
" beste = renditen.sum().nlargest(renditen.shape[1] // 2).index\n",
" daten = renditen[beste]\n",
" gewichte = np.ones(len(beste)) / len(beste)\n",
"\n",
" for t in range(fenster, len(daten) - 1):\n",
" if t % 21 == 0: # monatlich\n",
" if lookahead:\n",
" # FEHLER: Signal nutzt die Rendite von MORGEN\n",
" signal = daten.iloc[t + 1].values\n",
" else:\n",
" signal = daten.iloc[t - fenster:t].mean().values\n",
"\n",
" neue = (signal > np.median(signal)).astype(float)\n",
" neue = neue / neue.sum() if neue.sum() > 0 else gewichte\n",
" if not ohne_kosten:\n",
" kapital[-1] *= (1 - np.abs(neue - gewichte).sum() * gebuehr)\n",
" gewichte = neue\n",
"\n",
" kapital.append(kapital[-1] * (1 + float(gewichte @ daten.iloc[t + 1].values)))\n",
" return np.array(kapital)\n",
"\n",
"\n",
"def cagr(verlauf, jahre):\n",
" return (verlauf[-1] / verlauf[0]) ** (1 / jahre) - 1\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" renditen = erzeuge_zufallsmarkt()\n",
" jahre = len(renditen) / 252\n",
" markt = 100 * (1 + renditen.mean(axis=1)).cumprod().values\n",
"\n",
" print(\"=\" * 84)\n",
" print(\" DIE FUENF SELBSTTAEUSCHUNGEN - GEMESSEN AN REINEM RAUSCHEN\")\n",
" print(\"=\" * 84)\n",
" print(\"Die Daten sind zufaellig erzeugt. Es gibt KEINE echte Prognosekraft.\")\n",
" print(\"Jede Ueberrendite unten ist daher ein reines Artefakt.\\n\")\n",
" print(f\"{'Variante':<44} {'CAGR':>9} {'ggue. Markt':>13}\")\n",
" print(\"-\" * 84)\n",
"\n",
" basis = cagr(markt, jahre)\n",
" print(f\"{'Markt (Gleichgewichtung, Referenz)':<44} {basis*100:>8.2f} % \"\n",
" f\"{0.0:>12.2f} %\")\n",
"\n",
" varianten = [\n",
" (\"Ehrlich: Signal aus Vergangenheit, mit Kosten\", {}),\n",
" (\"FEHLER 1: Lookahead (Signal kennt morgen)\", {\"lookahead\": True}),\n",
" (\"FEHLER 2: Survivorship (nur Gewinner im Universum)\", {\"survivorship\": True}),\n",
" (\"FEHLER 3: Transaktionskosten ignoriert\", {\"ohne_kosten\": True}),\n",
" (\"FEHLER 1+2+3 kombiniert\", {\"lookahead\": True, \"survivorship\": True,\n",
" \"ohne_kosten\": True}),\n",
" ]\n",
" for name, argumente in varianten:\n",
" verlauf = strategie(renditen, **argumente)\n",
" wert = cagr(verlauf, jahre)\n",
" print(f\"{name:<44} {wert*100:>8.2f} % {(wert-basis)*100:>+12.2f} %\")\n",
"\n",
" print(\"-\" * 84)\n",
" print(\"FEHLER 4 (Overfitting) und FEHLER 5 (Data Snooping) lassen sich so nicht\")\n",
" print(\"zeigen - sie entstehen erst durch WIEDERHOLTES Probieren. Faustregel:\")\n",
" print(\"Wer 20 Strategien testet, findet auch in reinem Rauschen eine mit\")\n",
" print(\"Signifikanz auf dem 5-%-Niveau. Genau das ist der Punkt.\")\n",
" print(\"=\" * 84)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Finde den Denkfehler\n",
"\n",
"`Data_Snooping.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Data_Snooping.py\n",
"\"\"\"\n",
"Kapitel Handelsmaschine: Die Selbsttaeuschung, die man einem Backtest nicht ansieht.\n",
"\n",
"Backtest_Fallen.py zeigt drei Fehler, die IM Backtest stecken: Lookahead,\n",
"Survivorship, vergessene Kosten. Alle drei kann man einem Programm ansehen,\n",
"wenn man es liest.\n",
"\n",
"Die beiden anderen - Overfitting und Data Snooping - entstehen nicht im\n",
"Backtest, sondern in der ARBEITSWEISE davor. Sie hinterlassen im Code keine\n",
"Spur. Ein Backtest, der zwanzigmal angepasst wurde, sieht am Ende genauso\n",
"sauber aus wie einer, der beim ersten Versuch funktioniert hat.\n",
"\n",
"Dieses Programm macht den Effekt messbar. Es testet Strategien, die\n",
"NACHWEISLICH keinerlei Prognosekraft haben (ihre taeglichen Ergebnisse sind\n",
"Zufallszahlen mit Erwartungswert exakt null), und zeigt, wie gut die jeweils\n",
"BESTE davon aussieht - allein als Funktion der Anzahl der Versuche.\n",
"\n",
"Benoetigt: numpy, scipy\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import numpy as np\n",
"from scipy import stats\n",
"\n",
"HANDELSTAGE = 1260 # fuenf Jahre\n",
"TAGESSCHWANKUNG = 0.010 # 1 % taeglich\n",
"WIEDERHOLUNGEN = 300 # damit die Tabelle nicht vom Zufall abhaengt\n",
"NIVEAU = 0.05\n",
"\n",
"RNG = np.random.default_rng(42)\n",
"\n",
"\n",
"def bewerte(ergebnisse: np.ndarray) -> tuple[np.ndarray, np.ndarray]:\n",
" \"\"\"Sharpe-Kennzahl und einseitiger p-Wert je Strategie.\n",
"\n",
" ergebnisse hat die Form (Strategien, Handelstage). Der p-Wert prueft die\n",
" Nullhypothese 'mittleres Tagesergebnis ist null' - also genau die Frage,\n",
" die ein Backtest beantworten soll.\n",
" \"\"\"\n",
" mittel = ergebnisse.mean(axis=1)\n",
" streuung = ergebnisse.std(axis=1, ddof=1)\n",
" sharpe = mittel / streuung * np.sqrt(252)\n",
" t_wert = mittel / (streuung / np.sqrt(ergebnisse.shape[1]))\n",
" return sharpe, stats.t.sf(t_wert, ergebnisse.shape[1] - 1)\n",
"\n",
"\n",
"def probiere(anzahl_strategien: int) -> tuple[float, float, float, float]:\n",
" \"\"\"Liefert (beste Sharpe, ihr p-Wert, Anteil scheinbar signifikanter,\n",
" Anteil, der auch die Bonferroni-Huerde nimmt) - gemittelt ueber\n",
" WIEDERHOLUNGEN unabhaengige Durchgaenge.\"\"\"\n",
" beste_sharpe, beste_p, anteil_signifikant, ueberlebt_bonferroni = [], [], [], []\n",
"\n",
" for _ in range(WIEDERHOLUNGEN):\n",
" # Der springende Punkt: Erwartungswert exakt 0. Keine dieser\n",
" # Strategien hat einen echten Vorteil - per Konstruktion.\n",
" ergebnisse = RNG.normal(0.0, TAGESSCHWANKUNG,\n",
" (anzahl_strategien, HANDELSTAGE))\n",
" sharpe, p_werte = bewerte(ergebnisse)\n",
"\n",
" beste = int(sharpe.argmax())\n",
" beste_sharpe.append(float(sharpe[beste]))\n",
" beste_p.append(float(p_werte[beste]))\n",
" anteil_signifikant.append(float((p_werte < NIVEAU).mean()))\n",
" # Bonferroni: Wer n Tests macht, muss die Huerde durch n teilen.\n",
" ueberlebt_bonferroni.append(\n",
" float((p_werte < NIVEAU / anzahl_strategien).mean()))\n",
"\n",
" return (float(np.mean(beste_sharpe)), float(np.mean(beste_p)),\n",
" float(np.mean(anteil_signifikant)),\n",
" float(np.mean(ueberlebt_bonferroni)))\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 78)\n",
" print(\" WIE GUT SIEHT DIE BESTE VON N STRATEGIEN AUS,\")\n",
" print(\" WENN KEINE EINZIGE ETWAS TAUGT?\")\n",
" print(\"=\" * 78)\n",
" print(f\"{HANDELSTAGE} Handelstage, taegliche Ergebnisse mit Erwartungswert \"\n",
" f\"EXAKT null.\")\n",
" print(f\"Jede Zeile ist der Mittelwert aus {WIEDERHOLUNGEN} unabhaengigen \"\n",
" f\"Durchgaengen.\\n\")\n",
"\n",
" print(f\"{'getestet':>9} {'beste Sharpe':>14} {'ihr p-Wert':>12} \"\n",
" f\"{'scheinbar sign.':>16}\")\n",
" print(\"-\" * 78)\n",
" ergebnisse = {}\n",
" for anzahl in (1, 10, 100, 1000):\n",
" sharpe, p_wert, anteil, _ = probiere(anzahl)\n",
" ergebnisse[anzahl] = (sharpe, p_wert, anteil)\n",
" print(f\"{anzahl:9d} {sharpe:14.2f} {p_wert:12.4f} {anteil * 100:15.1f} %\")\n",
" print(\"-\" * 78)\n",
"\n",
" sharpe_100, p_100, _ = ergebnisse[100]\n",
" sharpe_1000, p_1000, _ = ergebnisse[1000]\n",
"\n",
" print(\"\\nLesen Sie die dritte Spalte von unten nach oben:\")\n",
" print(f\" Wer {1000} Varianten durchprobiert, findet eine mit p = {p_1000:.4f} -\")\n",
" print(\" ein Wert, den jede Zeitschrift als 'hochsignifikant' druckt.\")\n",
" print(f\" Wer {100} probiert, findet p = {p_100:.4f} und eine Sharpe-Kennzahl\")\n",
" print(f\" von {sharpe_100:.2f} - damit geht man zum Vorgesetzten.\")\n",
" print(\" Und keine dieser Strategien hat auch nur den Hauch eines Vorteils.\")\n",
" print()\n",
" print(\"Die LETZTE Spalte bleibt dagegen konstant bei rund 5 %. Das ist kein\")\n",
" print(\"Widerspruch, sondern die Definition: Ein Test zum 5-%-Niveau irrt in\")\n",
" print(\"5 % der Faelle. Was sich aendert, ist nicht der Anteil, sondern die\")\n",
" print(\"ANZAHL - und weil man nur die beste Strategie praesentiert, sieht man\")\n",
" print(\"von allen Fehlversuchen genau einen: den erfolgreichsten.\")\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" WAS MAN DAGEGEN TUN KANN\")\n",
" print(\"=\" * 78)\n",
" print(\"1. ZAEHLEN. Notieren Sie, wie viele Varianten Sie ausprobiert haben -\")\n",
" print(\" auch die verworfenen, auch die 'nur mal schnell'. Ohne diese Zahl\")\n",
" print(\" ist kein p-Wert und keine Sharpe-Kennzahl interpretierbar.\")\n",
" print()\n",
" print(\"2. KORRIGIEREN. Die Bonferroni-Korrektur teilt die Huerde durch die\")\n",
" print(\" Zahl der Versuche:\")\n",
" for anzahl in (10, 100, 1000):\n",
" print(f\" bei {anzahl:5d} Versuchen: p < {NIVEAU / anzahl:.5f} statt \"\n",
" f\"p < {NIVEAU:.2f}\")\n",
" print(\" Sie ist konservativ, aber sie ist ehrlich. Fuer Handelsstrategien\")\n",
" print(\" gibt es verfeinerte Varianten (Deflated Sharpe Ratio nach Bailey\")\n",
" print(\" und Lopez de Prado), die dasselbe Prinzip verfolgen.\")\n",
" print()\n",
" print(\"3. ZURUECKHALTEN. Legen Sie einen Zeitraum beiseite, BEVOR Sie\")\n",
" print(\" anfangen, und ruehren Sie ihn bis zum Schluss nicht an. Er ist\")\n",
" print(\" der einzige Test, den Sie nicht durch Probieren verderben koennen -\")\n",
" print(\" und er ist genau einmal verwendbar.\")\n",
" print(\"=\" * 78)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -0,0 +1,633 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 5: Lineare Programmierung — Simplex, Dualität und Schattenpreise\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Das Simplex-Tableau in Python\n",
"\n",
"`Simplex_Tableau_LP.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Simplex_Tableau_LP.py\n",
"\"\"\"\n",
"Kapitel LP: Vollständige Implementierung des Simplex-Algorithmus (Tableau-Methode).\n",
"\n",
"GRENZEN DIESER IMPLEMENTIERUNG (bewusst, aus didaktischen Gründen):\n",
" * nur Maximierung\n",
" * nur \"<=\"-Nebenbedingungen\n",
" * alle b_i >= 0 (sonst wäre der Ursprung keine zulässige Startecke und man\n",
" bräuchte eine Phase-1-Rechnung mit künstlichen Variablen)\n",
"Für den produktiven Einsatz nimmt man HiGHS - dieser Code dient dem Verständnis.\n",
"\n",
"Voraussetzungen werden geprüft statt stillschweigend angenommen;\n",
"Iterationsprotokoll und Schattenpreise werden ausgegeben.\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"\n",
"\n",
"class SimplexTableauSolver:\n",
" \"\"\"Maximierungs-Standardform: max c^T x u.d.N. A x <= b, x >= 0, b >= 0.\"\"\"\n",
"\n",
" def __init__(self, c, A, b, variablennamen=None, restriktionsnamen=None):\n",
" self.c = np.asarray(c, dtype=float)\n",
" self.A = np.asarray(A, dtype=float)\n",
" self.b = np.asarray(b, dtype=float)\n",
" self.n = len(self.c) # Anzahl Originalvariablen\n",
" self.m = len(self.b) # Anzahl Nebenbedingungen\n",
"\n",
" # --- Voraussetzungen pruefen, statt sie stillschweigend anzunehmen ---\n",
" if self.A.shape != (self.m, self.n):\n",
" raise ValueError(f\"A hat Form {self.A.shape}, erwartet ({self.m}, {self.n}).\")\n",
" if np.any(self.b < 0):\n",
" raise ValueError(\n",
" \"Mindestens ein b_i ist negativ. Dann ist der Ursprung keine zulässige \"\n",
" \"Startecke; dieser Solver benötigt eine Phase-1-Rechnung, die hier \"\n",
" \"bewusst nicht implementiert ist. Nutzen Sie scipy.optimize.linprog.\"\n",
" )\n",
"\n",
" self.var_namen = variablennamen or [f\"x{j+1}\" for j in range(self.n)]\n",
" self.restr_namen = restriktionsnamen or [f\"R{i+1}\" for i in range(self.m)]\n",
"\n",
" self.tableau = None\n",
" self.basis = None # welche Variable ist in welcher Zeile Basis?\n",
" self._baue_starttableau()\n",
"\n",
" def _baue_starttableau(self):\n",
" \"\"\"Zeilen: m Nebenbedingungen + Zielfunktionszeile.\n",
" Spalten: n Variablen + m Schlupfvariablen + rechte Seite.\"\"\"\n",
" m, n = self.m, self.n\n",
" self.tableau = np.zeros((m + 1, n + m + 1))\n",
" self.tableau[:m, :n] = self.A # Koeffizienten\n",
" self.tableau[:m, n:n + m] = np.eye(m) # Schlupfvariablen\n",
" self.tableau[:m, -1] = self.b # rechte Seite\n",
" self.tableau[-1, :n] = -self.c # Zielzeile: -c (Maximierung)\n",
" self.basis = list(range(n, n + m)) # Start: alle Schlupf in der Basis\n",
"\n",
" def _spaltenname(self, index):\n",
" return self.var_namen[index] if index < self.n else f\"s{index - self.n + 1}\"\n",
"\n",
" def solve(self, max_iterationen=100, protokoll=True):\n",
" m, n = self.m, self.n\n",
" if protokoll:\n",
" print(f\"{'Iter':>4} | {'eintritt':>9} | {'austritt':>9} | \"\n",
" f\"{'Pivot':>8} | {'Z':>12}\")\n",
" print(\"-\" * 58)\n",
"\n",
" for iteration in range(1, max_iterationen + 1):\n",
" zielzeile = self.tableau[-1, :-1]\n",
"\n",
" # 1. Optimalitätsprüfung: alle Koeffizienten >= 0 ?\n",
" if np.all(zielzeile >= -1e-9):\n",
" if protokoll:\n",
" print(\"-\" * 58)\n",
" print(f\"Optimum nach {iteration - 1} Pivotschritten erreicht.\")\n",
" return self._loesung_auslesen()\n",
"\n",
" # 2. Pivotspalte: negativster Eintrag (Dantzig-Regel)\n",
" pivot_spalte = int(np.argmin(zielzeile))\n",
"\n",
" # 3. Pivotzeile: minimaler Quotient über POSITIVE Spalteneinträge\n",
" spalte = self.tableau[:m, pivot_spalte]\n",
" rechte_seite = self.tableau[:m, -1]\n",
" quotienten = np.where(spalte > 1e-9, rechte_seite / np.where(spalte > 1e-9, spalte, 1),\n",
" np.inf)\n",
" pivot_zeile = int(np.argmin(quotienten))\n",
" if not np.isfinite(quotienten[pivot_zeile]):\n",
" raise ValueError(\n",
" f\"Problem ist unbeschraenkt: Variable {self._spaltenname(pivot_spalte)} \"\n",
" \"kann beliebig wachsen, ohne eine Bedingung zu verletzen. \"\n",
" \"Meist fehlt eine Kapazitaetsbeschraenkung.\"\n",
" )\n",
"\n",
" if protokoll:\n",
" print(f\"{iteration:>4} | {self._spaltenname(pivot_spalte):>9} | \"\n",
" f\"{self._spaltenname(self.basis[pivot_zeile]):>9} | \"\n",
" f\"{self.tableau[pivot_zeile, pivot_spalte]:>8.3f} | \"\n",
" f\"{self.tableau[-1, -1]:>12,.2f}\")\n",
"\n",
" # 4. Pivotoperation (Gauß-Jordan)\n",
" pivot_wert = self.tableau[pivot_zeile, pivot_spalte]\n",
" self.tableau[pivot_zeile, :] /= pivot_wert\n",
" for zeile in range(m + 1):\n",
" if zeile != pivot_zeile:\n",
" faktor = self.tableau[zeile, pivot_spalte]\n",
" self.tableau[zeile, :] -= faktor * self.tableau[pivot_zeile, :]\n",
"\n",
" self.basis[pivot_zeile] = pivot_spalte\n",
"\n",
" raise RuntimeError(\"Maximale Iterationszahl ueberschritten (moeglicherweise Zyklus).\")\n",
"\n",
" def _loesung_auslesen(self):\n",
" \"\"\"Basisvariablen tragen den RHS-Wert ihrer Zeile, Nichtbasisvariablen sind 0.\"\"\"\n",
" x = np.zeros(self.n + self.m)\n",
" for zeile, spalte in enumerate(self.basis):\n",
" x[spalte] = self.tableau[zeile, -1]\n",
" return x[:self.n], x[self.n:], self.tableau[-1, -1]\n",
"\n",
" def schattenpreise(self):\n",
" \"\"\"Die Zielzeile unter den Schlupfspalten enthält direkt die Dualwerte.\"\"\"\n",
" return self.tableau[-1, self.n:self.n + self.m].copy()\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" # Modell aus der Simplex-Handrechnung (Bot-Beispiel, Kapitel Einfuehrung)\n",
" ertraege = [150.0, 250.0]\n",
" matrix = [[2.0, 5.0],\n",
" [4.0, 6.0],\n",
" [1.0, 0.0]]\n",
" kapazitaeten = [40.0, 60.0, 8.0]\n",
" var_namen = [\"x_A\", \"x_B\"]\n",
" restr_namen = [\"vCPU\", \"RAM\", \"Marktlimit\"]\n",
"\n",
" print(\"=\" * 58)\n",
" print(\" SIMPLEX-TABLEAU: ITERATIONSPROTOKOLL\")\n",
" print(\"=\" * 58)\n",
"\n",
" solver = SimplexTableauSolver(ertraege, matrix, kapazitaeten, var_namen, restr_namen)\n",
" x_opt, schlupf, z_opt = solver.solve()\n",
"\n",
" print(\"\\n\" + \"=\" * 58)\n",
" print(\" ERGEBNIS\")\n",
" print(\"=\" * 58)\n",
" for name, wert in zip(var_namen, x_opt):\n",
" print(f\" {name:<12} = {wert:8.4f}\")\n",
" print(f\" {'Zielwert Z':<12} = {z_opt:8.2f} EUR\")\n",
"\n",
" print(\"\\n Ressourcenanalyse:\")\n",
" print(f\" {'Ressource':<12} {'Schlupf':>9} {'Status':>22} {'Schattenpreis':>15}\")\n",
" print(\" \" + \"-\" * 60)\n",
" for name, s, y in zip(restr_namen, schlupf, solver.schattenpreise()):\n",
" status = \"ENGPASS (bindend)\" if abs(s) < 1e-9 else \"Reserve vorhanden\"\n",
" print(f\" {name:<12} {s:>9.3f} {status:>22} {y:>12.2f} EUR\")\n",
"\n",
" # Selbstkontrolle: komplementaerer Schlupf muss gelten\n",
" for s, y in zip(schlupf, solver.schattenpreise()):\n",
" assert abs(s * y) < 1e-6, \"Komplementaerer Schlupf verletzt - Rechenfehler!\"\n",
" print(\"\\n Pruefung: komplementaerer Schlupf (s_i * y_i = 0) fuer alle i erfuellt.\")\n",
" print(\"=\" * 58)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Der starke Dualitätssatz\n",
"\n",
"`Dualitaet_Nachweis.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Dualitaet_Nachweis.py\n",
"\"\"\"\n",
"Kapitel LP: Primales und duales Problem unabhaengig loesen und den starken\n",
"Dualitaetssatz sowie den komplementaeren Schlupf numerisch nachweisen.\n",
"\n",
"Modell aus der Simplex-Handrechnung (Bot-Allokation, LP-Relaxation):\n",
" max 150*x1 + 250*x2 u.d.N. 2*x1+5*x2<=40, 4*x1+6*x2<=60, x1<=8, x>=0\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"from scipy.optimize import linprog\n",
"\n",
"# --- Primales Problem --------------------------------------------------------\n",
"C_PRIMAL = np.array([150.0, 250.0])\n",
"A_PRIMAL = np.array([[2.0, 5.0], [4.0, 6.0], [1.0, 0.0]])\n",
"B_PRIMAL = np.array([40.0, 60.0, 8.0])\n",
"\n",
"# --- Duales Problem: min b^T y u.d.N. A^T y >= c, y >= 0 ------------------\n",
"# linprog kennt nur <=, also A^T y >= c <=> -A^T y <= -c\n",
"C_DUAL = B_PRIMAL\n",
"A_DUAL = -A_PRIMAL.T\n",
"B_DUAL = -C_PRIMAL\n",
"\n",
"\n",
"def loese():\n",
" primal = linprog(c=-C_PRIMAL, A_ub=A_PRIMAL, b_ub=B_PRIMAL,\n",
" bounds=[(0, None)] * 2, method=\"highs\")\n",
" dual = linprog(c=C_DUAL, A_ub=A_DUAL, b_ub=B_DUAL,\n",
" bounds=[(0, None)] * 3, method=\"highs\")\n",
" if not primal.success or not dual.success:\n",
" raise SystemExit(\"Primal oder Dual nicht loesbar.\")\n",
" return primal, dual\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" primal, dual = loese()\n",
" x = primal.x\n",
" y = dual.x\n",
" z_primal = -primal.fun\n",
" z_dual = dual.fun\n",
"\n",
" print(\"=\" * 78)\n",
" print(\" PRIMALES PROBLEM\")\n",
" print(\"=\" * 78)\n",
" print(f\"x* = ({x[0]:.4f}, {x[1]:.4f})\")\n",
" print(f\"Z* = {z_primal:.4f}\")\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" DUALES PROBLEM\")\n",
" print(\"=\" * 78)\n",
" print(f\"y* = ({y[0]:.4f}, {y[1]:.4f}, {y[2]:.4f})\")\n",
" print(f\"W* = {z_dual:.4f}\")\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" STARKER DUALITAETSSATZ: c^T x* == b^T y* ?\")\n",
" print(\"=\" * 78)\n",
" print(f\" Primal Z* = {z_primal:.6f}\")\n",
" print(f\" Dual W* = {z_dual:.6f}\")\n",
" differenz = abs(z_primal - z_dual)\n",
" print(f\" Differenz = {differenz:.2e} -> \"\n",
" f\"{'BESTAETIGT' if differenz < 1e-6 else 'VERLETZT!'}\")\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" KOMPLEMENTAERER SCHLUPF: s_i * y_i == 0 fuer alle i ?\")\n",
" print(\"=\" * 78)\n",
" schlupf = B_PRIMAL - A_PRIMAL @ x\n",
" ressourcen = [\"vCPU (s1)\", \"RAM (s2)\", \"Marktlimit (s3)\"]\n",
" for name, s, yi in zip(ressourcen, schlupf, y):\n",
" produkt = s * yi\n",
" print(f\" {name:<16} Schlupf s={s:6.4f} Schattenpreis y={yi:6.4f} \"\n",
" f\"s*y={produkt:.2e} {'OK' if abs(produkt) < 1e-6 else 'VERLETZT!'}\")\n",
"\n",
" print(\"\\nFazit: Das dual geloeste y* stimmt exakt mit den Schattenpreisen\")\n",
" print(\"überein, die die Simplex-Rechnung von Hand in der Z-Zeile\")\n",
" print(\"ablas - unabhaengig voneinander berechnet, identisches Ergebnis.\")\n",
" print(\"=\" * 78)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Praxisfall: Sensitivitätsanalyse mit korrekten Schattenpreisen\n",
"\n",
"`Sensitivitaetsanalyse.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Sensitivitaetsanalyse.py\n",
"\"\"\"\n",
"Kapitel LP: Schattenpreis- und Sensitivitätsanalyse mit SciPy und HiGHS.\n",
"\n",
"Achtung: Ohne Vorzeichenumkehr der Dualwerte waere die Handlungsempfehlung\n",
"strukturell immer \"kein Zukauf noetig\" - selbst bei harten Engpaessen. Dieses\n",
"Programm zeigt die korrekte Vorzeichenbehandlung.\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"from scipy.optimize import linprog\n",
"\n",
"# --- Modell: Maximiere Deckungsbeitrag aus 3 Produkten ---------------------\n",
"# max 40*x1 + 30*x2 + 50*x3\n",
"DECKUNGSBEITRAG = np.array([40.0, 30.0, 50.0])\n",
"\n",
"# Ressourcenverbrauch je Produkt (Zeile = Ressource, Spalte = Produkt)\n",
"VERBRAUCH = np.array([\n",
" [2.0, 1.0, 3.0], # Montagezeit\n",
" [1.0, 2.0, 1.0], # Lackierzeit\n",
" [1.0, 0.5, 2.0], # Qualitätsprüfung\n",
"])\n",
"KAPAZITAET = np.array([120.0, 80.0, 50.0]) # Stunden\n",
"RESSOURCEN = [\"Montage\", \"Lackieren\", \"Qualitätsprüfung\"]\n",
"PRODUKTE = [\"Produkt 1\", \"Produkt 2\", \"Produkt 3\"]\n",
"\n",
"ANGEBOTSPREIS_PRUEFSTUNDE = 18.0 # EUR/h - lohnt sich der Zukauf?\n",
"\n",
"\n",
"def analysiere():\n",
" # linprog MINIMIERT -> Zielfunktion negieren\n",
" ergebnis = linprog(c=-DECKUNGSBEITRAG, A_ub=VERBRAUCH, b_ub=KAPAZITAET,\n",
" bounds=[(0, None)] * len(DECKUNGSBEITRAG), method=\"highs\")\n",
" if not ergebnis.success:\n",
" raise SystemExit(f\"Kein Optimum gefunden: {ergebnis.message}\")\n",
"\n",
" max_gewinn = -ergebnis.fun\n",
" mengen = ergebnis.x\n",
" schlupf = ergebnis.slack\n",
"\n",
" # ------------------------------------------------------------------\n",
" # DER ENTSCHEIDENDE PUNKT:\n",
" # Weil wir zur Maximierung negiert haben, sind die Dualwerte aus\n",
" # linprog fuer \"<=\"-Bedingungen <= 0. Zurueckdrehen!\n",
" # ------------------------------------------------------------------\n",
" schattenpreise = -ergebnis.ineqlin.marginals\n",
"\n",
" print(\"=\" * 74)\n",
" print(\" PRIMALE UND DUALE ERGEBNISANALYSE (SENSITIVITAET)\")\n",
" print(\"=\" * 74)\n",
" print(f\"Maximaler Deckungsbeitrag: {max_gewinn:,.2f} EUR\\n\")\n",
"\n",
" print(\"--- Primalloesung: optimale Produktionsmengen ---\")\n",
" for name, menge in zip(PRODUKTE, mengen):\n",
" print(f\" * {name}: {menge:8.2f} Stueck\")\n",
"\n",
" print(\"\\n--- Duale Analyse: Schattenpreise und Auslastung ---\")\n",
" for i, name in enumerate(RESSOURCEN):\n",
" kapazitaet = KAPAZITAET[i]\n",
" genutzt = kapazitaet - schlupf[i]\n",
" auslastung = genutzt / kapazitaet * 100\n",
" preis = schattenpreise[i]\n",
" bindend = abs(schlupf[i]) < 1e-9\n",
"\n",
" print(f\"\\nRessource '{name}':\")\n",
" print(f\" Auslastung: {genutzt:6.1f} / {kapazitaet:6.1f} h ({auslastung:5.1f} %)\"\n",
" f\" -> {'ENGPASS' if bindend else 'Reserve: %.1f h' % schlupf[i]}\")\n",
" print(f\" Schattenpreis: {preis:6.2f} EUR je zusaetzlicher Stunde\")\n",
"\n",
" if preis > 1e-9:\n",
" print(f\" >> Zusaetzliche Stunden lohnen sich bis zu einem Preis von \"\n",
" f\"{preis:.2f} EUR/h.\")\n",
" else:\n",
" print(f\" >> Kein Zukauf noetig - die Kapazitaet ist nicht erschoepft.\")\n",
"\n",
" # --- Konkrete Kaufentscheidung ---------------------------------------\n",
" preis_pruefung = schattenpreise[RESSOURCEN.index(\"Qualitätsprüfung\")]\n",
" marge = preis_pruefung - ANGEBOTSPREIS_PRUEFSTUNDE\n",
" print(\"\\n\" + \"-\" * 74)\n",
" print(f\"ENTSCHEIDUNG: Pruefstunden werden fuer \"\n",
" f\"{ANGEBOTSPREIS_PRUEFSTUNDE:.2f} EUR/h angeboten.\")\n",
" print(f\" Schattenpreis: {preis_pruefung:6.2f} EUR/h\")\n",
" print(f\" Angebotspreis: {ANGEBOTSPREIS_PRUEFSTUNDE:6.2f} EUR/h\")\n",
" print(f\" Marge: {marge:+6.2f} EUR je zugekaufter Stunde\")\n",
" print(f\" >> {'ZUKAUFEN' if marge > 0 else 'NICHT ZUKAUFEN'}\")\n",
"\n",
" # --- Numerische Gegenprobe: Kapazitaet wirklich um 1 erhoehen --------\n",
" kapazitaet_plus = KAPAZITAET.copy()\n",
" kapazitaet_plus[RESSOURCEN.index(\"Qualitätsprüfung\")] += 1.0\n",
" gegenprobe = linprog(c=-DECKUNGSBEITRAG, A_ub=VERBRAUCH, b_ub=kapazitaet_plus,\n",
" bounds=[(0, None)] * 3, method=\"highs\")\n",
" tatsaechlicher_zuwachs = -gegenprobe.fun - max_gewinn\n",
" print(\"\\n--- Gegenprobe: Modell mit +1 Pruefstunde neu geloest ---\")\n",
" print(f\" Vorhergesagt (Schattenpreis): {preis_pruefung:8.4f} EUR\")\n",
" print(f\" Tatsaechlich gemessen: {tatsaechlicher_zuwachs:8.4f} EUR\")\n",
" assert abs(tatsaechlicher_zuwachs - preis_pruefung) < 1e-6, \\\n",
" \"Schattenpreis stimmt nicht mit der Messung ueberein!\"\n",
" print(\" -> Der Schattenpreis ist bestaetigt.\")\n",
"\n",
" # --- Komplementaerer Schlupf pruefen --------------------------------\n",
" for s, y in zip(schlupf, schattenpreise):\n",
" assert abs(s * y) < 1e-6, \"Komplementaerer Schlupf verletzt!\"\n",
" print(\"\\nPruefung: komplementaerer Schlupf fuer alle Ressourcen erfuellt.\")\n",
" print(\"=\" * 74)\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" analysiere()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Die ehrliche Auskunft: Schattenpreis-Spannen\n",
"\n",
"`Toleranzen_und_Entartung.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Toleranzen_und_Entartung.py\n",
"\"\"\"\n",
"Kapitel LP: Zwei Faelle, in denen man Schattenpreisen NICHT trauen darf.\n",
"\n",
" 1. Entartung (Degeneriertheit): Mehr Nebenbedingungen sind aktiv, als das\n",
" Problem Variablen hat. Dann ist der Schattenpreis nicht eindeutig - zwei\n",
" korrekte Solver liefern voellig verschiedene Werte fuer dasselbe Optimum.\n",
" 2. Toleranzen: \"ausgelastet\" heisst nie 'schlupf == 0', sondern immer\n",
" 'schlupf < toleranz'. Wer auf exakte Gleichheit prueft, baut Berichte,\n",
" die zufaellig mal stimmen und mal nicht.\n",
"\n",
"Teil 3 zeigt die professionelle Antwort auf Fall 1: Statt EINEN Schattenpreis\n",
"zu melden, berechnet man seine SPANNE ueber alle optimalen Dualloesungen.\n",
"\n",
"Benoetigt: numpy, scipy\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import numpy as np\n",
"from scipy.optimize import linprog\n",
"\n",
"# Ein bewusst entartetes Beispiel: drei Geraden schneiden sich in EINEM Punkt.\n",
"# max x1 + x2\n",
"# u.d.N. x1 + 2*x2 <= 4 (A)\n",
"# 2*x1 + x2 <= 4 (B)\n",
"# x1 + x2 <= 8/3 (C) - laeuft genau durch die Ecke (4/3, 4/3)\n",
"# In zwei Dimensionen legen schon zwei Geraden eine Ecke fest. Hier sind drei\n",
"# aktiv - eine zu viel. Genau das ist Entartung.\n",
"C_ZIEL = np.array([-1.0, -1.0]) # linprog minimiert -> negiert\n",
"A_UB = np.array([[1.0, 2.0],\n",
" [2.0, 1.0],\n",
" [1.0, 1.0]])\n",
"B_UB = np.array([4.0, 4.0, 8.0 / 3.0])\n",
"NAMEN = [\"A: Fraeszeit\", \"B: Schleifzeit\", \"C: Pruefzeit\"]\n",
"\n",
"\n",
"def zeige_entartung() -> float:\n",
" \"\"\"Loest dasselbe LP mit zwei Verfahren und vergleicht die Dualwerte.\"\"\"\n",
" print(\"=\" * 78)\n",
" print(\" 1. ENTARTUNG: DERSELBE PLAN, GEGENSAETZLICHE SCHATTENPREISE\")\n",
" print(\"=\" * 78)\n",
"\n",
" print(f\"{'Verfahren':<26} {'x1':>7} {'x2':>7} {'Z*':>9} Schattenpreise\")\n",
" print(\"-\" * 78)\n",
"\n",
" dualwerte = {}\n",
" for verfahren, beschreibung in [(\"highs-ds\", \"Dual Simplex\"),\n",
" (\"highs-ipm\", \"Innere-Punkte-Verfahren\")]:\n",
" ergebnis = linprog(C_ZIEL, A_ub=A_UB, b_ub=B_UB, bounds=(0, None),\n",
" method=verfahren)\n",
" if not ergebnis.success:\n",
" raise RuntimeError(f\"{verfahren}: {ergebnis.message}\")\n",
" y = -ergebnis.ineqlin.marginals\n",
" dualwerte[verfahren] = y\n",
" print(f\"{beschreibung:<26} {ergebnis.x[0]:>7.3f} {ergebnis.x[1]:>7.3f} \"\n",
" f\"{-ergebnis.fun:>9.4f} {np.round(y, 4)}\")\n",
"\n",
" print(\"-\" * 78)\n",
" print(\"Beide Zeilen sind RICHTIG: gleicher Plan, gleicher Zielwert, und beide\")\n",
" print(\"Dualvektoren erfuellen die Optimalitaetsbedingungen. Trotzdem sagen sie\")\n",
" print(\"das Gegenteil:\")\n",
" print(f\" Dual Simplex : {NAMEN[2]} ist wertlos, A und B sind je 0,33 EUR wert.\")\n",
" print(f\" Innere Punkte: {NAMEN[0]} und {NAMEN[1]} sind wertlos, C ist 1,00 EUR wert.\")\n",
" print()\n",
" print(\"Wer auf dieser Grundlage eine Maschine kauft, hat eine 50:50-Chance -\")\n",
" print(\"abhaengig davon, welches Verfahren der Solver zufaellig gewaehlt hat.\")\n",
"\n",
" return float(-linprog(C_ZIEL, A_ub=A_UB, b_ub=B_UB, bounds=(0, None)).fun)\n",
"\n",
"\n",
"def entartung_erkennen() -> None:\n",
" \"\"\"Der Test, der in jedes Auswertungsskript gehoert.\"\"\"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" 2. ENTARTUNG ERKENNEN - UND WARUM '== 0' DABEI VERSAGT\")\n",
" print(\"=\" * 78)\n",
"\n",
" ergebnis = linprog(C_ZIEL, A_ub=A_UB, b_ub=B_UB, bounds=(0, None))\n",
" schlupf = B_UB - A_UB @ ergebnis.x\n",
"\n",
" print(f\"{'Nebenbedingung':<18} {'Schlupf':>16} {'== 0 ?':>9} \"\n",
" f\"{'< 1e-7 ?':>10}\")\n",
" print(\"-\" * 78)\n",
" for name, s in zip(NAMEN, schlupf):\n",
" print(f\"{name:<18} {s:>16.3e} {str(s == 0.0):>9} {str(abs(s) < 1e-7):>10}\")\n",
"\n",
" aktiv = int((np.abs(schlupf) < 1e-7).sum())\n",
" variablen = A_UB.shape[1]\n",
" print(\"-\" * 78)\n",
" print(f\"Aktive Nebenbedingungen: {aktiv}, Variablen: {variablen}\")\n",
" if aktiv > variablen:\n",
" print(f\"=> ENTARTET. {aktiv} aktive Restriktionen bei nur {variablen} \"\n",
" \"Variablen bedeuten:\")\n",
" print(\" Der Schattenpreis ist nicht eindeutig. Melden Sie eine Spanne,\")\n",
" print(\" keinen Einzelwert (siehe Teil 3).\")\n",
" else:\n",
" print(\"=> nicht entartet, die Dualwerte sind eindeutig.\")\n",
"\n",
" print(\"\\nBeachten Sie die Spalte '== 0': Ein Schlupf von 4.44e-16 ist\")\n",
" print(\"rechnerisch null, aber nicht gleich 0.0. Wer mit '==' prueft,\")\n",
" print(\"uebersieht genau die Engpaesse, die er sucht.\")\n",
"\n",
"\n",
"def schattenpreis_spanne(zielwert: float, toleranz: float = 1e-9\n",
" ) -> list[tuple[float, float]]:\n",
" \"\"\"Berechnet fuer jede Nebenbedingung die Spanne ihres Schattenpreises\n",
" ueber ALLE optimalen Dualloesungen.\n",
"\n",
" Die Menge der optimalen Dualloesungen ist selbst ein Polyeder:\n",
"\n",
" A^T y >= c, y >= 0, b^T y = Z*\n",
"\n",
" (Dualzulaessigkeit plus starker Dualitaetssatz.) Minimiert und maximiert\n",
" man darauf y_i, erhaelt man die exakten Grenzen. Das ist die ehrliche\n",
" Auskunft an das Management: nicht 'die Stunde ist 0,33 EUR wert', sondern\n",
" 'zwischen 0,00 und 0,33 EUR - der Wert ist aus dem Modell nicht bestimmbar'.\n",
" \"\"\"\n",
" m = A_UB.shape[0]\n",
" # A^T y >= c <=> -A^T y <= -c ; Ziel war max c^T x, in linprog-Notation\n",
" # steckt c mit negativem Vorzeichen in C_ZIEL.\n",
" c_original = -C_ZIEL\n",
" A_dual_ub = -A_UB.T\n",
" b_dual_ub = -c_original\n",
"\n",
" spannen = []\n",
" for i in range(m):\n",
" richtung = np.zeros(m)\n",
" richtung[i] = 1.0\n",
" grenzen = []\n",
" for vorzeichen in (1.0, -1.0): # 1 = minimieren, -1 = maximieren\n",
" ergebnis = linprog(\n",
" vorzeichen * richtung,\n",
" A_ub=A_dual_ub, b_ub=b_dual_ub,\n",
" A_eq=B_UB.reshape(1, -1), b_eq=[zielwert],\n",
" bounds=(0, None))\n",
" if not ergebnis.success:\n",
" raise RuntimeError(f\"Spannenberechnung fehlgeschlagen: \"\n",
" f\"{ergebnis.message}\")\n",
" grenzen.append(float(ergebnis.x[i]))\n",
" spannen.append((min(grenzen), max(grenzen)))\n",
" return spannen\n",
"\n",
"\n",
"def zeige_spanne(zielwert: float) -> None:\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" 3. DIE EHRLICHE AUSKUNFT: SCHATTENPREIS-SPANNEN\")\n",
" print(\"=\" * 78)\n",
"\n",
" spannen = schattenpreis_spanne(zielwert)\n",
" print(f\"{'Nebenbedingung':<18} {'von':>10} {'bis':>10} Aussage\")\n",
" print(\"-\" * 78)\n",
" for name, (unten, oben) in zip(NAMEN, spannen):\n",
" if oben - unten < 1e-7:\n",
" aussage = f\"eindeutig {oben:.2f} EUR\"\n",
" elif oben < 1e-7:\n",
" aussage = \"sicher wertlos (kein Engpass)\"\n",
" else:\n",
" aussage = \"NICHT bestimmbar - Spanne melden!\"\n",
" print(f\"{name:<18} {unten:>10.4f} {oben:>10.4f} {aussage}\")\n",
"\n",
" print(\"-\" * 78)\n",
" print(\"So berichtet man an Entscheider: 'Eine zusaetzliche Fraesstunde ist\")\n",
" print(\"zwischen 0,00 und 0,33 EUR wert - das Modell kann es nicht genauer\")\n",
" print(\"sagen, weil drei Engpaesse exakt gleichzeitig binden.' Das ist eine\")\n",
" print(\"brauchbare Aussage. Ein erfundener Einzelwert ist es nicht.\")\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" zielwert = zeige_entartung()\n",
" entartung_erkennen()\n",
" zeige_spanne(zielwert)\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\"Merksatz: Pruefen Sie VOR jeder Sensitivitaetsaussage auf Entartung -\")\n",
" print(\"und vergleichen Sie Schlupfwerte nie mit '== 0', sondern mit einer\")\n",
" print(\"Toleranz.\")\n",
" print(\"=\" * 78)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -0,0 +1,542 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 19: Die moderne Portfoliotheorie nach Markowitz\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Warum Diversifikation funktioniert\n",
"\n",
"`Diversifikation_Demo.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Diversifikation_Demo.py\n",
"\"\"\"\n",
"Kapitel Markowitz: Portfoliorisiko in Abhaengigkeit von der Korrelation - die\n",
"Handrechnung zum Gratis-Mittagessen, geprueft ueber die analytische Formel und\n",
"ueber simulierte, korrelierte Renditen.\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"\n",
"MU = 0.08\n",
"SIGMA = 0.20\n",
"\n",
"\n",
"def sigma_portfolio(rho: float, w1: float = 0.5) -> float:\n",
" \"\"\"Analytische Formel: sigma_p^2 = w1^2 s1^2 + w2^2 s2^2 + 2 w1 w2 rho s1 s2.\"\"\"\n",
" w2 = 1 - w1\n",
" varianz = w1**2 * SIGMA**2 + w2**2 * SIGMA**2 + 2 * w1 * w2 * rho * SIGMA**2\n",
" return float(np.sqrt(varianz))\n",
"\n",
"\n",
"def simuliere(rho: float, n: int = 500_000, seed: int = 7) -> float:\n",
" \"\"\"Erzeugt n korrelierte Renditepaare und misst die Portfolio-Volatilitaet empirisch.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" kovarianz = np.array([[SIGMA**2, rho * SIGMA**2],\n",
" [rho * SIGMA**2, SIGMA**2]])\n",
" renditen = rng.multivariate_normal([MU, MU], kovarianz, size=n)\n",
" portfolio = 0.5 * renditen[:, 0] + 0.5 * renditen[:, 1]\n",
" return float(portfolio.std(ddof=1))\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 78)\n",
" print(\" PORTFOLIORISIKO IN ABHAENGIGKEIT VON DER KORRELATION\")\n",
" print(\"=\" * 78)\n",
" print(f\"{'rho':>6} | {'sigma_p (Formel)':>18} | {'sigma_p (Simulation)':>20} | {'Risikoreduktion':>16}\")\n",
" print(\"-\" * 78)\n",
" for rho in [1.0, 0.5, 0.0, -0.5, -1.0]:\n",
" formel = sigma_portfolio(rho)\n",
" sim = simuliere(rho)\n",
" reduktion = (1 - formel / SIGMA) * 100\n",
" print(f\"{rho:6.1f} | {formel*100:16.2f} % | {sim*100:18.2f} % | {reduktion:14.0f} %\")\n",
"\n",
" print(\"\\nDie simulierten Werte (500.000 gezogene Renditepaare je rho) bestaetigen\")\n",
" print(\"die Formel aus der Handrechnung bis auf statistisches Rauschen - und das,\")\n",
" print(\"obwohl Formel und Simulation nichts voneinander wissen.\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Vollimplementierung mit CVXPY\n",
"\n",
"`Markowitz_CVXPY.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Markowitz_CVXPY.py\n",
"\"\"\"\n",
"Kapitel Markowitz: Markowitz-Mean-Variance-Optimierung mit CVXPY.\n",
"Enthaelt GMV, Maximum Sharpe (Korn-Transformation), Effizienzgrenze\n",
"und institutionelle Restriktionen.\n",
"\n",
"Eigenschaften:\n",
" * Spaltenreihenfolge erzwungen; Sektoren ueber NAMEN statt Indizes\n",
" * Effizienzgrenze bis zur TATSAECHLICH erreichbaren Maximalrendite\n",
" (nicht etwa max(mu)*0.95 - das waere unter Restriktionen oft\n",
" unerreichbar und die Kurve wuerde stillschweigend vorzeitig abbrechen)\n",
" * Pruefung aller Restriktionen nach dem Loesen\n",
" * Vergleich mit Gleichgewichtung als Realitaetscheck\n",
"\"\"\"\n",
"\n",
"import os\n",
"\n",
"import cvxpy as cp\n",
"import numpy as np\n",
"import pandas as pd\n",
"import yfinance as yf\n",
"from sklearn.covariance import LedoitWolf\n",
"import matplotlib\n",
"matplotlib.use(\"Agg\")\n",
"import matplotlib.pyplot as plt\n",
"\n",
"OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"output\")\n",
"os.makedirs(OUTPUT_DIR, exist_ok=True)\n",
"\n",
"TICKER = [\"AAPL\", \"MSFT\", \"NVDA\", \"AMZN\", \"JNJ\", \"PFE\", \"JPM\", \"GS\", \"XOM\", \"CVX\"]\n",
"\n",
"# Sektoren ueber Namen definiert, nicht ueber Positionen\n",
"SEKTOREN = {\n",
" \"Technologie\": [\"AAPL\", \"MSFT\", \"NVDA\", \"AMZN\"],\n",
" \"Gesundheit\": [\"JNJ\", \"PFE\"],\n",
" \"Finanzen\": [\"JPM\", \"GS\"],\n",
" \"Energie\": [\"XOM\", \"CVX\"],\n",
"}\n",
"SEKTORGRENZEN = {\"Technologie\": 0.35, \"Gesundheit\": 0.40,\n",
" \"Finanzen\": 0.40, \"Energie\": 0.40}\n",
"\n",
"MAX_GEWICHT = 0.20 # hoechstens 20 % je Einzeltitel\n",
"RISIKOFREI = 0.03 # 3 % p.a.\n",
"HANDELSTAGE = 252\n",
"\n",
"\n",
"def lade_daten():\n",
" \"\"\"Laedt Kurse und garantiert die Spaltenreihenfolge.\"\"\"\n",
" ende = pd.Timestamp.today().normalize()\n",
" start = ende - pd.DateOffset(years=2)\n",
" roh = yf.download(TICKER, start=start, end=ende, auto_adjust=True, progress=False)\n",
" if roh.empty:\n",
" raise SystemExit(\"Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).\")\n",
"\n",
" if isinstance(roh.columns, pd.MultiIndex):\n",
" kurse = roh[\"Close\"][TICKER].dropna() # [TICKER] erzwingt die Reihenfolge\n",
" else:\n",
" kurse = roh[[\"Close\"]].dropna()\n",
" kurse.columns = TICKER\n",
"\n",
" assert list(kurse.columns) == TICKER, \"Spaltenreihenfolge weicht ab!\"\n",
" renditen = kurse.pct_change().dropna()\n",
" mu = renditen.mean().values * HANDELSTAGE\n",
" sigma = LedoitWolf().fit(renditen.values).covariance_ * HANDELSTAGE\n",
" return kurse, renditen, mu, sigma\n",
"\n",
"\n",
"def sektor_indizes():\n",
" \"\"\"Uebersetzt Sektornamen einmalig in Positionsindizes - mit Pruefung.\"\"\"\n",
" ergebnis = {}\n",
" for sektor, titel in SEKTOREN.items():\n",
" fehlend = [t for t in titel if t not in TICKER]\n",
" if fehlend:\n",
" raise ValueError(f\"Sektor '{sektor}': Ticker {fehlend} nicht im Universum.\")\n",
" ergebnis[sektor] = [TICKER.index(t) for t in titel]\n",
" return ergebnis\n",
"\n",
"\n",
"def basis_restriktionen(w, skala=None):\n",
" \"\"\"\n",
" Standardrestriktionen. Bei der Korn-Transformation muessen ALLE Grenzen\n",
" mit kappa mitskaliert werden - dafuer dient der Parameter 'skala'.\n",
" \"\"\"\n",
" eins = 1.0 if skala is None else skala\n",
" idx = sektor_indizes()\n",
" bedingungen = [w >= 0, w <= MAX_GEWICHT * eins]\n",
" for sektor, positionen in idx.items():\n",
" bedingungen.append(cp.sum(w[positionen]) <= SEKTORGRENZEN[sektor] * eins)\n",
" return bedingungen\n",
"\n",
"\n",
"def loese_gmv(sigma):\n",
" \"\"\"Global Minimum Variance: minimiere Risiko, ignoriere Rendite.\"\"\"\n",
" w = cp.Variable(len(sigma))\n",
" problem = cp.Problem(cp.Minimize(0.5 * cp.quad_form(w, sigma)),\n",
" [cp.sum(w) == 1] + basis_restriktionen(w))\n",
" problem.solve()\n",
" if problem.status not in (\"optimal\", \"optimal_inaccurate\"):\n",
" raise SystemExit(f\"GMV nicht loesbar: {problem.status}\")\n",
" return w.value\n",
"\n",
"\n",
"def loese_max_sharpe(mu, sigma, r_f):\n",
" \"\"\"Maximum Sharpe Ratio ueber die Korn-Transformation.\"\"\"\n",
" n = len(mu)\n",
" ueberrendite = mu - r_f\n",
" if np.all(ueberrendite <= 0):\n",
" raise SystemExit(\"Kein Titel schlaegt den risikofreien Zins - \"\n",
" \"Max-Sharpe-Portfolio existiert nicht.\")\n",
"\n",
" y = cp.Variable(n)\n",
" kappa = cp.Variable(nonneg=True)\n",
" bedingungen = ([ueberrendite @ y == 1, cp.sum(y) == kappa]\n",
" + basis_restriktionen(y, skala=kappa))\n",
" problem = cp.Problem(cp.Minimize(0.5 * cp.quad_form(y, sigma)), bedingungen)\n",
" problem.solve()\n",
" if problem.status not in (\"optimal\", \"optimal_inaccurate\"):\n",
" raise SystemExit(f\"Max-Sharpe nicht loesbar: {problem.status}\")\n",
" return y.value / kappa.value\n",
"\n",
"\n",
"def max_erreichbare_rendite(mu, sigma):\n",
" \"\"\"\n",
" Achtung: max(mu)*0.95 als obere Grenze der Frontier anzunehmen, ist unter\n",
" Positions- und Sektorgrenzen oft unerreichbar; die Kurve wuerde dann\n",
" stillschweigend abbrechen. Hier wird die tatsaechliche Obergrenze berechnet.\n",
" \"\"\"\n",
" w = cp.Variable(len(mu))\n",
" problem = cp.Problem(cp.Maximize(mu @ w), [cp.sum(w) == 1] + basis_restriktionen(w))\n",
" problem.solve()\n",
" return float(problem.value)\n",
"\n",
"\n",
"def berechne_frontier(mu, sigma, ret_min, ret_max, punkte=40):\n",
" \"\"\"Effizienzgrenze durch Variation der Mindestrendite.\"\"\"\n",
" n = len(mu)\n",
" w = cp.Variable(n)\n",
" ziel_rendite = cp.Parameter()\n",
" problem = cp.Problem(\n",
" cp.Minimize(0.5 * cp.quad_form(w, sigma)),\n",
" [cp.sum(w) == 1, mu @ w >= ziel_rendite] + basis_restriktionen(w))\n",
"\n",
" volas, renditen, uebersprungen = [], [], 0\n",
" for ziel in np.linspace(ret_min, ret_max, punkte):\n",
" ziel_rendite.value = ziel\n",
" problem.solve()\n",
" if problem.status in (\"optimal\", \"optimal_inaccurate\"):\n",
" volas.append(float(np.sqrt(w.value @ sigma @ w.value)))\n",
" renditen.append(float(mu @ w.value))\n",
" else:\n",
" uebersprungen += 1\n",
" if uebersprungen:\n",
" print(f\" Hinweis: {uebersprungen} Zielrenditen waren nicht erreichbar.\")\n",
" return np.array(volas), np.array(renditen)\n",
"\n",
"\n",
"def pruefe_restriktionen(w, bezeichnung):\n",
" \"\"\"Nach dem Loesen: haelt die Loesung wirklich alle Regeln ein?\"\"\"\n",
" assert abs(w.sum() - 1) < 1e-6, f\"{bezeichnung}: Summe != 1\"\n",
" assert w.min() > -1e-6, f\"{bezeichnung}: negatives Gewicht\"\n",
" assert w.max() < MAX_GEWICHT + 1e-6, f\"{bezeichnung}: Positionsgrenze verletzt\"\n",
" for sektor, positionen in sektor_indizes().items():\n",
" anteil = w[positionen].sum()\n",
" assert anteil < SEKTORGRENZEN[sektor] + 1e-6, \\\n",
" f\"{bezeichnung}: Sektor {sektor} bei {anteil:.3f} ueber Grenze\"\n",
"\n",
"\n",
"def kennzahlen(w, mu, sigma, r_f):\n",
" rendite = float(mu @ w)\n",
" vola = float(np.sqrt(w @ sigma @ w))\n",
" return rendite, vola, (rendite - r_f) / vola\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" kurse, renditen, mu, sigma = lade_daten()\n",
" n = len(TICKER)\n",
"\n",
" w_gmv = loese_gmv(sigma)\n",
" w_sharpe = loese_max_sharpe(mu, sigma, RISIKOFREI)\n",
" w_gleich = np.ones(n) / n\n",
"\n",
" pruefe_restriktionen(w_gmv, \"GMV\")\n",
" pruefe_restriktionen(w_sharpe, \"Max Sharpe\")\n",
"\n",
" print(\"=\" * 88)\n",
" print(\" ERGEBNISSE DER MEAN-VARIANCE-OPTIMIERUNG\")\n",
" print(\"=\" * 88)\n",
" print(f\"Datenbasis: {len(renditen)} Handelstage, {n} Titel, \"\n",
" f\"risikofreier Zins {RISIKOFREI*100:.1f} %\")\n",
" print(f\"Restriktionen: max. {MAX_GEWICHT*100:.0f} % je Titel, \"\n",
" f\"Sektorgrenzen {SEKTORGRENZEN}\\n\")\n",
"\n",
" print(f\"{'Portfolio':<26} {'Rendite':>10} {'Volatilitaet':>13} {'Sharpe':>9}\")\n",
" print(\"-\" * 88)\n",
" for name, w in [(\"Global Minimum Variance\", w_gmv),\n",
" (\"Maximum Sharpe Ratio\", w_sharpe),\n",
" (\"Gleichgewichtung (1/N)\", w_gleich)]:\n",
" r, v, sr = kennzahlen(w, mu, sigma, RISIKOFREI)\n",
" print(f\"{name:<26} {r*100:>9.2f} % {v*100:>12.2f} % {sr:>9.2f}\")\n",
"\n",
" # --- Gewichte je Titel ------------------------------------------------\n",
" print(\"\\n--- Optimierte Portfoliogewichte ---\")\n",
" sektor_je_titel = {t: s for s, titel in SEKTOREN.items() for t in titel}\n",
" tabelle = pd.DataFrame({\n",
" \"Ticker\": TICKER,\n",
" \"Sektor\": [sektor_je_titel[t] for t in TICKER],\n",
" \"Rendite p.a.\": [f\"{r*100:+6.1f} %\" for r in mu],\n",
" \"Vola p.a.\": [f\"{np.sqrt(sigma[i, i])*100:5.1f} %\" for i in range(n)],\n",
" \"GMV\": [f\"{w*100:5.1f} %\" for w in w_gmv],\n",
" \"Max Sharpe\": [f\"{w*100:5.1f} %\" for w in w_sharpe],\n",
" })\n",
" print(tabelle.to_string(index=False))\n",
"\n",
" print(\"\\n--- Sektoraufteilung (Kontrolle) ---\")\n",
" print(f\"{'Sektor':<14} {'Grenze':>8} {'GMV':>9} {'Max Sharpe':>12}\")\n",
" for sektor, positionen in sektor_indizes().items():\n",
" print(f\"{sektor:<14} {SEKTORGRENZEN[sektor]*100:>7.0f} % \"\n",
" f\"{w_gmv[positionen].sum()*100:>8.1f} % \"\n",
" f\"{w_sharpe[positionen].sum()*100:>11.1f} %\")\n",
"\n",
" # --- Effizienzgrenze --------------------------------------------------\n",
" ret_gmv = float(mu @ w_gmv)\n",
" ret_max = max_erreichbare_rendite(mu, sigma)\n",
" print(f\"\\nEffizienzgrenze von {ret_gmv*100:.2f} % bis {ret_max*100:.2f} % \"\n",
" f\"(unter Restriktionen tatsaechlich erreichbar)\")\n",
" print(f\" Zum Vergleich: bester Einzeltitel {mu.max()*100:.2f} % - \"\n",
" f\"durch die Grenzen nicht erreichbar.\")\n",
" volas, rets = berechne_frontier(mu, sigma, ret_gmv, ret_max)\n",
"\n",
" # --- Diagramm ---------------------------------------------------------\n",
" plt.figure(figsize=(11, 6.5))\n",
" plt.plot(volas * 100, rets * 100, \"b-\", lw=2.5, label=\"Effizienzgrenze (restringiert)\")\n",
"\n",
" for w, farbe, marker, groesse, name in [\n",
" (w_gmv, \"green\", \"o\", 150, \"GMV\"),\n",
" (w_sharpe, \"red\", \"*\", 260, \"Max Sharpe\"),\n",
" (w_gleich, \"purple\", \"D\", 110, \"Gleichgewichtung\")]:\n",
" r, v, sr = kennzahlen(w, mu, sigma, RISIKOFREI)\n",
" plt.scatter([v * 100], [r * 100], color=farbe, marker=marker, s=groesse,\n",
" zorder=5, label=f\"{name} (SR={sr:.2f})\")\n",
"\n",
" # Kapitalmarktlinie durch den risikofreien Zins und das Tangentialportfolio\n",
" r_s, v_s, _ = kennzahlen(w_sharpe, mu, sigma, RISIKOFREI)\n",
" x_linie = np.array([0, v_s * 1.25])\n",
" plt.plot(x_linie * 100, (RISIKOFREI + (r_s - RISIKOFREI) / v_s * x_linie) * 100,\n",
" color=\"orange\", ls=\":\", lw=2, label=\"Kapitalmarktlinie\")\n",
"\n",
" for i, t in enumerate(TICKER):\n",
" plt.scatter(np.sqrt(sigma[i, i]) * 100, mu[i] * 100, color=\"gray\", alpha=0.5, s=40)\n",
" plt.annotate(t, (np.sqrt(sigma[i, i]) * 100 + 0.4, mu[i] * 100), fontsize=8)\n",
"\n",
" plt.title(\"Markowitz-Effizienzgrenze mit Sektor- und Positionsgrenzen\", fontsize=12)\n",
" plt.xlabel(\"Annualisierte Volatilitaet [%]\")\n",
" plt.ylabel(\"Annualisierte erwartete Rendite [%]\")\n",
" plt.grid(True, linestyle=\":\", alpha=0.6)\n",
" plt.legend(loc=\"best\")\n",
" plt.tight_layout()\n",
" ziel = os.path.join(OUTPUT_DIR, \"markowitz_efficient_frontier.png\")\n",
" plt.savefig(ziel, dpi=150)\n",
" print(f\"\\nDiagramm gespeichert unter '{ziel}'\")\n",
" print(\"=\" * 88)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Finde den Denkfehler\n",
"\n",
"`Renditeschaetzung_Falle.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Renditeschaetzung_Falle.py\n",
"\"\"\"\n",
"Kapitel Markowitz: Warum geschaetzte Renditen noch gefaehrlicher sind als geschaetzte\n",
"Kovarianzen.\n",
"\n",
"Das Kapitel Finanzdaten hat gezeigt, was eine schlecht geschaetzte Kovarianzmatrix\n",
"anrichtet. Bei den erwarteten RENDITEN ist es schlimmer - aus einem einfachen\n",
"statistischen Grund: Um eine Rendite von 0,04 % pro Tag von null zu\n",
"unterscheiden, braucht man bei 1,2 % Tagesschwankung ueber tausend\n",
"Beobachtungen. Kovarianzen konvergieren dagegen deutlich schneller.\n",
"\n",
"Das Experiment ist bewusst so gebaut, dass die Antwort feststeht: ALLE zwoelf\n",
"Anlagen haben exakt dieselbe wahre Rendite und dieselbe Schwankung. Ein\n",
"korrektes Ergebnis waere also Gleichgewichtung. Was der Optimierer stattdessen\n",
"tut, zeigt der Lauf.\n",
"\n",
"Wiederholt wird 200 mal, damit das Ergebnis nicht von einer Zufallsstichprobe\n",
"abhaengt.\n",
"\n",
"Benoetigt: numpy, cvxpy\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import numpy as np\n",
"import cvxpy as cp\n",
"\n",
"ANZAHL_ANLAGEN = 12\n",
"BEOBACHTUNGEN = 250 # ein Jahr Tagesdaten\n",
"BEOBACHTUNGEN_SPAETER = 2000 # der \"spaetere Verlauf\"\n",
"WIEDERHOLUNGEN = 200\n",
"\n",
"# Die Wahrheit, die der Optimierer nicht kennt: alle Anlagen sind gleich.\n",
"WAHRE_RENDITE = 0.0004 # 0,04 % pro Tag\n",
"WAHRE_SCHWANKUNG = 0.012 # 1,2 % pro Tag\n",
"RISIKOAVERSION = 10.0\n",
"\n",
"RNG = np.random.default_rng(5)\n",
"\n",
"\n",
"def baue_optimierer() -> tuple[cp.Problem, cp.Variable, cp.Parameter, cp.Parameter]:\n",
" \"\"\"Baut das Markowitz-Problem EINMAL mit Parametern.\n",
"\n",
" Bei 200 Wiederholungen ist das der Unterschied zwischen Sekunden und\n",
" Minuten: cp.Parameter erlaubt es, nur die Daten zu tauschen, statt den\n",
" Ausdrucksbaum jedes Mal neu zu kompilieren (siehe Kapitel Oekosystem).\n",
" \"\"\"\n",
" gewichte = cp.Variable(ANZAHL_ANLAGEN, nonneg=True)\n",
" renditen = cp.Parameter(ANZAHL_ANLAGEN)\n",
" kovarianz = cp.Parameter((ANZAHL_ANLAGEN, ANZAHL_ANLAGEN), PSD=True)\n",
" problem = cp.Problem(\n",
" cp.Maximize(renditen @ gewichte\n",
" - RISIKOAVERSION * cp.quad_form(gewichte, kovarianz)),\n",
" [cp.sum(gewichte) == 1])\n",
" return problem, gewichte, renditen, kovarianz\n",
"\n",
"\n",
"def erzeuge(perioden: int) -> np.ndarray:\n",
" \"\"\"Renditen, bei denen alle Anlagen identisch verteilt und unabhaengig sind.\"\"\"\n",
" return RNG.normal(WAHRE_RENDITE, WAHRE_SCHWANKUNG,\n",
" (perioden, ANZAHL_ANLAGEN))\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" problem, gewichte, renditen, kovarianz = baue_optimierer()\n",
"\n",
" im_zeitraum, spaeter, spaeter_gleich = [], [], []\n",
" groesstes_gewicht, schaetzspanne = [], []\n",
"\n",
" for _ in range(WIEDERHOLUNGEN):\n",
" schaetzdaten = erzeuge(BEOBACHTUNGEN)\n",
" spaetere_daten = erzeuge(BEOBACHTUNGEN_SPAETER)\n",
"\n",
" geschaetzte_rendite = schaetzdaten.mean(axis=0)\n",
" geschaetzte_kovarianz = np.cov(schaetzdaten, rowvar=False)\n",
"\n",
" renditen.value = geschaetzte_rendite\n",
" # Symmetrisieren und minimal anheben: numerisches Rauschen kann die\n",
" # Matrix sonst knapp unter die PSD-Grenze druecken (Kapitel Fundament).\n",
" kovarianz.value = ((geschaetzte_kovarianz + geschaetzte_kovarianz.T) / 2\n",
" + 1e-10 * np.eye(ANZAHL_ANLAGEN))\n",
" problem.solve()\n",
"\n",
" w = np.array(gewichte.value).ravel()\n",
" im_zeitraum.append(float((schaetzdaten @ w).mean()))\n",
" spaeter.append(float((spaetere_daten @ w).mean()))\n",
" spaeter_gleich.append(float(spaetere_daten.mean()))\n",
" groesstes_gewicht.append(float(w.max()))\n",
" schaetzspanne.append(float(geschaetzte_rendite.max()\n",
" - geschaetzte_rendite.min()))\n",
"\n",
" prozent = lambda werte: float(np.mean(werte)) * 100\n",
"\n",
" print(\"=\" * 78)\n",
" print(\" WENN DER OPTIMIERER GESCHAETZTE RENDITEN GLAUBT\")\n",
" print(\"=\" * 78)\n",
" print(f\"{ANZAHL_ANLAGEN} Anlagen, {WIEDERHOLUNGEN} Wiederholungen, \"\n",
" f\"je {BEOBACHTUNGEN} Beobachtungen zur Schaetzung.\")\n",
" print(f\"In Wahrheit haben ALLE dieselbe Tagesrendite von \"\n",
" f\"{WAHRE_RENDITE * 100:.4f} % und dieselbe Schwankung.\")\n",
" print(\"Die richtige Antwort waere also: gleichgewichten.\")\n",
" print()\n",
" print(f\"Mittlere Spanne der geschaetzten Renditen: \"\n",
" f\"{prozent(schaetzspanne):.4f} Prozentpunkte\")\n",
" print(f\" -> Das ist das {prozent(schaetzspanne) / (WAHRE_RENDITE * 100):.1f}-Fache \"\n",
" f\"des wahren Werts. Aus reinem Rauschen.\")\n",
" print(f\"Mittleres groesstes Einzelgewicht: \"\n",
" f\"{prozent(groesstes_gewicht):.1f} % \"\n",
" f\"(bei Gleichgewichtung waeren es {100 / ANZAHL_ANLAGEN:.1f} %)\")\n",
" print()\n",
" print(f\"{'':<38} {'Tagesrendite':>14}\")\n",
" print(\"-\" * 78)\n",
" print(f\"{'Wahrheit (alle Anlagen)':<38} {WAHRE_RENDITE * 100:>13.4f} %\")\n",
" print(f\"{'Optimierer IM Schaetzzeitraum':<38} {prozent(im_zeitraum):>13.4f} %\"\n",
" f\" <- Illusion\")\n",
" print(f\"{'Optimierer AUSSERHALB':<38} {prozent(spaeter):>13.4f} %\")\n",
" print(f\"{'Gleichgewichtung AUSSERHALB':<38} {prozent(spaeter_gleich):>13.4f} %\")\n",
" print(\"-\" * 78)\n",
"\n",
" faktor = prozent(im_zeitraum) / (WAHRE_RENDITE * 100)\n",
" vorsprung = prozent(spaeter) - prozent(spaeter_gleich)\n",
" print(f\"\\nIm Schaetzzeitraum verspricht der Optimierer das \"\n",
" f\"{faktor:.1f}-Fache der wahren Rendite.\")\n",
" print(f\"Ausserhalb bleibt davon nichts: Er liegt {abs(vorsprung):.4f} \"\n",
" f\"Prozentpunkte\")\n",
" print(f\"{'schlechter' if vorsprung < 0 else 'besser'} als blosse Gleichgewichtung.\")\n",
" print()\n",
" print(\"Der Grund ist statistisch, nicht finanzwirtschaftlich: Um eine\")\n",
" print(f\"Rendite von {WAHRE_RENDITE*100:.2f} % taeglich von null zu unterscheiden,\")\n",
" print(f\"braucht man bei {WAHRE_SCHWANKUNG*100:.1f} % Schwankung ueber tausend\")\n",
" print(\"Beobachtungen. Mit 250 misst man ueberwiegend Rauschen - und der\")\n",
" print(\"Optimierer nimmt jedes Rauschen fuer bare Muenze.\")\n",
" print()\n",
" print(\"Drei praktische Konsequenzen:\")\n",
" print(\" 1. Verzichten Sie auf Renditeschaetzungen, wo es geht. Das\")\n",
" print(\" Minimum-Varianz-Portfolio braucht gar keine.\")\n",
" print(\" 2. Wenn Sie welche brauchen: schrumpfen Sie sie stark zur Mitte\")\n",
" print(\" (James-Stein, Black-Litterman) - viel staerker als Kovarianzen.\")\n",
" print(\" 3. Begrenzen Sie Einzelgewichte. Was der Optimierer nicht darf,\")\n",
" print(\" kann er auch nicht auf Rauschen setzen.\")\n",
" print(\"=\" * 78)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -0,0 +1,315 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 14: Mehrere Ziele — Pareto-Fronten statt Gewichte\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Das Programm\n",
"\n",
"`Mehrziel_Pareto.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Mehrziel_Pareto.py\n",
"\"\"\"\n",
"Kapitel Mehrziel: Kosten gegen CO2 - und warum Gewichte nicht genuegen.\n",
"\n",
"Eine Spedition vergibt zwoelf Sendungen an drei Verkehrstraeger: LKW (schnell,\n",
"teuer, schmutzig), Bahn (billig und sauber, aber nur fuenf Trassen frei) und\n",
"Kombinierten Verkehr (dazwischen). Zwei Ziele stehen gegeneinander:\n",
"Transportkosten und CO2-Ausstoss.\n",
"\n",
"Der uebliche Reflex ist, beide Ziele zu einem zusammenzuruehren:\n",
"\n",
" minimiere Kosten + w * CO2\n",
"\n",
"Das ist bequem, liefert zulaessige Loesungen - und ist unvollstaendig. Bei\n",
"ganzzahligen Entscheidungen gibt es Kompromisse, die auf diesem Weg\n",
"GRUNDSAETZLICH nicht erreichbar sind, egal welches w man waehlt. Nicht \"schwer\n",
"zu finden\", sondern beweisbar unerreichbar.\n",
"\n",
"Das Programm zeigt in vier Teilen:\n",
"\n",
" 1. Die beiden Extreme - was jedes Ziel allein kostet.\n",
" 2. Die lineare Skalarisierung ueber ein feines Gewichtsraster.\n",
" 3. Die vollstaendige Pareto-Front ueber das eps-Constraint-Verfahren.\n",
" 4. Den Nachweis, dass die Luecke keine Frage des Rasters ist, sondern\n",
" Geometrie: Die fehlenden Punkte liegen strikt oberhalb der konvexen\n",
" Huelle und koennen deshalb von keiner Geraden gestuetzt werden.\n",
"\n",
"Benoetigt: numpy, scipy\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import numpy as np\n",
"from scipy.optimize import linprog\n",
"\n",
"TRAEGER = [\"LKW\", \"Bahn\", \"Kombiniert\"]\n",
"BAHN_TRASSEN = 5 # so viele Sendungen passen hoechstens auf die Bahn\n",
"SAAT = 5\n",
"\n",
"\n",
"def erzeuge_sendungen(anzahl: int = 12, saat: int = SAAT):\n",
" \"\"\"Kosten und CO2 je Sendung und Verkehrstraeger.\n",
"\n",
" Die Bahn ist immer billiger und sauberer als der LKW - der Zielkonflikt\n",
" entsteht nicht zwischen den Traegern, sondern durch die KNAPPHEIT der\n",
" Trassen. Genau so sieht es in der Praxis aus.\n",
" \"\"\"\n",
" rng = np.random.default_rng(saat)\n",
" kosten = np.zeros((anzahl, 3))\n",
" co2 = np.zeros((anzahl, 3))\n",
" for i in range(anzahl):\n",
" grund_kosten = rng.integers(600, 2400)\n",
" grund_co2 = rng.integers(400, 1800)\n",
" kosten[i] = [grund_kosten,\n",
" grund_kosten * rng.uniform(0.55, 0.85),\n",
" grund_kosten * rng.uniform(0.70, 1.00)]\n",
" co2[i] = [grund_co2,\n",
" grund_co2 * rng.uniform(0.15, 0.35),\n",
" grund_co2 * rng.uniform(0.40, 0.70)]\n",
" return np.round(kosten).astype(int), np.round(co2).astype(int)\n",
"\n",
"\n",
"KOSTEN, CO2 = erzeuge_sendungen()\n",
"N = len(KOSTEN)\n",
"\n",
"\n",
"def plane(ziel: np.ndarray, co2_grenze: float | None = None,\n",
" kosten_grenze: float | None = None):\n",
" \"\"\"Jede Sendung genau einem Traeger zuordnen; Trassen sind knapp.\n",
"\n",
" 'ziel' ist die zu minimierende Matrix (Kosten, CO2 oder eine Mischung).\n",
" Die beiden Grenzen sind das Werkzeug fuer eps-Constraint und\n",
" lexikografische Optimierung - sie machen aus einem Ziel eine Schranke.\n",
" \"\"\"\n",
" gleichungen = np.zeros((N, N * 3))\n",
" for i in range(N):\n",
" gleichungen[i, i * 3:(i + 1) * 3] = 1.0 # genau ein Traeger\n",
"\n",
" ungleichungen = [[1.0 if j == 1 else 0.0 for _ in range(N) for j in range(3)]]\n",
" grenzen = [float(BAHN_TRASSEN)]\n",
" if co2_grenze is not None:\n",
" ungleichungen.append(CO2.reshape(-1).astype(float))\n",
" grenzen.append(float(co2_grenze))\n",
" if kosten_grenze is not None:\n",
" ungleichungen.append(KOSTEN.reshape(-1).astype(float))\n",
" grenzen.append(float(kosten_grenze))\n",
"\n",
" ergebnis = linprog(ziel.reshape(-1).astype(float),\n",
" A_ub=ungleichungen, b_ub=grenzen,\n",
" A_eq=gleichungen, b_eq=np.ones(N),\n",
" bounds=(0, 1), integrality=1, method=\"highs\")\n",
" if not ergebnis.success:\n",
" return None\n",
" plan = np.round(ergebnis.x).astype(int)\n",
" return (int(KOSTEN.reshape(-1) @ plan), int(CO2.reshape(-1) @ plan), plan)\n",
"\n",
"\n",
"def pareto_front():\n",
" \"\"\"Vollstaendige Front ueber das eps-Constraint-Verfahren.\n",
"\n",
" Der Ablauf ist der eigentliche Inhalt dieser Funktion: Erst das\n",
" Kostenminimum bestimmen (der eine Rand der Front), dann die CO2-Schranke\n",
" schrittweise um genau ein Kilogramm unter den zuletzt erreichten Wert\n",
" druecken. Jeder Lauf liefert den naechsten Punkt - und wenn keiner mehr\n",
" zulaessig ist, ist die Front vollstaendig.\n",
"\n",
" Das braucht so viele Solveraufrufe, wie die Front Punkte hat. Ein Raster\n",
" ueber alle moeglichen CO2-Werte braeuchte hier fast tausend.\n",
" \"\"\"\n",
" front = []\n",
" start = plane(KOSTEN)\n",
" grenze = start[1]\n",
" while True:\n",
" ergebnis = plane(KOSTEN, co2_grenze=grenze)\n",
" if ergebnis is None:\n",
" break\n",
" front.append((ergebnis[0], ergebnis[1]))\n",
" grenze = ergebnis[1] - 1\n",
" return front\n",
"\n",
"\n",
"def skalarisierung(gewichte):\n",
" \"\"\"Was 'Kosten + w * CO2' fuer viele w hergibt - als Menge von Punkten.\"\"\"\n",
" gefunden = {}\n",
" for w in gewichte:\n",
" ergebnis = plane(KOSTEN + w * CO2)\n",
" if ergebnis:\n",
" gefunden.setdefault((ergebnis[0], ergebnis[1]), []).append(w)\n",
" return gefunden\n",
"\n",
"\n",
"def untere_huelle(punkte):\n",
" \"\"\"Untere linke konvexe Huelle - genau die Punkte, die eine Gerade stuetzt.\n",
"\n",
" Der Zusammenhang, um den es geht: 'Kosten + w*CO2 minimieren' heisst\n",
" geometrisch, eine Gerade der Steigung -1/w von links unten an die\n",
" Punktwolke zu schieben. Sie beruehrt immer einen Eckpunkt der konvexen\n",
" Huelle. Punkte, die oberhalb liegen, werden nie beruehrt - fuer kein w.\n",
" \"\"\"\n",
" huelle = []\n",
" for punkt in sorted(punkte):\n",
" while len(huelle) >= 2:\n",
" (x1, y1), (x2, y2) = huelle[-2], huelle[-1]\n",
" if (x2 - x1) * (punkt[1] - y1) - (y2 - y1) * (punkt[0] - x1) <= 0:\n",
" huelle.pop()\n",
" else:\n",
" break\n",
" huelle.append(punkt)\n",
" return huelle\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 80)\n",
" print(\" KOSTEN GEGEN CO2 - UND WARUM GEWICHTE NICHT GENUEGEN\")\n",
" print(\"=\" * 80)\n",
" print(f\"{N} Sendungen, {len(TRAEGER)} Verkehrstraeger, \"\n",
" f\"{BAHN_TRASSEN} freie Bahntrassen.\\n\")\n",
"\n",
" # --- 1. Die beiden Extreme -------------------------------------------\n",
" guenstigst = plane(KOSTEN)\n",
" saubersten = plane(CO2)\n",
" print(\"1. Was jedes Ziel allein ergibt\\n\")\n",
" print(f\" {'':<22} {'Kosten':>10} {'CO2':>10}\")\n",
" print(\" \" + \"-\" * 44)\n",
" print(f\" {'nur Kosten minimal':<22} {guenstigst[0]:>10,} {guenstigst[1]:>9,} kg\")\n",
" print(f\" {'nur CO2 minimal':<22} {saubersten[0]:>10,} {saubersten[1]:>9,} kg\")\n",
" print(f\"\\n Der Zielkonflikt ist echt, aber klein: \"\n",
" f\"{saubersten[0] - guenstigst[0]:,} EUR mehr\")\n",
" print(f\" ({(saubersten[0] - guenstigst[0]) / guenstigst[0] * 100:.1f} %) sparen \"\n",
" f\"{guenstigst[1] - saubersten[1]:,} kg CO2 \"\n",
" f\"({(guenstigst[1] - saubersten[1]) / guenstigst[1] * 100:.1f} %).\")\n",
" print(\" Genau solche Zahlen will die Geschaeftsfuehrung sehen - nicht ein\")\n",
" print(\" Gewicht, das niemand interpretieren kann.\")\n",
"\n",
" # --- 2. Die lineare Skalarisierung -----------------------------------\n",
" gewichte = np.concatenate([np.linspace(0.0, 3.0, 1201),\n",
" np.geomspace(3.0, 1000.0, 200)])\n",
" gefunden = skalarisierung(gewichte)\n",
" print(\"\\n\" + \"-\" * 80)\n",
" print(f\"2. Lineare Skalarisierung: 'Kosten + w * CO2' fuer \"\n",
" f\"{len(gewichte):,} Gewichte\\n\")\n",
" print(f\" {'Kosten':>10} {'CO2':>10} {'gefunden bei w':>16}\")\n",
" print(\" \" + \"-\" * 46)\n",
" for (k, c), ws in sorted(gefunden.items()):\n",
" print(f\" {k:>10,} {c:>9,} kg {min(ws):>7.3f} bis {max(ws):>7.3f}\")\n",
" print(f\"\\n {len(gefunden)} verschiedene Plaene - fuer {len(gewichte):,} Gewichte.\")\n",
" print(\" Das Gewicht ist also gar keine Feineinstellung: Weite Bereiche\")\n",
" print(\" liefern dasselbe Ergebnis, und dazwischen springt es.\")\n",
"\n",
" # --- 3. Die vollstaendige Front --------------------------------------\n",
" front = pareto_front()\n",
" print(\"\\n\" + \"-\" * 80)\n",
" print(f\"3. Die vollstaendige Pareto-Front ueber eps-Constraint \"\n",
" f\"({len(front)} Solverlaeufe)\\n\")\n",
" print(f\" {'Kosten':>10} {'CO2':>10} {'Aufpreis':>9} {'CO2-Ersparnis':>14} \"\n",
" f\"{'EUR je kg':>10}\")\n",
" print(\" \" + \"-\" * 60)\n",
" for k, c in front:\n",
" auf = k - guenstigst[0]\n",
" ersparnis = guenstigst[1] - c\n",
" preis = auf / ersparnis if ersparnis else 0.0\n",
" print(f\" {k:>10,} {c:>9,} kg {auf:>8,} {ersparnis:>13,} \"\n",
" f\"{preis:>10.2f}\")\n",
"\n",
" # --- 4. Der Nachweis --------------------------------------------------\n",
" huelle = untere_huelle(front)\n",
" unerreichbar = [p for p in front if p not in huelle]\n",
" print(\"\\n\" + \"-\" * 80)\n",
" print(\"4. Was die Skalarisierung nicht findet\\n\")\n",
" erreicht = [p for p in front if p in gefunden]\n",
" print(f\" Pareto-Punkte insgesamt: {len(front)}\")\n",
" print(f\" davon von der Skalarisierung gefunden: {len(erreicht)}\")\n",
" print(f\" nie gefunden: {len(front) - len(erreicht)}\")\n",
" print()\n",
" print(\" Diese Kompromisse sind fuer KEIN Gewicht erreichbar:\\n\")\n",
" print(f\" {'Kosten':>10} {'CO2':>10} {'Aufpreis':>9} {'CO2-Ersparnis':>14}\")\n",
" print(\" \" + \"-\" * 50)\n",
" for k, c in unerreichbar:\n",
" print(f\" {k:>10,} {c:>9,} kg {k - guenstigst[0]:>8,} \"\n",
" f\"{guenstigst[1] - c:>13,}\")\n",
"\n",
" stimmt = sorted(unerreichbar) == sorted(p for p in front if p not in gefunden)\n",
" print(f\"\\n Gegenprobe ueber die Geometrie: {'bestanden' if stimmt else 'ABWEICHUNG'}\")\n",
" print(\" Genau die Punkte, die das Gewichtsraster verfehlt, liegen strikt\")\n",
" print(\" oberhalb der unteren konvexen Huelle. Das ist kein Rasterproblem -\")\n",
" print(\" eine Gerade, die von links unten an die Wolke geschoben wird,\")\n",
" print(\" beruehrt immer einen Eckpunkt der Huelle und nie einen Punkt\")\n",
" print(\" darueber. Ein feineres Raster aendert daran nichts.\")\n",
"\n",
" # --- 5. Lexikografisch -------------------------------------------------\n",
" print(\"\\n\" + \"-\" * 80)\n",
" print(\"5. Lexikografisch: erst Kosten, dann CO2 im Rahmen eines Budgets\\n\")\n",
" print(f\" {'Kostenbudget':>14} {'Kosten':>10} {'CO2':>10} {'gegenueber Minimum':>20}\")\n",
" print(\" \" + \"-\" * 58)\n",
" for aufschlag in (0.00, 0.01, 0.02, 0.05, 0.10):\n",
" budget = guenstigst[0] * (1 + aufschlag)\n",
" ergebnis = plane(CO2, kosten_grenze=budget)\n",
" if ergebnis is None:\n",
" print(f\" {aufschlag:>13.0%} unzulaessig\")\n",
" continue\n",
" print(f\" {aufschlag:>13.0%} {ergebnis[0]:>10,} {ergebnis[1]:>9,} kg \"\n",
" f\"{guenstigst[1] - ergebnis[1]:>15,} kg weniger\")\n",
"\n",
" print(\"\\n Das ist die Form, die im Betrieb am ehesten trifft: Nicht 'wie\")\n",
" print(\" wichtig ist CO2?', sondern 'wir geben zwei Prozent mehr aus - was\")\n",
" print(\" bringt das?'. Die Frage kann ein Kaufmann beantworten.\")\n",
"\n",
" print(\"\\n\" + \"=\" * 80)\n",
" print(\" WAS MAN DARAUS MITNIMMT\")\n",
" print(\"=\" * 80)\n",
" print(\"Ein Gewicht zu setzen heisst, die Entscheidung heimlich zu treffen -\")\n",
" print(\"und dabei einen Teil der Moeglichkeiten gar nicht erst zu sehen.\")\n",
" print()\n",
" print(\"Die Pareto-Front ist die ehrlichere Antwort: Sie legt dem Betrieb\")\n",
" print(\"alle sinnvollen Kompromisse vor und ueberlaesst ihm die Wahl. Die\")\n",
" print(\"Spalte 'EUR je kg' macht sie entscheidbar - man vergleicht sie mit\")\n",
" print(\"dem CO2-Preis, den das Unternehmen ohnehin ansetzt.\")\n",
" print(\"=\" * 80)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -0,0 +1,906 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 9: Metaheuristiken — wenn der exakte Solver aussteigt\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Simulated Annealing\n",
"\n",
"`Simulated_Annealing.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Simulated_Annealing.py\n",
"\"\"\"\n",
"Kapitel Metaheuristiken: Simulated Annealing an der Lackieranlage.\n",
"\n",
"Aufgabe: n Auftraege in eine Reihenfolge bringen. Zwischen zwei Auftraegen\n",
"faellt eine Ruestzeit an - die Anlage muss gereinigt werden. Wie lange das\n",
"dauert, haengt von BEIDEN Auftraegen ab: Ein Wechsel innerhalb derselben\n",
"Produktfamilie kostet fast nichts, ein Wechsel von Dunkel nach Hell ist teuer,\n",
"und zwischen manchen Familien ist der Reinigungsaufwand schlicht hoch. Gesucht\n",
"ist die Reihenfolge mit der kleinsten Summe der Ruestzeiten.\n",
"\n",
"Das Programm zeigt drei Dinge:\n",
"\n",
" 1. Warum die Faustregel \"immer der billigste naechste Auftrag\" in eine\n",
" Sackgasse laeuft - und wie weit man sie mit lokaler Suche verbessert.\n",
" 2. Wie gross der Beitrag der Verschlechterungen WIRKLICH ist. Die Antwort\n",
" faellt bescheidener aus als das Lehrbuch verspricht, und genau das ist\n",
" der Grund, den Vergleich immer mitzurechnen.\n",
" 3. Dass die Starttemperatur kein Beiwerk ist: zu heiss macht die Suche\n",
" nutzlos, und man sieht es an einer einzigen Kennzahl kommen.\n",
"\n",
"Zum Budget: Der Abkuehlplan laeuft ueber eine feste ZUGZAHL, nicht ueber eine\n",
"Sekundenzahl. Im Betrieb hat man zwar ein Zeitbudget - fuer einen VERGLEICH ist\n",
"die Zugzahl aber das ehrlichere Mass: Sie ist auf jeder Maschine dieselbe, und\n",
"die abgedruckten Zahlen unten lassen sich damit nachrechnen. Die gemessene\n",
"Laufzeit steht trotzdem dabei.\n",
"\n",
"Benoetigt: numpy\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import math\n",
"import time\n",
"\n",
"import numpy as np\n",
"\n",
"ZUEGE = 400_000 # Zugbudget je Lauf (statt Sekunden: reproduzierbar)\n",
"SAAT = 11 # feste Saat -> reproduzierbare Instanz\n",
"\n",
"\n",
"# --- 1. Die Instanz ---------------------------------------------------------\n",
"\n",
"def erzeuge_ruestmatrix(n: int, saat: int = SAAT) -> np.ndarray:\n",
" \"\"\"Ruestzeiten in Minuten zwischen je zwei Auftraegen.\n",
"\n",
" Die Zahl der Produktfamilien waechst mit der Auftragszahl (eine Familie je\n",
" zehn Auftraege). Sonst wuerde das Problem mit wachsendem n LEICHTER: Bei\n",
" fester Familienzahl haette der Plan irgendwann nur noch ein paar grosse\n",
" Bloecke, und jede Faustregel faende ihn.\n",
" \"\"\"\n",
" rng = np.random.default_rng(saat)\n",
" familien = max(6, n // 10)\n",
" # Reinigungsaufwand zwischen den Familien - unregelmaessig und asymmetrisch,\n",
" # so wie im Betrieb: Von Klarlack auf Rot ist etwas anderes als umgekehrt.\n",
" zwischen = rng.integers(8, 60, (familien, familien))\n",
" np.fill_diagonal(zwischen, 2)\n",
"\n",
" familie = rng.integers(0, familien, n)\n",
" farbe = rng.integers(0, 10, n) # 0 = weiss ... 9 = schwarz\n",
"\n",
" matrix = np.zeros((n, n), dtype=np.int64)\n",
" for i in range(n):\n",
" for j in range(n):\n",
" if i != j:\n",
" # Dunkel -> hell kostet zusaetzlich: die Anlage muss heller\n",
" # werden, das braucht mehr Spuelgaenge.\n",
" matrix[i, j] = (zwischen[familie[i], familie[j]]\n",
" + 3 * max(0, farbe[i] - farbe[j]))\n",
" return matrix\n",
"\n",
"\n",
"def gesamtruestzeit(reihe: list[int], matrix: np.ndarray) -> int:\n",
" \"\"\"Summe der Ruestzeiten einer Reihenfolge (offene Kette, keine Rundreise).\"\"\"\n",
" return int(sum(matrix[reihe[k], reihe[k + 1]] for k in range(len(reihe) - 1)))\n",
"\n",
"\n",
"def faustregel(matrix: np.ndarray) -> list[int]:\n",
" \"\"\"Immer der billigste noch offene Auftrag - 'naechster Nachbar'.\n",
"\n",
" So plant ein Meister von Hand, und es ist keine schlechte Regel. Ihr Fehler\n",
" ist die Kurzsichtigkeit: Sie spart am Anfang und laesst die teuren Wechsel\n",
" fuer das Ende uebrig, wo keine Wahl mehr bleibt.\n",
" \"\"\"\n",
" n = len(matrix)\n",
" offen = set(range(1, n))\n",
" reihe = [0]\n",
" while offen:\n",
" naechster = min(offen, key=lambda j: matrix[reihe[-1], j])\n",
" reihe.append(naechster)\n",
" offen.discard(naechster)\n",
" return reihe\n",
"\n",
"\n",
"# --- 2. Der Zug und seine Kostenaenderung -----------------------------------\n",
"\n",
"def delta_verschieben(reihe: list[int], matrix: np.ndarray,\n",
" von: int, nach: int):\n",
" \"\"\"Auftrag von Position 'von' an Position 'nach' versetzen.\n",
"\n",
" Liefert (Kostenaenderung, Restliste, Einfuegeposition) oder None, wenn der\n",
" Zug nichts aendert.\n",
"\n",
" DAS IST DIE WICHTIGSTE FUNKTION DES PROGRAMMS. Sie berechnet die\n",
" Kostenaenderung aus HOECHSTENS SECHS Matrixeintraegen, statt die\n",
" Reihenfolge neu durchzusummieren. Der Unterschied ist nicht kosmetisch:\n",
" Die volle Summe kostet O(n) je Zug, diese Rechnung O(1). Bei 500 Auftraegen\n",
" sind das rund 500-mal mehr geprueste Zuege im selben Zeitbudget - und eine\n",
" Metaheuristik lebt von der Zahl der Zuege.\n",
"\n",
" Herausgerissen wird der Auftrag aus zwei Kanten, seine Nachbarn ruecken\n",
" zusammen (eine neue Kante). Eingefuegt wird er zwischen zwei andere\n",
" Nachbarn, deren bisherige Kante dadurch verschwindet.\n",
" \"\"\"\n",
" n = len(reihe)\n",
" if von == nach or nach == von + 1:\n",
" return None\n",
"\n",
" heraus = 0\n",
" if von > 0:\n",
" heraus += matrix[reihe[von - 1], reihe[von]]\n",
" if von < n - 1:\n",
" heraus += matrix[reihe[von], reihe[von + 1]]\n",
" if 0 < von < n - 1: # die Nachbarn ruecken zusammen\n",
" heraus -= matrix[reihe[von - 1], reihe[von + 1]]\n",
"\n",
" rest = reihe[:von] + reihe[von + 1:]\n",
" stelle = nach if nach < von else nach - 1\n",
"\n",
" hinein = 0\n",
" if stelle > 0:\n",
" hinein += matrix[rest[stelle - 1], reihe[von]]\n",
" if stelle < len(rest):\n",
" hinein += matrix[reihe[von], rest[stelle]]\n",
" if 0 < stelle < len(rest): # die aufgetrennte Kante faellt weg\n",
" hinein -= matrix[rest[stelle - 1], rest[stelle]]\n",
"\n",
" return int(hinein - heraus), rest, stelle\n",
"\n",
"\n",
"# --- 3. Die Suche -----------------------------------------------------------\n",
"\n",
"class Verlauf:\n",
" \"\"\"Was ein Lauf ausser dem Ergebnis noch verraet.\"\"\"\n",
"\n",
" def __init__(self) -> None:\n",
" self.schritte = 0\n",
" self.verschlechterungen_geprueft = 0\n",
" self.verschlechterungen_genommen = 0\n",
" self.schlechtester_zustand = 0 # wie weit die Suche abgedriftet ist\n",
" self.sekunden = 0.0 # nur zur Information, nicht zum Vergleich\n",
"\n",
" @property\n",
" def annahmequote(self) -> float:\n",
" if not self.verschlechterungen_geprueft:\n",
" return 0.0\n",
" return self.verschlechterungen_genommen / self.verschlechterungen_geprueft\n",
"\n",
"\n",
"def suche(matrix: np.ndarray, zuege: int, start_temperatur: float,\n",
" end_temperatur: float = 0.05, saat: int = 1,\n",
" bergsteigen: bool = False) -> tuple[list[int], int, Verlauf]:\n",
" \"\"\"Simulated Annealing (oder reines Bergsteigen, wenn bergsteigen=True).\n",
"\n",
" Der einzige Unterschied zwischen beiden steht in der Annahmezeile: Das\n",
" Bergsteigen nimmt nur Verbesserungen, SA nimmt Verschlechterungen mit einer\n",
" Wahrscheinlichkeit an, die mit der Zeit gegen null geht.\n",
" \"\"\"\n",
" rng = np.random.default_rng(saat)\n",
" n = len(matrix)\n",
" reihe = faustregel(matrix)\n",
" kosten = gesamtruestzeit(reihe, matrix)\n",
" beste, beste_kosten = reihe[:], kosten\n",
" verlauf = Verlauf()\n",
" verlauf.schlechtester_zustand = kosten\n",
"\n",
" start = time.perf_counter()\n",
" for zug in range(zuege):\n",
" verlauf.schritte += 1\n",
" # Geometrischer Abkuehlplan ueber das Zugbudget.\n",
" temperatur = start_temperatur * (end_temperatur / start_temperatur) \\\n",
" ** (zug / zuege)\n",
"\n",
" von = int(rng.integers(0, n))\n",
" nach = int(rng.integers(0, n + 1))\n",
" ergebnis = delta_verschieben(reihe, matrix, von, nach)\n",
" if ergebnis is None:\n",
" continue\n",
" aenderung, rest, stelle = ergebnis\n",
"\n",
" if aenderung <= 0:\n",
" annehmen = True\n",
" else:\n",
" verlauf.verschlechterungen_geprueft += 1\n",
" # Metropolis-Kriterium: je groesser die Verschlechterung und je\n",
" # kaelter es ist, desto unwahrscheinlicher.\n",
" annehmen = (not bergsteigen\n",
" and rng.random() < math.exp(-aenderung / temperatur))\n",
" verlauf.verschlechterungen_genommen += annehmen\n",
"\n",
" if annehmen:\n",
" reihe = rest[:stelle] + [reihe[von]] + rest[stelle:]\n",
" kosten += aenderung\n",
" verlauf.schlechtester_zustand = max(verlauf.schlechtester_zustand,\n",
" kosten)\n",
" if kosten < beste_kosten:\n",
" beste, beste_kosten = reihe[:], kosten\n",
"\n",
" verlauf.sekunden = time.perf_counter() - start\n",
" return beste, beste_kosten, verlauf\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" N = 200\n",
" matrix = erzeuge_ruestmatrix(N)\n",
" start_reihe = faustregel(matrix)\n",
" start_kosten = gesamtruestzeit(start_reihe, matrix)\n",
"\n",
" print(\"=\" * 78)\n",
" print(\" SIMULATED ANNEALING AN DER LACKIERANLAGE\")\n",
" print(\"=\" * 78)\n",
" print(f\"{N} Auftraege, {max(6, N // 10)} Produktfamilien, \"\n",
" f\"Zugbudget {ZUEGE:,} je Lauf.\\n\")\n",
" print(f\"Faustregel 'billigster naechster Auftrag': \"\n",
" f\"{start_kosten:,} Minuten Ruestzeit\")\n",
"\n",
" # --- Wie gross sind die Zuege ueberhaupt? ---------------------------\n",
" # Ohne diese Zahl kann man die Temperatur nicht waehlen: Das\n",
" # Metropolis-Kriterium vergleicht die Verschlechterung MIT der Temperatur.\n",
" rng = np.random.default_rng(0)\n",
" stichprobe = []\n",
" for _ in range(5000):\n",
" e = delta_verschieben(start_reihe, matrix,\n",
" int(rng.integers(0, N)), int(rng.integers(0, N + 1)))\n",
" if e:\n",
" stichprobe.append(abs(e[0]))\n",
" print(f\"Typische Zuggroesse |Delta|: Median {np.median(stichprobe):.0f}, \"\n",
" f\"90 %-Quantil {np.quantile(stichprobe, 0.9):.0f} Minuten\")\n",
"\n",
" # --- Bergsteigen gegen Annealing -------------------------------------\n",
" print(\"\\n\" + \"-\" * 78)\n",
" print(\"Nur Verbesserungen annehmen (Bergsteigen) gegen Annealing:\\n\")\n",
" _, berg, berg_verlauf = suche(matrix, ZUEGE, 1.0, bergsteigen=True)\n",
" _, gluehen, gluehen_verlauf = suche(matrix, ZUEGE, 1.0)\n",
" print(f\" {'Faustregel (Start)':<34} {start_kosten:>7,} Minuten\")\n",
" print(f\" {'Bergsteigen':<34} {berg:>7,} Minuten \"\n",
" f\"({(start_kosten - berg) / start_kosten * 100:5.1f} % besser)\")\n",
" print(f\" {'Simulated Annealing':<34} {gluehen:>7,} Minuten \"\n",
" f\"({(start_kosten - gluehen) / start_kosten * 100:5.1f} % besser)\")\n",
" print(f\"\\n Beide pruefen genau {berg_verlauf.schritte:,} Zuege \"\n",
" f\"({berg_verlauf.sekunden:.1f} bzw. {gluehen_verlauf.sekunden:.1f} Sekunden).\")\n",
" print(f\" Annealing nimmt davon {gluehen_verlauf.annahmequote * 100:.2f} % der \"\n",
" f\"VERSCHLECHTERUNGEN an, Bergsteigen keine.\")\n",
" print(\"\\n Bemerkenswert ist, wie klein der Abstand ist: Das simple\")\n",
" print(f\" Bergsteigen holt hier bereits {(start_kosten - berg) / (start_kosten - gluehen) * 100:.0f} % dessen, was Annealing\")\n",
" print(\" schafft. Wer eine Metaheuristik einsetzt, sollte diesen Vergleich\")\n",
" print(\" IMMER mitrechnen - sonst schreibt man einer aufwendigen Methode\")\n",
" print(\" gut, was schon die einfache geliefert haette.\")\n",
"\n",
" # --- Die Temperatur ist kein Beiwerk ---------------------------------\n",
" print(\"\\n\" + \"-\" * 78)\n",
" print(\"Und warum die Starttemperatur ausgemessen gehoert:\\n\")\n",
" print(f\" {'Start-T':>8} {'Ruestzeit':>11} {'gg. Faustregel':>15} \"\n",
" f\"{'angenommene':>12} {'schlechtester':>14}\")\n",
" print(f\" {'':>8} {'':>11} {'':>15} {'Verschlecht.':>12} {'Zwischenwert':>14}\")\n",
" print(\" \" + \"-\" * 64)\n",
" ergebnisse, verlaeufe = {}, {}\n",
" for temperatur in (0.5, 1.0, 2.0, 4.0, 8.0):\n",
" _, wert, verlauf = suche(matrix, ZUEGE, temperatur)\n",
" ergebnisse[temperatur] = wert\n",
" verlaeufe[temperatur] = verlauf\n",
" gewinn = (start_kosten - wert) / start_kosten * 100\n",
" print(f\" {temperatur:>8.1f} {wert:>11,} {gewinn:>13.1f} % \"\n",
" f\"{verlauf.annahmequote * 100:>11.2f} % \"\n",
" f\"{verlauf.schlechtester_zustand:>14,}\")\n",
"\n",
" beste_temperatur = min(ergebnisse, key=ergebnisse.get)\n",
" heiss = verlaeufe[8.0]\n",
" print(f\"\\n Bester Wert bei T0 = {beste_temperatur}.\")\n",
" print(\"\\n Die letzten beiden Spalten erklaeren, warum es nach oben kippt.\")\n",
" print(f\" Bei T0 = 8 gehen nur {heiss.annahmequote * 100:.2f} % der Verschlechterungen\")\n",
" print(\" durch - wenig genug, dass man es fuer harmlos halten koennte. Es\")\n",
" print(f\" genuegt aber: Die Suche driftet bis auf {heiss.schlechtester_zustand:,} Minuten ab, das\")\n",
" print(f\" {heiss.schlechtester_zustand / start_kosten:.1f}-fache der Startloesung, und findet im Zugbudget nicht\")\n",
" print(\" mehr zurueck. Vom Ergebnis bleibt fast nichts uebrig: Die Suche hat\")\n",
" print(f\" in {ZUEGE:,} Zuegen nie etwas GESEHEN, das mehr als\")\n",
" print(f\" {(start_kosten - ergebnisse[8.0]) / start_kosten * 100:.1f} % unter der Startloesung lag.\")\n",
" print(\"\\n Bemerkenswert ist die andere Richtung: Die besten Werte entstehen\")\n",
" print(\" bei Annahmequoten nahe null. Die Lehrbuchregel 'anfangs sollen\")\n",
" print(\" 20 bis 50 Prozent der Verschlechterungen durchgehen' fuehrt hier in\")\n",
" print(\" die Irre - sie stammt aus Problemen mit sehr kleinen Zuggroessen.\")\n",
" print(\" Bei Ruestzeiten in Minuten ist ein einziger schlechter Zug teuer.\")\n",
" print(\"\\n Was stattdessen traegt: Den SCHLECHTESTEN Zwischenwert protokollieren\")\n",
" print(\" und T0 so waehlen, dass er die Startloesung nicht wesentlich\")\n",
" print(\" ueberschreitet. Diese eine Zahl haette hier auf Anhieb gezeigt, dass\")\n",
" print(\" T0 = 8 unbrauchbar ist - ohne dass man das Endergebnis abwarten muss.\")\n",
" print(\"=\" * 78)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Ab wann lohnt es sich?\n",
"\n",
"`Metaheuristik_vs_Exakt.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Metaheuristik_vs_Exakt.py\n",
"\"\"\"\n",
"Kapitel Metaheuristiken: Ab wann lohnt sich eine Metaheuristik? Die Antwort ist eine Zahl.\n",
"\n",
"Die verbreitete Regel lautet: \"Kleine Probleme exakt, grosse mit\n",
"Metaheuristiken.\" Sie ist richtig - aber voellig nutzlos, solange niemand sagt,\n",
"wo 'gross' anfaengt. Dieses Programm misst es fuer die Ruestzeitenaufgabe aus\n",
"Simulated_Annealing.py nach, und das Ergebnis ueberrascht in beide Richtungen:\n",
"\n",
" * Bei 60 Auftraegen loest CP-SAT BEWEISBAR optimal, in wenigen Sekunden.\n",
" Die Metaheuristik ist hier schlicht die schlechtere Wahl - sie liefert ein\n",
" schwaecheres Ergebnis und weiss nicht einmal, wie schwach.\n",
" * Bei 200 Auftraegen liegt CP-SAT ohne Optimalitaetsbeweis vorne.\n",
" * Erst bei 500 Auftraegen kippt es: Dort gibt CP-SAT im Zeitlimit eine\n",
" Loesung aus, die SCHLECHTER ist als die Faustregel eines Meisters.\n",
"\n",
"Der Punkt des Kapitels steht in der letzten Spalte: Die Schranke. Nur der\n",
"exakte Solver sagt, wie gut die Loesung sein KANN. Eine Metaheuristik allein\n",
"liefert eine Zahl ohne Massstab - man weiss nie, ob man 2 % oder 40 % neben dem\n",
"Optimum liegt.\n",
"\n",
"ZUR REPRODUZIERBARKEIT: Die Spalte 'Annealing' ist auf jeder Maschine gleich -\n",
"sie laeuft ueber ein festes ZUGbudget. Die CP-SAT-Spalten sind es nicht: Ein\n",
"Zeitlimit ist ein Wanduhr-Limit, und wie weit der Solver in 30 Sekunden kommt,\n",
"haengt von der Maschine ab. CP-SAT kennt zwar ein deterministisches Zeitmass\n",
"(max_deterministic_time), das aber in einer Groessenordnung rechnet, die fuer\n",
"ein Buchbeispiel unbrauchbar langsam ist. Die Zahlen unten sind also\n",
"hardwareabhaengig - die AUSSAGE der Tabelle ist es nicht, sie ist auf\n",
"verschiedenen Groessen und in mehreren Laeufen stabil.\n",
"\n",
"WICHTIG: Hier wird nur ortools importiert, nicht highspy (Kapitel Oekosystem).\n",
"\n",
"Benoetigt: numpy, ortools\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import time\n",
"\n",
"import numpy as np\n",
"from ortools.sat.python import cp_model\n",
"\n",
"ZUEGE = 400_000 # Zugbudget der Metaheuristik (wie Simulated_Annealing.py)\n",
"ZEITLIMIT = 30.0 # Zeitbudget des exakten Solvers\n",
"GROESSEN = (60, 200, 500)\n",
"\n",
"\n",
"# --- Instanz, Faustregel und Suche: identisch zu Simulated_Annealing.py -----\n",
"\n",
"def erzeuge_ruestmatrix(n: int, saat: int = 11) -> np.ndarray:\n",
" rng = np.random.default_rng(saat)\n",
" familien = max(6, n // 10)\n",
" zwischen = rng.integers(8, 60, (familien, familien))\n",
" np.fill_diagonal(zwischen, 2)\n",
" familie = rng.integers(0, familien, n)\n",
" farbe = rng.integers(0, 10, n)\n",
" matrix = np.zeros((n, n), dtype=np.int64)\n",
" for i in range(n):\n",
" for j in range(n):\n",
" if i != j:\n",
" matrix[i, j] = (zwischen[familie[i], familie[j]]\n",
" + 3 * max(0, farbe[i] - farbe[j]))\n",
" return matrix\n",
"\n",
"\n",
"def gesamtruestzeit(reihe: list[int], matrix: np.ndarray) -> int:\n",
" return int(sum(matrix[reihe[k], reihe[k + 1]] for k in range(len(reihe) - 1)))\n",
"\n",
"\n",
"def faustregel(matrix: np.ndarray) -> list[int]:\n",
" n = len(matrix)\n",
" offen = set(range(1, n))\n",
" reihe = [0]\n",
" while offen:\n",
" naechster = min(offen, key=lambda j: matrix[reihe[-1], j])\n",
" reihe.append(naechster)\n",
" offen.discard(naechster)\n",
" return reihe\n",
"\n",
"\n",
"def delta_verschieben(reihe, matrix, von, nach):\n",
" n = len(reihe)\n",
" if von == nach or nach == von + 1:\n",
" return None\n",
" heraus = 0\n",
" if von > 0:\n",
" heraus += matrix[reihe[von - 1], reihe[von]]\n",
" if von < n - 1:\n",
" heraus += matrix[reihe[von], reihe[von + 1]]\n",
" if 0 < von < n - 1:\n",
" heraus -= matrix[reihe[von - 1], reihe[von + 1]]\n",
" rest = reihe[:von] + reihe[von + 1:]\n",
" stelle = nach if nach < von else nach - 1\n",
" hinein = 0\n",
" if stelle > 0:\n",
" hinein += matrix[rest[stelle - 1], reihe[von]]\n",
" if stelle < len(rest):\n",
" hinein += matrix[reihe[von], rest[stelle]]\n",
" if 0 < stelle < len(rest):\n",
" hinein -= matrix[rest[stelle - 1], rest[stelle]]\n",
" return int(hinein - heraus), rest, stelle\n",
"\n",
"\n",
"def annealing(matrix: np.ndarray, zuege: int = ZUEGE,\n",
" start_temperatur: float = 1.0, saat: int = 1) -> tuple[int, float]:\n",
" \"\"\"Dieselbe Suche wie in Simulated_Annealing.py, hier nur als Vergleichswert.\"\"\"\n",
" import math\n",
"\n",
" rng = np.random.default_rng(saat)\n",
" n = len(matrix)\n",
" reihe = faustregel(matrix)\n",
" kosten = gesamtruestzeit(reihe, matrix)\n",
" beste_kosten = kosten\n",
"\n",
" t0 = time.perf_counter()\n",
" for zug in range(zuege):\n",
" temperatur = start_temperatur * (0.05 / start_temperatur) ** (zug / zuege)\n",
" von = int(rng.integers(0, n))\n",
" nach = int(rng.integers(0, n + 1))\n",
" ergebnis = delta_verschieben(reihe, matrix, von, nach)\n",
" if ergebnis is None:\n",
" continue\n",
" aenderung, rest, stelle = ergebnis\n",
" if aenderung <= 0 or rng.random() < math.exp(-aenderung / temperatur):\n",
" reihe = rest[:stelle] + [reihe[von]] + rest[stelle:]\n",
" kosten += aenderung\n",
" beste_kosten = min(beste_kosten, kosten)\n",
" return beste_kosten, time.perf_counter() - t0\n",
"\n",
"\n",
"# --- Der exakte Weg: CP-SAT mit AddCircuit ---------------------------------\n",
"\n",
"def exakt(matrix: np.ndarray, zeitlimit: float = ZEITLIMIT):\n",
" \"\"\"Reihenfolge als Rundreise mit einem kostenlosen Hilfsknoten.\n",
"\n",
" AddCircuit verlangt einen geschlossenen Kreis. Unsere Aufgabe ist aber eine\n",
" offene Kette: Der erste Auftrag hat keinen Vorgaenger, der letzte keinen\n",
" Nachfolger. Der uebliche Kniff ist ein zusaetzlicher Hilfsknoten n, dessen\n",
" Kanten nichts kosten. Der Kreis laeuft dann Hilfsknoten -> Kette ->\n",
" Hilfsknoten, und die Kosten des Kreises sind genau die der Kette.\n",
"\n",
" AddCircuit ist der Grund, warum CP-SAT hier so lange mithaelt: Die\n",
" Nebenbedingung sorgt selbst dafuer, dass keine Teilkreise entstehen. Wer\n",
" dasselbe als MILP formuliert, braucht dafuer entweder exponentiell viele\n",
" Ungleichungen oder die schwache MTZ-Formulierung (Kapitel Graphen).\n",
" \"\"\"\n",
" n = len(matrix)\n",
" modell = cp_model.CpModel()\n",
" kanten, ziel = [], []\n",
" for i in range(n + 1):\n",
" for j in range(n + 1):\n",
" if i == j:\n",
" continue\n",
" aktiv = modell.NewBoolVar(f\"kante_{i}_{j}\")\n",
" kanten.append((i, j, aktiv))\n",
" if i < n and j < n: # Hilfsknoten kostet nichts\n",
" ziel.append(int(matrix[i, j]) * aktiv)\n",
" modell.AddCircuit(kanten)\n",
" modell.Minimize(sum(ziel))\n",
"\n",
" loeser = cp_model.CpSolver()\n",
" loeser.parameters.max_time_in_seconds = zeitlimit\n",
" loeser.parameters.num_workers = 8\n",
" loeser.parameters.random_seed = 1\n",
" t0 = time.perf_counter()\n",
" status = loeser.Solve(modell)\n",
" dauer = time.perf_counter() - t0\n",
" return (loeser.StatusName(status), int(loeser.ObjectiveValue()),\n",
" int(loeser.BestObjectiveBound()), dauer)\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 88)\n",
" print(\" AB WANN LOHNT SICH DIE METAHEURISTIK?\")\n",
" print(\"=\" * 88)\n",
" print(f\"Dieselbe Aufgabe in drei Groessen. Metaheuristik: {ZUEGE:,} Zuege. \"\n",
" f\"Exakt: {ZEITLIMIT:.0f} s Zeitlimit.\\n\")\n",
"\n",
" print(f\"{'Auftraege':>10} {'Faustregel':>11} {'Annealing':>10} \"\n",
" f\"{'CP-SAT':>9} {'Status':>10} {'Schranke':>9} {'CP-Zeit':>9}\")\n",
" print(\"-\" * 88)\n",
"\n",
" zeilen = []\n",
" for n in GROESSEN:\n",
" matrix = erzeuge_ruestmatrix(n)\n",
" start = gesamtruestzeit(faustregel(matrix), matrix)\n",
" heuristisch, _ = annealing(matrix)\n",
" status, wert, schranke, dauer = exakt(matrix)\n",
" zeilen.append((n, start, heuristisch, wert, status, schranke, dauer))\n",
" print(f\"{n:>10} {start:>11,} {heuristisch:>10,} {wert:>9,} \"\n",
" f\"{status:>10} {schranke:>9,} {dauer:>8.1f}s\")\n",
"\n",
" print(\"-\" * 88)\n",
" print(\"\\nWas in jeder Zeile steht:\\n\")\n",
" for n, start, heur, wert, status, schranke, _ in zeilen:\n",
" if status == \"OPTIMAL\":\n",
" urteil = (f\"CP-SAT beweist das Optimum. Annealing liegt \"\n",
" f\"{(heur - wert) / wert * 100:.1f} % daneben - und haette\")\n",
" zweite = \" das ohne den exakten Lauf nicht erfahren.\"\n",
" elif wert <= heur:\n",
" urteil = (f\"CP-SAT liegt {(heur - wert) / heur * 100:.1f} % vor Annealing, \"\n",
" f\"ohne Beweis (Gap {(wert - schranke) / wert * 100:.1f} %).\")\n",
" zweite = \" Auch ohne Optimalitaetsbeweis ist es die bessere Wahl.\"\n",
" else:\n",
" urteil = (f\"UMSCHLAGPUNKT: CP-SAT ist {(wert - heur) / heur * 100:.1f} % \"\n",
" f\"SCHLECHTER als Annealing\")\n",
" zweite = (f\" und sogar {(wert - start) / start * 100:.1f} % schlechter \"\n",
" f\"als die Faustregel des Meisters.\")\n",
" print(f\" {n:>4} Auftraege: {urteil}\")\n",
" print(zweite)\n",
"\n",
" n, start, heur, wert, status, schranke, _ = zeilen[-1]\n",
" print(\"\\n\" + \"=\" * 88)\n",
" print(\" DIE SPALTE, DIE MAN NICHT WEGLASSEN DARF\")\n",
" print(\"=\" * 88)\n",
" print(\"Die Schranke ist der eigentliche Ertrag des exakten Solvers - auch dann,\")\n",
" print(\"wenn seine LOESUNG unbrauchbar ist.\")\n",
" print()\n",
" print(f\"Bei {n} Auftraegen liefert Annealing {heur:,} Minuten. Klingt gut: \"\n",
" f\"{(start - heur) / start * 100:.1f} %\")\n",
" print(\"besser als die Faustregel. Die Schranke aus demselben CP-SAT-Lauf, dessen\")\n",
" print(f\"Loesung wir gerade verworfen haben, sagt aber: Unter {schranke:,} Minuten geht\")\n",
" print(f\"es nicht. Zwischen {schranke:,} und {heur:,} liegen {(heur - schranke) / heur * 100:.0f} % - so viel Luft\")\n",
" print(\"kann noch nach oben sein.\")\n",
" print()\n",
" print(\"Das ist der Unterschied zwischen 'besser als vorher' und 'gut'. Eine\")\n",
" print(\"Metaheuristik allein kann nur das Erste. Deshalb gehoert auch dann ein\")\n",
" print(\"exakter Lauf dazu, wenn man am Ende die heuristische Loesung einsetzt:\")\n",
" print(\"nicht wegen seiner Loesung, sondern wegen seiner Schranke.\")\n",
" print(\"=\" * 88)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Large Neighborhood Search\n",
"\n",
"`Large_Neighborhood_Search.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Large_Neighborhood_Search.py\n",
"\"\"\"\n",
"Kapitel Metaheuristiken: Zerstoeren und exakt reparieren - der Solver im Dienst der Heuristik.\n",
"\n",
"Metaheuristik_vs_Exakt.py endet mit einem Widerspruch. Bei 500 Auftraegen gilt:\n",
"\n",
" * Der exakte Solver scheitert an der GROESSE - seine Loesung nach 30 Sekunden\n",
" ist schlechter als die Faustregel eines Meisters.\n",
" * Die Metaheuristik kommt weiter, laesst aber laut Schranke noch rund ein\n",
" Viertel liegen. Ihre Zuege sind zu kleinteilig: Sie verschiebt jeweils\n",
" EINEN Auftrag und kann eine ganze Passage nicht auf einmal umbauen.\n",
"\n",
"Large Neighborhood Search loest den Widerspruch, indem sie beide einsetzt -\n",
"jeden fuer das, was er kann:\n",
"\n",
" ZERSTOEREN Ein Stueck des Plans herausbrechen (hier: ein zusammen-\n",
" haengendes Fenster von 20 Auftraegen).\n",
" REPARIEREN Genau dieses Stueck EXAKT neu optimieren. 20 Auftraege sind\n",
" fuer CP-SAT eine Kleinigkeit, 500 sind es nicht.\n",
" UEBERNEHMEN Nur behalten, wenn der Gesamtplan besser wurde.\n",
"\n",
"Der Solver bekommt also nicht mehr das ganze Problem, sondern immer wieder ein\n",
"kleines. Das ist der ganze Trick, und er ist in der Praxis der wichtigste\n",
"Baustein dieses Kapitels.\n",
"\n",
"ZUR REPRODUZIERBARKEIT: Alle Laeufe haben eine feste RUNDENzahl, nicht ein\n",
"Zeitbudget - die Ergebniswerte sind damit auf jeder Maschine gleich. Die\n",
"Laufzeiten daneben sind hardwareabhaengig.\n",
"\n",
"Benoetigt: numpy, ortools\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import math\n",
"import time\n",
"\n",
"import numpy as np\n",
"from ortools.sat.python import cp_model\n",
"\n",
"FENSTER = 20 # so viele Auftraege werden je Runde herausgebrochen\n",
"RUNDEN = 120\n",
"ZUEGE = 400_000 # Zugbudget des Annealings (wie Simulated_Annealing.py)\n",
"N = 500\n",
"\n",
"\n",
"# --- Instanz und Faustregel (wie in Simulated_Annealing.py) ----------------\n",
"\n",
"def erzeuge_ruestmatrix(n: int, saat: int = 11) -> np.ndarray:\n",
" rng = np.random.default_rng(saat)\n",
" familien = max(6, n // 10)\n",
" zwischen = rng.integers(8, 60, (familien, familien))\n",
" np.fill_diagonal(zwischen, 2)\n",
" familie = rng.integers(0, familien, n)\n",
" farbe = rng.integers(0, 10, n)\n",
" matrix = np.zeros((n, n), dtype=np.int64)\n",
" for i in range(n):\n",
" for j in range(n):\n",
" if i != j:\n",
" matrix[i, j] = (zwischen[familie[i], familie[j]]\n",
" + 3 * max(0, farbe[i] - farbe[j]))\n",
" return matrix\n",
"\n",
"\n",
"def gesamtruestzeit(reihe: list[int], matrix: np.ndarray) -> int:\n",
" return int(sum(matrix[reihe[k], reihe[k + 1]] for k in range(len(reihe) - 1)))\n",
"\n",
"\n",
"def faustregel(matrix: np.ndarray) -> list[int]:\n",
" n = len(matrix)\n",
" offen = set(range(1, n))\n",
" reihe = [0]\n",
" while offen:\n",
" naechster = min(offen, key=lambda j: matrix[reihe[-1], j])\n",
" reihe.append(naechster)\n",
" offen.discard(naechster)\n",
" return reihe\n",
"\n",
"\n",
"def delta_verschieben(reihe, matrix, von, nach):\n",
" n = len(reihe)\n",
" if von == nach or nach == von + 1:\n",
" return None\n",
" heraus = 0\n",
" if von > 0:\n",
" heraus += matrix[reihe[von - 1], reihe[von]]\n",
" if von < n - 1:\n",
" heraus += matrix[reihe[von], reihe[von + 1]]\n",
" if 0 < von < n - 1:\n",
" heraus -= matrix[reihe[von - 1], reihe[von + 1]]\n",
" rest = reihe[:von] + reihe[von + 1:]\n",
" stelle = nach if nach < von else nach - 1\n",
" hinein = 0\n",
" if stelle > 0:\n",
" hinein += matrix[rest[stelle - 1], reihe[von]]\n",
" if stelle < len(rest):\n",
" hinein += matrix[reihe[von], rest[stelle]]\n",
" if 0 < stelle < len(rest):\n",
" hinein -= matrix[rest[stelle - 1], rest[stelle]]\n",
" return int(hinein - heraus), rest, stelle\n",
"\n",
"\n",
"def annealing(matrix: np.ndarray, zuege: int = ZUEGE,\n",
" start_temperatur: float = 1.0, saat: int = 1) -> list[int]:\n",
" rng = np.random.default_rng(saat)\n",
" n = len(matrix)\n",
" reihe = faustregel(matrix)\n",
" kosten = gesamtruestzeit(reihe, matrix)\n",
" beste, beste_kosten = reihe[:], kosten\n",
" for zug in range(zuege):\n",
" temperatur = start_temperatur * (0.05 / start_temperatur) ** (zug / zuege)\n",
" von, nach = int(rng.integers(0, n)), int(rng.integers(0, n + 1))\n",
" ergebnis = delta_verschieben(reihe, matrix, von, nach)\n",
" if ergebnis is None:\n",
" continue\n",
" aenderung, rest, stelle = ergebnis\n",
" if aenderung <= 0 or rng.random() < math.exp(-aenderung / temperatur):\n",
" reihe = rest[:stelle] + [reihe[von]] + rest[stelle:]\n",
" kosten += aenderung\n",
" if kosten < beste_kosten:\n",
" beste, beste_kosten = reihe[:], kosten\n",
" return beste\n",
"\n",
"\n",
"# --- Der Reparaturschritt: ein kleines Problem, exakt geloest --------------\n",
"\n",
"def repariere_fenster(reihe: list[int], matrix: np.ndarray,\n",
" a: int, b: int) -> list[int] | None:\n",
" \"\"\"Ordnet reihe[a:b] optimal neu; die beiden Raender bleiben, wo sie sind.\n",
"\n",
" Die Raender festzuhalten ist entscheidend. Ohne sie waere das Teilproblem\n",
" ein anderes als der Ausschnitt aus dem Gesamtplan: Der Uebergang vom\n",
" Vorgaenger in das Fenster und aus dem Fenster in den Nachfolger gehoert\n",
" zu den Kosten dazu. Genau dafuer steht der Hilfsknoten k - er vertritt\n",
" beide Raender in einem.\n",
" \"\"\"\n",
" innen = reihe[a:b]\n",
" k = len(innen)\n",
" vorgaenger = reihe[a - 1] if a > 0 else None\n",
" nachfolger_rand = reihe[b] if b < len(reihe) else None\n",
"\n",
" modell = cp_model.CpModel()\n",
" kanten, ziel = [], []\n",
" for i in range(k + 1):\n",
" for j in range(k + 1):\n",
" if i == j:\n",
" continue\n",
" aktiv = modell.NewBoolVar(f\"kante_{i}_{j}\")\n",
" kanten.append((i, j, aktiv))\n",
" if i < k and j < k:\n",
" ziel.append(int(matrix[innen[i], innen[j]]) * aktiv)\n",
" elif i == k and j < k and vorgaenger is not None:\n",
" ziel.append(int(matrix[vorgaenger, innen[j]]) * aktiv)\n",
" elif j == k and i < k and nachfolger_rand is not None:\n",
" ziel.append(int(matrix[innen[i], nachfolger_rand]) * aktiv)\n",
" modell.AddCircuit(kanten)\n",
" modell.Minimize(sum(ziel))\n",
"\n",
" loeser = cp_model.CpSolver()\n",
" loeser.parameters.num_workers = 1\n",
" loeser.parameters.random_seed = 1\n",
" loeser.parameters.max_time_in_seconds = 10.0\n",
" status = loeser.Solve(modell)\n",
" if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):\n",
" # Auch das gehoert dazu: Wenn das Teilproblem nicht loest, bleibt der\n",
" # Plan, wie er war. Eine LNS-Runde darf scheitern.\n",
" return None\n",
"\n",
" nachfolger = {i: j for i, j, aktiv in kanten if loeser.Value(aktiv)}\n",
" neue_folge, aktuell = [], nachfolger[k]\n",
" while aktuell != k:\n",
" neue_folge.append(innen[aktuell])\n",
" aktuell = nachfolger[aktuell]\n",
" return reihe[:a] + neue_folge + reihe[b:]\n",
"\n",
"\n",
"def lns(reihe: list[int], matrix: np.ndarray, runden: int = RUNDEN,\n",
" fenster: int = FENSTER, saat: int = 3):\n",
" \"\"\"Zerstoeren, exakt reparieren, uebernehmen - so lange das Budget reicht.\"\"\"\n",
" rng = np.random.default_rng(saat)\n",
" kosten = gesamtruestzeit(reihe, matrix)\n",
" verbesserungen = 0\n",
" start = time.perf_counter()\n",
" for _ in range(runden):\n",
" a = int(rng.integers(0, len(reihe) - fenster))\n",
" neu = repariere_fenster(reihe, matrix, a, a + fenster)\n",
" if neu is None:\n",
" continue\n",
" neue_kosten = gesamtruestzeit(neu, matrix)\n",
" if neue_kosten < kosten:\n",
" reihe, kosten = neu, neue_kosten\n",
" verbesserungen += 1\n",
" return reihe, kosten, verbesserungen, time.perf_counter() - start\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" matrix = erzeuge_ruestmatrix(N)\n",
" start_reihe = faustregel(matrix)\n",
" start_kosten = gesamtruestzeit(start_reihe, matrix)\n",
" SCHRANKE = 1768 # aus dem CP-SAT-Lauf in Metaheuristik_vs_Exakt.py\n",
"\n",
" print(\"=\" * 84)\n",
" print(\" ZERSTOEREN UND EXAKT REPARIEREN\")\n",
" print(\"=\" * 84)\n",
" print(f\"{N} Auftraege. Je Runde werden {FENSTER} aufeinanderfolgende Auftraege\")\n",
" print(f\"herausgebrochen und exakt neu geordnet, {RUNDEN} Runden lang.\\n\")\n",
"\n",
" print(\"Erst die Metaheuristik allein, dann LNS auf ihrem Ergebnis:\\n\")\n",
" sa_reihe = annealing(matrix)\n",
" sa_kosten = gesamtruestzeit(sa_reihe, matrix)\n",
"\n",
" lns_ab_faustregel = lns(start_reihe[:], matrix)\n",
" lns_ab_annealing = lns(sa_reihe[:], matrix)\n",
"\n",
" print(f\" {'Verfahren':<34} {'Ruestzeit':>10} {'ueber Schranke':>15}\")\n",
" print(\" \" + \"-\" * 62)\n",
" for name, wert in [\n",
" (\"Faustregel (ohne Solver)\", start_kosten),\n",
" (\"CP-SAT allein, 30 s\", 2628),\n",
" (\"Simulated Annealing\", sa_kosten),\n",
" (\"LNS ab Faustregel\", lns_ab_faustregel[1]),\n",
" (\"Annealing, dann LNS\", lns_ab_annealing[1])]:\n",
" print(f\" {name:<34} {wert:>10,} {(wert - SCHRANKE) / SCHRANKE * 100:>13.1f} %\")\n",
" print(f\" {'untere Schranke (CP-SAT)':<34} {SCHRANKE:>10,} {0.0:>13.1f} %\")\n",
"\n",
" print(f\"\\n LNS ab Faustregel: {lns_ab_faustregel[2]} von {RUNDEN} Runden brachten \"\n",
" f\"eine Verbesserung ({lns_ab_faustregel[3]:.0f} s).\")\n",
" print(f\" LNS ab Annealing: {lns_ab_annealing[2]} von {RUNDEN} Runden \"\n",
" f\"({lns_ab_annealing[3]:.0f} s).\")\n",
"\n",
" # --- Die Fenstergroesse ----------------------------------------------\n",
" # Verglichen wird bei ANNAEHERND GLEICHER ZEIT, nicht bei gleicher\n",
" # Rundenzahl: Eine Runde mit Fenster 30 kostet ein Vielfaches einer Runde\n",
" # mit Fenster 10. Wer nach Runden vergleicht, misst nur, dass groessere\n",
" # Teilprobleme mehr finden - und uebersieht, was sie kosten. Die\n",
" # Rundenzahlen unten sind so gewaehlt, dass alle drei Laeufe in derselben\n",
" # Groessenordnung liegen.\n",
" print(\"\\n\" + \"-\" * 84)\n",
" print(\"Die Fenstergroesse ist die eine Stellschraube - und sie hat ein\")\n",
" print(\"Optimum in der Mitte. Gleiche Zeit, verschiedene Fenster:\\n\")\n",
" print(f\" {'Fenster':>8} {'Runden':>8} {'Ruestzeit':>11} {'Verbesser-':>12} {'Zeit':>8}\")\n",
" print(f\" {'':>8} {'':>8} {'':>11} {'ungen':>12} {'':>8}\")\n",
" print(\" \" + \"-\" * 52)\n",
" fenster_ergebnis = {}\n",
" for fenster, runden in ((10, 4000), (20, 150), (30, 20)):\n",
" _, wert, verbesserungen, dauer = lns(sa_reihe[:], matrix,\n",
" runden=runden, fenster=fenster)\n",
" fenster_ergebnis[fenster] = wert\n",
" print(f\" {fenster:>8} {runden:>8,} {wert:>11,} {verbesserungen:>12} \"\n",
" f\"{dauer:>7.0f}s\")\n",
"\n",
" print(\"\\n Nach unten ist die Grenze nicht die Zeit, sondern die SAETTIGUNG:\")\n",
" print(f\" Mit Fenster 10 bleiben von {4000:,} Runden nur eine Handvoll\")\n",
" print(\" Verbesserungen uebrig. Zwanzig Auftraege lassen sich in einem Zug\")\n",
" print(\" umbauen, zehn nicht - und was ein Fenster nicht umbauen kann, findet\")\n",
" print(\" es auch in beliebig vielen Runden nicht.\")\n",
" print(\"\\n Nach oben ist die Grenze die Rechenzeit: Fenster 30 findet pro Runde\")\n",
" print(\" mehr, kommt in derselben Zeit aber nur auf einen Bruchteil der Runden\")\n",
" print(f\" und landet bei {fenster_ergebnis[30]:,} statt {fenster_ergebnis[20]:,}. Eine Reparatur mit 30 Auftraegen\")\n",
" print(\" ist eben genau das Problem, an dem der exakte Solver im Grossen\")\n",
" print(\" scheitert - nur eine Nummer kleiner.\")\n",
" print(\"\\n Diese Tabelle gehoert an den Anfang jedes LNS-Projekts. Sie zu raten\")\n",
" print(\" statt zu messen ist der haeufigste Fehler beim Einsatz des Verfahrens.\")\n",
"\n",
" print(\"\\n\" + \"=\" * 84)\n",
" print(\" WAS DAS HEISST\")\n",
" print(\"=\" * 84)\n",
" bester = lns_ab_annealing[1]\n",
" print(f\"Der beste Plan kommt aus der Kombination: {bester:,} Minuten,\")\n",
" print(f\"{(start_kosten - bester) / start_kosten * 100:.1f} % unter der Faustregel und \"\n",
" f\"{(sa_kosten - bester) / sa_kosten * 100:.1f} % unter dem reinen Annealing.\")\n",
" print()\n",
" print(\"Weder der exakte Solver noch die Metaheuristik allein kommen dorthin.\")\n",
" print(\"Der Solver scheitert an der Groesse, die Metaheuristik an der Kleinheit\")\n",
" print(\"ihrer Zuege. LNS gibt dem Solver Teilprobleme in einer Groesse, die er\")\n",
" print(\"beherrscht, und der Metaheuristik die Umbauten, die sie nicht kann.\")\n",
" print()\n",
" print(f\"Und die Ehrlichkeit zum Schluss: Auch {bester:,} liegt noch \"\n",
" f\"{(bester - SCHRANKE) / SCHRANKE * 100:.0f} % ueber der\")\n",
" print(\"Schranke. Ob dort wirklich noch so viel Luft ist oder ob die Schranke\")\n",
" print(\"nur schwach ist, weiss man nicht - das ist die Lage, in der man mit\")\n",
" print(\"einer Metaheuristik arbeitet. Man kennt seine Loesung, nicht ihren\")\n",
" print(\"Abstand zum Optimum.\")\n",
" print(\"=\" * 84)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,298 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 4: Vom Management-Wunsch zum Modell\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Derselbe Datensatz, fünf Modelle\n",
"\n",
"`Vom_Wunsch_zum_Modell.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Vom_Wunsch_zum_Modell.py\n",
"\"\"\"\n",
"Kapitel Modellierung: Derselbe Datensatz, fuenf zulaessige Modelle, fuenf andere Plaene.\n",
"\n",
"Eine Lohnfertigung kann nicht alle Anfragen annehmen: 14 Auftraege verlangen\n",
"zusammen 800 Maschinenstunden, verfuegbar sind 300. Es muss also ausgewaehlt\n",
"werden. Die Daten stehen fest - was FEHLT, ist die Frage.\n",
"\n",
"Das Programm zeigt in vier Teilen, was daran haengt:\n",
"\n",
" 1. Drei naheliegende Ziele aus dem Managementgespraech (\"Umsatz\", \"Auslastung\",\n",
" \"Deckungsbeitrag\") - und wie weit die Plaene auseinanderlaufen.\n",
" 2. Der Satz \"Stammkunden duerfen wir nicht verlieren\" in drei Lesarten. Alle\n",
" drei sind vertretbar, alle drei kosten unterschiedlich viel.\n",
" 3. Was ein Strafgewicht daraus macht - und ab wann es dasselbe tut wie eine\n",
" harte Regel.\n",
" 4. Was die harte Lesart ein Quartal spaeter anrichtet.\n",
"\n",
"Der Punkt des Kapitels: Nicht die Daten bestimmen das Modell, sondern die\n",
"Frage. Und die steht selten in der Anforderung.\n",
"\n",
"Benoetigt: numpy, scipy\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import numpy as np\n",
"from scipy.optimize import linprog\n",
"\n",
"# (Nummer, Kunde, Stammkunde?, Maschinenstunden, Umsatz, Materialkosten)\n",
"AUFTRAEGE = [\n",
" (\"A-101\", \"Weber GmbH\", True, 40, 28_000, 19_000),\n",
" (\"A-102\", \"Weber GmbH\", True, 25, 15_000, 9_500),\n",
" (\"A-103\", \"Kranz AG\", True, 60, 33_000, 24_000),\n",
" (\"A-104\", \"Kranz AG\", True, 35, 19_000, 13_500),\n",
" (\"A-105\", \"Obermeier KG\", True, 20, 12_000, 7_000),\n",
" (\"A-106\", \"Siedler & Co\", False, 120, 84_000, 71_000),\n",
" (\"A-107\", \"Siedler & Co\", False, 110, 74_000, 63_000),\n",
" (\"A-108\", \"Novatek\", False, 95, 61_000, 52_000),\n",
" (\"A-109\", \"Novatek\", False, 30, 31_000, 17_000),\n",
" (\"A-110\", \"Hellwig\", False, 15, 17_000, 8_000),\n",
" (\"A-111\", \"Hellwig\", False, 55, 41_000, 30_000),\n",
" (\"A-112\", \"Pfeiffer Werke\", False, 80, 46_000, 39_000),\n",
" (\"A-113\", \"Obermeier KG\", True, 45, 26_000, 17_000),\n",
" (\"A-114\", \"Pfeiffer Werke\", False, 70, 52_000, 36_000),\n",
"]\n",
"KAPAZITAET = 300 # Maschinenstunden im Planungszeitraum\n",
"\n",
"STUNDEN = np.array([a[3] for a in AUFTRAEGE], dtype=float)\n",
"UMSATZ = np.array([a[4] for a in AUFTRAEGE], dtype=float)\n",
"DECKUNGSBEITRAG = np.array([a[4] - a[5] for a in AUFTRAEGE], dtype=float)\n",
"IST_STAMM = np.array([a[2] for a in AUFTRAEGE])\n",
"STAMMKUNDEN = sorted({a[1] for a in AUFTRAEGE if a[2]})\n",
"\n",
"\n",
"def waehle_aus(ziel: np.ndarray, zusatz_ub=None, zusatz_b_ub=None,\n",
" zusatz_eq=None, zusatz_b_eq=None, auftraege=AUFTRAEGE,\n",
" stunden=STUNDEN, kapazitaet=KAPAZITAET):\n",
" \"\"\"Ein Auftrag wird angenommen oder nicht - mehr Modell ist das nicht.\n",
"\n",
" Der ganze Unterschied zwischen den fuenf Varianten unten steckt in 'ziel'\n",
" und in den Zusatzbedingungen. Das Modell selbst bleibt dasselbe.\n",
" \"\"\"\n",
" ergebnis = linprog(\n",
" -ziel,\n",
" A_ub=[stunden] + (zusatz_ub or []),\n",
" b_ub=[kapazitaet] + (zusatz_b_ub or []),\n",
" A_eq=zusatz_eq, b_eq=zusatz_b_eq,\n",
" bounds=(0, 1), integrality=1, method=\"highs\")\n",
" if not ergebnis.success:\n",
" return None\n",
" return np.round(ergebnis.x).astype(int)\n",
"\n",
"\n",
"def kennzahlen(plan, auftraege=AUFTRAEGE, stunden=STUNDEN, umsatz=UMSATZ,\n",
" db=DECKUNGSBEITRAG, ist_stamm=IST_STAMM, kapazitaet=KAPAZITAET):\n",
" \"\"\"Immer dieselben fuenf Zahlen - nur so sind die Varianten vergleichbar.\"\"\"\n",
" abgelehnte_stammauftraege = int(((1 - plan) & ist_stamm.astype(int)).sum())\n",
" leer_ausgegangen = sum(\n",
" 1 for kunde in sorted({a[1] for a in auftraege if a[2]})\n",
" if not any(plan[i] for i, a in enumerate(auftraege) if a[1] == kunde))\n",
" return {\"umsatz\": float(umsatz @ plan), \"db\": float(db @ plan),\n",
" \"auslastung\": float(stunden @ plan) / kapazitaet * 100,\n",
" \"stamm_abgelehnt\": abgelehnte_stammauftraege,\n",
" \"kunden_leer\": leer_ausgegangen, \"angenommen\": int(plan.sum())}\n",
"\n",
"\n",
"def zeile(name: str, plan) -> None:\n",
" if plan is None:\n",
" print(f\" {name:<34} {'UNZULAESSIG - kein Plan moeglich':>44}\")\n",
" return\n",
" k = kennzahlen(plan)\n",
" print(f\" {name:<34} {k['umsatz']:>10,.0f} {k['db']:>9,.0f} \"\n",
" f\"{k['auslastung']:>6.0f}% {k['stamm_abgelehnt']:>7} \"\n",
" f\"{k['kunden_leer']:>6} {k['angenommen']:>7}\")\n",
"\n",
"\n",
"def kopfzeile() -> None:\n",
" print(f\" {'':<34} {'Umsatz':>10} {'DB':>9} {'Aus-':>7} {'Stamm-':>7} \"\n",
" f\"{'Kunden':>6} {'ange-':>7}\")\n",
" print(f\" {'':<34} {'':>10} {'':>9} {'lastung':>7} {'auftr.':>7} \"\n",
" f\"{'ohne':>6} {'nommen':>7}\")\n",
" print(\" \" + \"-\" * 78)\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 84)\n",
" print(\" VOM MANAGEMENT-WUNSCH ZUM MODELL\")\n",
" print(\"=\" * 84)\n",
" print(f\"{len(AUFTRAEGE)} Anfragen verlangen {STUNDEN.sum():.0f} Maschinenstunden, \"\n",
" f\"verfuegbar sind {KAPAZITAET}.\")\n",
" print(f\"Es muss also ausgewaehlt werden. {len(STAMMKUNDEN)} der Kunden sind \"\n",
" f\"Stammkunden:\")\n",
" print(f\" {', '.join(STAMMKUNDEN)}\\n\")\n",
"\n",
" # --- 1. Drei Saetze aus dem Gespraech, drei Ziele ---------------------\n",
" print(\"-\" * 84)\n",
" print(\"1. Was im Gespraech gesagt wurde - drei naheliegende Ziele\\n\")\n",
" kopfzeile()\n",
" plaene = {}\n",
" for name, ziel in [(\"\\\"Umsatz maximieren\\\"\", UMSATZ),\n",
" (\"\\\"Maschinen besser auslasten\\\"\", STUNDEN),\n",
" (\"\\\"Deckungsbeitrag maximieren\\\"\", DECKUNGSBEITRAG)]:\n",
" plaene[name] = waehle_aus(ziel)\n",
" zeile(name, plaene[name])\n",
"\n",
" umsatzplan = kennzahlen(plaene[\"\\\"Umsatz maximieren\\\"\"])\n",
" auslastungsplan = kennzahlen(plaene[\"\\\"Maschinen besser auslasten\\\"\"])\n",
" dbplan = kennzahlen(plaene[\"\\\"Deckungsbeitrag maximieren\\\"\"])\n",
"\n",
" mehr_umsatz = umsatzplan[\"umsatz\"] - dbplan[\"umsatz\"]\n",
" weniger_db = dbplan[\"db\"] - umsatzplan[\"db\"]\n",
" print(f\"\\n Der Umsatzplan bringt {mehr_umsatz:,.0f} EUR mehr Umsatz und \"\n",
" f\"{weniger_db:,.0f} EUR WENIGER\")\n",
" print(f\" Deckungsbeitrag. Fuer {mehr_umsatz:,.0f} EUR Umsatz werden also \"\n",
" f\"{weniger_db:,.0f} EUR Ergebnis\")\n",
" print(f\" aufgegeben - und {umsatzplan['stamm_abgelehnt']} von \"\n",
" f\"{int(IST_STAMM.sum())} Stammkundenauftraegen dazu.\")\n",
" print()\n",
" print(\" \\\"Auslastung\\\" ist der haeufigste Wunsch und der schaedlichste. Der\")\n",
" print(f\" darauf optimierte Plan bringt {dbplan['db'] - auslastungsplan['db']:,.0f} EUR weniger \"\n",
" f\"Deckungsbeitrag und\")\n",
" print(f\" {dbplan['umsatz'] - auslastungsplan['umsatz']:,.0f} EUR weniger Umsatz als der \"\n",
" f\"DB-Plan - und erreicht dabei\")\n",
" print(f\" nicht einmal mehr Auslastung: alle drei liegen bei \"\n",
" f\"{dbplan['auslastung']:.0f} %.\")\n",
" print(\" Eine volle Maschine ist eben kein Ziel, sondern ein Nebenprodukt.\")\n",
"\n",
" # --- 2. Ein Satz, drei Lesarten --------------------------------------\n",
" print(\"\\n\" + \"-\" * 84)\n",
" print(\"2. \\\"Stammkunden duerfen wir nicht verlieren\\\" - aber was heisst das?\\n\")\n",
" kopfzeile()\n",
" zeile(\"ohne die Regel (DB maximieren)\", waehle_aus(DECKUNGSBEITRAG))\n",
"\n",
" # Lesart A: jeder Stammkundenauftrag wird angenommen\n",
" alle_stamm = [[1.0 if IST_STAMM[i] else 0.0 for i in range(len(AUFTRAEGE))]]\n",
" lesart_a = waehle_aus(DECKUNGSBEITRAG, zusatz_eq=alle_stamm,\n",
" zusatz_b_eq=[float(IST_STAMM.sum())])\n",
" zeile(\"A: jeden Stammauftrag annehmen\", lesart_a)\n",
"\n",
" # Lesart B: kein Stammkunde geht leer aus (mindestens ein Auftrag je Kunde)\n",
" je_kunde_ub, je_kunde_b = [], []\n",
" for kunde in STAMMKUNDEN:\n",
" je_kunde_ub.append([-1.0 if a[1] == kunde else 0.0 for a in AUFTRAEGE])\n",
" je_kunde_b.append(-1.0)\n",
" lesart_b = waehle_aus(DECKUNGSBEITRAG, zusatz_ub=je_kunde_ub,\n",
" zusatz_b_ub=je_kunde_b)\n",
" zeile(\"B: kein Stammkunde geht leer aus\", lesart_b)\n",
"\n",
" a, b = kennzahlen(lesart_a), kennzahlen(lesart_b)\n",
" ohne = kennzahlen(waehle_aus(DECKUNGSBEITRAG))\n",
" print(\"\\n Beide Lesarten erreichen dasselbe: kein Stammkunde geht leer aus.\")\n",
" print(f\" Lesart A kostet dafuer {ohne['db'] - a['db']:,.0f} EUR Deckungsbeitrag, \"\n",
" f\"Lesart B nur {ohne['db'] - b['db']:,.0f} EUR -\")\n",
" print(f\" ein Unterschied von {b['db'] - a['db']:,.0f} EUR fuer denselben Satz aus derselben\")\n",
" print(\" Besprechung. Wer nicht nachfragt, entscheidet das selbst, ohne es\")\n",
" print(\" zu merken.\")\n",
"\n",
" # --- 3. Die weiche Variante ------------------------------------------\n",
" print(\"\\n\" + \"-\" * 84)\n",
" print(\"3. Dieselbe Regel als Strafkosten je abgelehntem Stammauftrag\\n\")\n",
" kopfzeile()\n",
" for strafe in (3_000, 6_000, 10_000):\n",
" ziel = DECKUNGSBEITRAG + np.where(IST_STAMM, float(strafe), 0.0)\n",
" zeile(f\"C: Strafe {strafe:>6,} EUR je Auftrag\", waehle_aus(ziel))\n",
"\n",
" print(\"\\n Bei 3.000 EUR aendert sich nichts: Die Strafe liegt unter dem, was\")\n",
" print(\" die Auftraege an Deckungsbeitrag verdraengen wuerden. Bei 10.000 EUR\")\n",
" print(\" kommt genau Lesart A heraus. Eine harte Regel ist also nichts\")\n",
" print(\" anderes als eine weiche mit hinreichend grosser Strafe - was ein\")\n",
" print(\" Strafgewicht sonst noch bedeutet, steht im Kapitel CP-SAT.\")\n",
"\n",
" # --- 4. Was die harte Lesart ein Quartal spaeter anrichtet -----------\n",
" print(\"\\n\" + \"-\" * 84)\n",
" print(\"4. Ein Quartal spaeter: Weber fragt einen Grossauftrag an\\n\")\n",
" erweitert = AUFTRAEGE + [(\"A-115\", \"Weber GmbH\", True, 320, 210_000, 150_000)]\n",
" stunden_neu = np.array([a[3] for a in erweitert], dtype=float)\n",
" db_neu = np.array([a[4] - a[5] for a in erweitert], dtype=float)\n",
" stamm_neu = np.array([a[2] for a in erweitert])\n",
"\n",
" print(f\" A-115: Weber GmbH, {erweitert[-1][3]} Maschinenstunden - allein mehr,\")\n",
" print(f\" als der ganze Zeitraum hergibt ({KAPAZITAET} h).\\n\")\n",
"\n",
" alle_stamm_neu = [[1.0 if stamm_neu[i] else 0.0 for i in range(len(erweitert))]]\n",
" hart = waehle_aus(db_neu, zusatz_eq=alle_stamm_neu,\n",
" zusatz_b_eq=[float(stamm_neu.sum())],\n",
" auftraege=erweitert, stunden=stunden_neu)\n",
" weich = waehle_aus(db_neu + np.where(stamm_neu, 10_000.0, 0.0),\n",
" auftraege=erweitert, stunden=stunden_neu)\n",
"\n",
" print(f\" Lesart A (hart): \"\n",
" f\"{'UNZULAESSIG - der Nachtlauf bricht ab' if hart is None else 'loesbar'}\")\n",
" if weich is not None:\n",
" k = kennzahlen(weich, erweitert, stunden_neu,\n",
" np.array([a[4] for a in erweitert], dtype=float),\n",
" db_neu, stamm_neu)\n",
" print(f\" Lesart C (weich): loesbar - {k['db']:,.0f} EUR Deckungsbeitrag, \"\n",
" f\"{k['stamm_abgelehnt']} Stammauftrag abgelehnt\")\n",
"\n",
" print(\"\\n\" + \"=\" * 84)\n",
" print(\" WORAUF ES ANKOMMT\")\n",
" print(\"=\" * 84)\n",
" print(\"Die Daten waren in allen vier Teilen dieselben. Was sich geaendert hat,\")\n",
" print(\"war nur die Frage - und mit ihr der Plan, der Deckungsbeitrag und die\")\n",
" print(\"Kundenbeziehung.\")\n",
" print()\n",
" print(\"Drei Dinge, die deshalb vor dem ersten Modell geklaert gehoeren:\")\n",
" print()\n",
" print(\" 1. WORAN wird der Erfolg gemessen? Nicht 'was ist Ihnen wichtig',\")\n",
" print(\" sondern: Welche Zahl steht am Jahresende im Bericht? Umsatz,\")\n",
" print(\" Auslastung und Deckungsbeitrag fuehren hier zu drei\")\n",
" print(\" verschiedenen Plaenen.\")\n",
" print(\" 2. WAS GENAU heisst der Satz? 'Stammkunden nicht verlieren' hat\")\n",
" print(f\" mindestens drei Lesarten mit {b['db'] - a['db']:,.0f} EUR Unterschied. Die Frage\")\n",
" print(\" 'meinen Sie jeden Auftrag oder jeden Kunden?' dauert zehn\")\n",
" print(\" Sekunden.\")\n",
" print(\" 3. WAS PASSIERT, WENN ES NICHT GEHT? Eine harte Regel, die niemand\")\n",
" print(\" hinterfragt hat, macht das System eines Tages unloesbar - und\")\n",
" print(\" zwar genau dann, wenn ein besonders grosser Auftrag hereinkommt.\")\n",
" print(\" Weiche Regeln antworten auch dann noch.\")\n",
" print(\"=\" * 84)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -0,0 +1,709 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 3: Das Python-Ökosystem für OR — Solver, Bindings und Modellierungsschichten\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Die Entscheidung in drei Fragen\n",
"\n",
"`Solver_Wahl.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Solver_Wahl.py\n",
"\"\"\"\n",
"Kapitel Oekosystem: Entscheidungshilfe zur Solverwahl.\n",
"Beantwortet drei Fragen und empfiehlt Bibliothek + Backend.\n",
"\"\"\"\n",
"\n",
"from dataclasses import dataclass\n",
"\n",
"\n",
"@dataclass\n",
"class Problem:\n",
" diskrete_entscheidungen: bool # Ja/Nein-Variablen, Zuordnungen, Reihenfolgen?\n",
" zielfunktion: str # \"linear\" | \"quadratisch\" | \"konvex\" | \"beliebig\"\n",
" routing: bool = False # Fahrzeuge, Depots, Zeitfenster?\n",
" groesse: str = \"klein\" # \"klein\" (<10^3 Var.) | \"gross\"\n",
" wiederholte_laeufe: bool = False\n",
"\n",
"\n",
"def empfehle(p: Problem) -> tuple[str, str, str]:\n",
" \"\"\"Gibt (Bibliothek, Backend, Begründung) zurück.\"\"\"\n",
" if p.routing:\n",
" return (\"ortools.constraint_solver (Routing)\", \"Routing Engine\",\n",
" \"Spezialisierte Heuristiken für VRP/TSP schlagen jedes selbstgebaute Modell.\")\n",
"\n",
" if p.diskrete_entscheidungen:\n",
" if p.zielfunktion in (\"linear\",) and not p.routing:\n",
" if p.groesse == \"gross\":\n",
" return (\"highspy\", \"HiGHS Branch-and-Cut\",\n",
" \"MILP mit ökonomischer Struktur (Fixkosten, Kardinalität).\")\n",
" return (\"ortools.sat (CP-SAT)\", \"CP-SAT\",\n",
" \"Logische Regeln und Zuweisungen: CP-SAT propagiert sehr effizient.\")\n",
" return (\"ortools.sat (CP-SAT)\", \"CP-SAT\",\n",
" \"Diskrete Struktur dominiert; CP-SAT verarbeitet auch nichtlineare Logik.\")\n",
"\n",
" if p.zielfunktion == \"linear\":\n",
" if p.wiederholte_laeufe or p.groesse == \"gross\":\n",
" return (\"highspy\", \"HiGHS Dual Simplex\",\n",
" \"Modell einmal aufbauen, Parameter ändern, wiederholt lösen.\")\n",
" return (\"scipy.optimize.linprog\", \"HiGHS\",\n",
" \"Kleinstes Setup, keine zusätzliche Abhängigkeit.\")\n",
"\n",
" if p.zielfunktion in (\"quadratisch\", \"konvex\"):\n",
" return (\"cvxpy\", \"Clarabel / OSQP\",\n",
" \"Konvexität wird automatisch geprüft; Portfolio-Standard.\")\n",
"\n",
" return (\"scipy.optimize.minimize\", \"SLSQP / trust-constr\",\n",
" \"Nicht konvex: nur lokales Optimum, Startpunkt variieren und vergleichen!\")\n",
"\n",
"\n",
"BEISPIELE = {\n",
" \"Vertretungsplan Schule\": Problem(True, \"linear\", groesse=\"klein\"),\n",
" \"Produktionsplanung (LP)\": Problem(False, \"linear\", groesse=\"klein\"),\n",
" \"Produktionsplanung, 50k Var.\": Problem(False, \"linear\", groesse=\"gross\"),\n",
" \"Portfolio Markowitz\": Problem(False, \"quadratisch\"),\n",
" \"Portfolio mit max. 5 Titeln\": Problem(True, \"quadratisch\"),\n",
" \"Liefertouren mit Zeitfenstern\": Problem(True, \"linear\", routing=True),\n",
" \"Entropie-Allokation (NLP)\": Problem(False, \"beliebig\"),\n",
" \"Backtest, 60x neu optimieren\": Problem(False, \"konvex\", wiederholte_laeufe=True),\n",
"}\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 92)\n",
" print(\" SOLVER-EMPFEHLUNG\")\n",
" print(\"=\" * 92)\n",
" for name, p in BEISPIELE.items():\n",
" lib, backend, grund = empfehle(p)\n",
" print(f\"\\n{name}\")\n",
" print(f\" -> Bibliothek: {lib}\")\n",
" print(f\" Backend: {backend}\")\n",
" print(f\" Grund: {grund}\")\n",
" print(\"\\n\" + \"=\" * 92)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Ein System — vier Programmieransätze\n",
"\n",
"`Ein_System_Vier_Ansaetze.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Ein_System_Vier_Ansaetze.py\n",
"\"\"\"\n",
"Kapitel Oekosystem: Dasselbe LP in vier Bibliotheken.\n",
" max 10*x1 + 15*x2 + 25*x3\n",
" u.d.N. x1 + x2 + 2*x3 <= 40\n",
" 2*x1 + 3*x2 + x3 <= 50\n",
" x >= 0\n",
"\n",
"Deckt scipy.optimize, highspy, CVXPY und OR-Tools/GLOP ab, mit Kreuzvergleich am Ende.\n",
"\n",
"WICHTIG: Jeder Solver läuft in einem EIGENEN Prozess, weil sich ortools und\n",
"highspy auf vielen Systemen nicht gemeinsam importieren lassen (beide bringen\n",
"eine eigene HiGHS-Kopie mit -> Symbolkonflikt).\n",
"\"\"\"\n",
"\n",
"import json\n",
"import subprocess\n",
"import sys\n",
"import textwrap\n",
"import time\n",
"\n",
"ERWARTET = 530.0 # Ergebnis der Handrechnung zum Produktionsprogramm\n",
"\n",
"# Jeder Eintrag ist ein eigenständiges Miniprogramm, das sein Ergebnis als\n",
"# JSON auf stdout ausgibt. So bleibt jeder Import in seinem eigenen Prozess.\n",
"ANSAETZE: dict[str, str] = {\n",
"\n",
" \"scipy.optimize.linprog\": \"\"\"\n",
" from scipy.optimize import linprog\n",
" res = linprog(c=[-10.0, -15.0, -25.0], # linprog MINIMIERT -> negieren\n",
" A_ub=[[1, 1, 2], [2, 3, 1]], b_ub=[40, 50],\n",
" bounds=[(0, None)] * 3, method=\"highs\")\n",
" ausgabe = (-res.fun, list(res.x))\n",
" \"\"\",\n",
"\n",
" \"highspy (natives HiGHS)\": \"\"\"\n",
" import numpy as np, highspy\n",
" h = highspy.Highs()\n",
" h.setOptionValue(\"output_flag\", False)\n",
" h.addVars(3, np.zeros(3), np.full(3, highspy.kHighsInf))\n",
" h.changeObjectiveSense(highspy.ObjSense.kMaximize)\n",
" for j, wert in enumerate([10.0, 15.0, 25.0]):\n",
" h.changeColCost(j, wert)\n",
" # CSR-Format: starts[i] = Beginn von Zeile i in indices/values\n",
" h.addRows(2, np.full(2, -highspy.kHighsInf), np.array([40.0, 50.0]), 6,\n",
" np.array([0, 3], dtype=np.int32),\n",
" np.array([0, 1, 2, 0, 1, 2], dtype=np.int32),\n",
" np.array([1.0, 1.0, 2.0, 2.0, 3.0, 1.0]))\n",
" h.run()\n",
" ausgabe = (h.getInfo().objective_function_value,\n",
" list(h.getSolution().col_value[:3]))\n",
" \"\"\",\n",
"\n",
" \"cvxpy\": \"\"\"\n",
" import numpy as np, cvxpy as cp\n",
" x = cp.Variable(3, nonneg=True)\n",
" problem = cp.Problem(cp.Maximize(np.array([10.0, 15.0, 25.0]) @ x),\n",
" [np.array([[1, 1, 2], [2, 3, 1]]) @ x <= np.array([40, 50])])\n",
" problem.solve()\n",
" ausgabe = (float(problem.value), [float(v) for v in x.value])\n",
" \"\"\",\n",
"\n",
" \"ortools / GLOP\": \"\"\"\n",
" from ortools.linear_solver import pywraplp\n",
" s = pywraplp.Solver.CreateSolver(\"GLOP\")\n",
" x = [s.NumVar(0, s.infinity(), f\"x{j+1}\") for j in range(3)]\n",
" A = [[1, 1, 2], [2, 3, 1]]\n",
" for i, kap in enumerate([40, 50]):\n",
" s.Add(sum(A[i][j] * x[j] for j in range(3)) <= kap)\n",
" s.Maximize(10 * x[0] + 15 * x[1] + 25 * x[2])\n",
" s.Solve()\n",
" ausgabe = (s.Objective().Value(), [v.solution_value() for v in x])\n",
" \"\"\",\n",
"}\n",
"\n",
"\n",
"def fuehre_in_eigenem_prozess_aus(quelltext: str) -> tuple[float, list[float]]:\n",
" \"\"\"Startet den Codeschnipsel als separaten Python-Prozess und liest das Ergebnis.\"\"\"\n",
" programm = textwrap.dedent(quelltext) + \"\\nimport json; print(json.dumps(ausgabe))\\n\"\n",
" ergebnis = subprocess.run([sys.executable, \"-c\", programm],\n",
" capture_output=True, text=True, timeout=120)\n",
" if ergebnis.returncode != 0:\n",
" raise RuntimeError(ergebnis.stderr.strip().splitlines()[-1])\n",
" wert, loesung = json.loads(ergebnis.stdout.strip().splitlines()[-1])\n",
" return wert, loesung\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 78)\n",
" print(\" EIN SYSTEM - VIER ANSAETZE (je eigener Prozess)\")\n",
" print(\"=\" * 78)\n",
" print(f\"{'Bibliothek':<26} {'Z*':>10} {'x1':>7} {'x2':>7} {'x3':>7} {'Zeit':>10}\")\n",
" print(\"-\" * 78)\n",
"\n",
" werte = []\n",
" for name, quelltext in ANSAETZE.items():\n",
" t0 = time.perf_counter()\n",
" try:\n",
" wert, x = fuehre_in_eigenem_prozess_aus(quelltext)\n",
" except RuntimeError as fehler:\n",
" print(f\"{name:<26} nicht verfuegbar: {fehler[:40]}\")\n",
" continue\n",
" dauer = time.perf_counter() - t0\n",
" werte.append(wert)\n",
" print(f\"{name:<26} {wert:>10.2f} {x[0]:>7.2f} {x[1]:>7.2f} {x[2]:>7.2f} \"\n",
" f\"{dauer:>8.2f} s\")\n",
"\n",
" print(\"-\" * 78)\n",
" spanne = max(werte) - min(werte)\n",
" print(f\"Spannweite zwischen den Bibliotheken: {spanne:.2e}\")\n",
" print(f\"Abweichung zur Handrechnung ({ERWARTET:.0f}): \"\n",
" f\"{abs(werte[0] - ERWARTET):.2e}\")\n",
" assert spanne < 1e-6, \"Die Bibliotheken widersprechen sich!\"\n",
" assert abs(werte[0] - ERWARTET) < 1e-6, \"Ergebnis weicht von der Handrechnung ab!\"\n",
" print(\"Alle Wege fuehren zum selben, von Hand bestaetigten Optimum.\")\n",
" print(\"(Die Zeiten enthalten den Prozessstart und den Import - sie messen\")\n",
" print(\" NICHT die reine Solverleistung, siehe Uebung 3.5.)\")\n",
" print(\"=\" * 78)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Die Schichten im Überblick\n",
"\n",
"`Modellierungsschichten.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Modellierungsschichten.py\n",
"\"\"\"\n",
"Kapitel Oekosystem: Pyomo und Linopy - zwei Modellierungsschichten fuer grosse Modelle.\n",
"\n",
"Geloest wird dasselbe Produktionsproblem wie im Vierfach-Vergleich:\n",
"\n",
" max 10*x1 + 15*x2 + 25*x3\n",
" u.d.N. x1 + x2 + 2*x3 <= 40\n",
" 2*x1 + 3*x2 + x3 <= 50\n",
" x >= 0\n",
"\n",
"Handrechnung: Z* = 530 bei x = (0, 12, 14).\n",
"\n",
"Der Vergleich zeigt die beiden Denkweisen:\n",
" * Pyomo - algebraisch, indexbasiert, Industriestandard fuer Grossmodelle,\n",
" trennt Modellstruktur sauber von den Daten (AbstractModel).\n",
" * Linopy - beschriftete Arrays (xarray): eine Zeile Code erzeugt Tausende\n",
" Nebenbedingungen auf einmal, ohne Python-Schleife.\n",
"\n",
"Beide bringen KEINEN eigenen Solver mit; hier rechnet in beiden Faellen HiGHS.\n",
"\n",
"WICHTIG: ortools wird in diesem Programm bewusst NICHT importiert - es\n",
"vertraegt sich nicht mit der HiGHS-Kopie, die Pyomo und Linopy laden\n",
"(siehe die Stolperfalle im Abschnitt 'Ein System - vier Programmieransaetze').\n",
"\n",
"Benoetigt: pyomo, linopy, xarray, pandas, highspy, numpy\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import time\n",
"\n",
"import numpy as np\n",
"import pandas as pd\n",
"import pyomo.environ as pyo\n",
"import xarray as xr\n",
"import linopy\n",
"\n",
"ERWARTET = 530.0 # Ergebnis der Handrechnung\n",
"\n",
"PRODUKTE = [\"Standard\", \"Komfort\", \"Premium\"]\n",
"RESSOURCEN = [\"Material\", \"Montage\"]\n",
"\n",
"DECKUNGSBEITRAG = np.array([10.0, 15.0, 25.0])\n",
"VERBRAUCH = np.array([[1.0, 1.0, 2.0], # Material je Produkt\n",
" [2.0, 3.0, 1.0]]) # Montage je Produkt\n",
"VORRAT = np.array([40.0, 50.0])\n",
"\n",
"\n",
"# --- Pyomo: algebraisch und indexbasiert ------------------------------------\n",
"\n",
"def loese_mit_pyomo() -> tuple[float, list[float], float]:\n",
" \"\"\"Pyomo denkt in Mengen und Indizes, wie ein Mathematiker es aufschreibt.\n",
"\n",
" `Constraint(RESSOURCEN, rule=...)` erzeugt fuer JEDES Element der Menge\n",
" eine Nebenbedingung - das ist das 'fuer alle i' der Formelsprache,\n",
" unmittelbar in Code uebersetzt.\n",
" \"\"\"\n",
" t0 = time.perf_counter()\n",
"\n",
" modell = pyo.ConcreteModel(name=\"Produktionsprogramm\")\n",
" modell.P = pyo.Set(initialize=PRODUKTE)\n",
" modell.R = pyo.Set(initialize=RESSOURCEN)\n",
"\n",
" modell.db = pyo.Param(modell.P, initialize=dict(zip(PRODUKTE, DECKUNGSBEITRAG)))\n",
" modell.a = pyo.Param(modell.R, modell.P, initialize={\n",
" (r, p): VERBRAUCH[i, j]\n",
" for i, r in enumerate(RESSOURCEN) for j, p in enumerate(PRODUKTE)})\n",
" modell.vorrat = pyo.Param(modell.R, initialize=dict(zip(RESSOURCEN, VORRAT)))\n",
"\n",
" modell.x = pyo.Var(modell.P, domain=pyo.NonNegativeReals)\n",
"\n",
" modell.ziel = pyo.Objective(\n",
" expr=sum(modell.db[p] * modell.x[p] for p in modell.P),\n",
" sense=pyo.maximize)\n",
"\n",
" def kapazitaet(m, r):\n",
" return sum(m.a[r, p] * m.x[p] for p in m.P) <= m.vorrat[r]\n",
"\n",
" modell.kapazitaet = pyo.Constraint(modell.R, rule=kapazitaet)\n",
"\n",
" ergebnis = pyo.SolverFactory(\"appsi_highs\").solve(modell)\n",
" dauer = time.perf_counter() - t0\n",
"\n",
" status = ergebnis.solver.termination_condition\n",
" if status != pyo.TerminationCondition.optimal:\n",
" raise RuntimeError(f\"Pyomo meldet Status: {status}\")\n",
"\n",
" return (float(pyo.value(modell.ziel)),\n",
" [float(pyo.value(modell.x[p])) for p in PRODUKTE],\n",
" dauer)\n",
"\n",
"\n",
"# --- Linopy: beschriftete Arrays --------------------------------------------\n",
"\n",
"def loese_mit_linopy() -> tuple[float, list[float], float]:\n",
" \"\"\"Linopy denkt in beschrifteten Arrays (xarray).\n",
"\n",
" Der entscheidende Unterschied: `(verbrauch * x).sum(\"produkt\") <= vorrat`\n",
" ist EINE Zeile und erzeugt so viele Nebenbedingungen, wie die Dimension\n",
" 'ressource' Eintraege hat. Bei 2 Ressourcen faellt das nicht auf, bei\n",
" 200 000 schon - dort entstehen sie als Matrixoperation statt in einer\n",
" Python-Schleife.\n",
" \"\"\"\n",
" t0 = time.perf_counter()\n",
"\n",
" # Benannte Indizes statt blosser Listen: Dadurch heissen die Achsen\n",
" # 'produkt' und 'ressource', und xarray fuehrt sie beim Rechnen von allein\n",
" # richtig zusammen. Ohne Namen vergibt linopy 'dim_0', und man muss\n",
" # spaeter umbenennen - eine haeufige Stolperstelle.\n",
" produkt = pd.Index(PRODUKTE, name=\"produkt\")\n",
" ressource = pd.Index(RESSOURCEN, name=\"ressource\")\n",
"\n",
" modell = linopy.Model()\n",
" modell.add_variables(lower=0, coords=[produkt], name=\"menge\")\n",
" x = modell.variables[\"menge\"]\n",
"\n",
" db = xr.DataArray(DECKUNGSBEITRAG, coords=[produkt])\n",
" verbrauch = xr.DataArray(VERBRAUCH, coords=[ressource, produkt])\n",
" vorrat = xr.DataArray(VORRAT, coords=[ressource])\n",
"\n",
" # EINE Zeile - sie erzeugt so viele Nebenbedingungen, wie die Achse\n",
" # 'ressource' Eintraege hat. Genau das ist der Punkt.\n",
" modell.add_constraints((verbrauch * x).sum(\"produkt\") <= vorrat,\n",
" name=\"kapazitaet\")\n",
" modell.add_objective((db * x).sum(), sense=\"max\")\n",
"\n",
" modell.solve(solver_name=\"highs\", output_flag=False)\n",
" dauer = time.perf_counter() - t0\n",
"\n",
" if modell.termination_condition != \"optimal\":\n",
" raise RuntimeError(f\"Linopy meldet Status: {modell.termination_condition}\")\n",
"\n",
" loesung = modell.variables[\"menge\"].solution.to_series()\n",
" return (float(modell.objective.value),\n",
" [float(loesung[p]) for p in PRODUKTE],\n",
" dauer)\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 80)\n",
" print(\" MODELLIERUNGSSCHICHTEN FUER GROSSE MODELLE\")\n",
" print(\"=\" * 80)\n",
" print(f\"{'Schicht':<14} {'Z*':>10} {'Standard':>10} {'Komfort':>10} \"\n",
" f\"{'Premium':>10} {'Zeit':>10}\")\n",
" print(\"-\" * 80)\n",
"\n",
" ergebnisse = []\n",
" for name, loeser in [(\"Pyomo\", loese_mit_pyomo), (\"Linopy\", loese_mit_linopy)]:\n",
" ziel, mengen, dauer = loeser()\n",
" ergebnisse.append(ziel)\n",
" print(f\"{name:<14} {ziel:>10.2f} {mengen[0]:>10.2f} {mengen[1]:>10.2f} \"\n",
" f\"{mengen[2]:>10.2f} {dauer:>8.2f} s\")\n",
"\n",
" print(\"-\" * 80)\n",
" for ziel in ergebnisse:\n",
" assert abs(ziel - ERWARTET) < 1e-6, \\\n",
" f\"Abweichung von der Handrechnung: {ziel} statt {ERWARTET}\"\n",
" print(f\"Beide stimmen mit der Handrechnung ueberein (Z* = {ERWARTET:.0f}).\")\n",
" print()\n",
" print(\"Wann welche Schicht?\")\n",
" print(\" Pyomo -> wenn Modellstruktur und Daten getrennt bleiben sollen,\")\n",
" print(\" wenn nichtlineare Terme oder MINLP dazukommen koennen,\")\n",
" print(\" wenn spaeter ein kommerzieller Solver angebunden wird.\")\n",
" print(\" Linopy -> wenn die Daten ohnehin als beschriftete Arrays vorliegen\")\n",
" print(\" (Energiesystem-, Netz- und Zeitreihenmodelle) und das\")\n",
" print(\" Modell zehntausende gleichartige Nebenbedingungen hat.\")\n",
" print(\"=\" * 80)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Vier Stufen an einem Transportproblem\n",
"\n",
"`Vektorisierte_Modellgenerierung.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Vektorisierte_Modellgenerierung.py\n",
"\"\"\"\n",
"Kapitel Oekosystem: Warum der Solver oft gar nicht der Engpass ist.\n",
"\n",
"In realen Projekten geht ein grosser Teil der Rechenzeit nicht ins Loesen,\n",
"sondern ins AUFBAUEN des Modells. Dieses Programm misst das an einem\n",
"Transportproblem wachsender Groesse in vier Stufen:\n",
"\n",
" A Modellierungsschicht, Nebenbedingung fuer Nebenbedingung (OR-Tools)\n",
" B Matrix direkt, aber mit Python-Schleifen ueber die Eintraege (COO)\n",
" C Matrix vektorisiert ueber Kronecker-Produkte (NumPy/SciPy)\n",
" D Daten kommen als lange Tabelle, aufbereitet mit Polars\n",
"\n",
"Alle Varianten loesen dasselbe Problem und muessen denselben Zielwert\n",
"liefern - das wird am Ende geprueft.\n",
"\n",
"Benoetigt: numpy, scipy, ortools; Variante D zusaetzlich polars (optional).\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import importlib.util\n",
"import time\n",
"\n",
"import numpy as np\n",
"import scipy.sparse as sp\n",
"from ortools.linear_solver import pywraplp\n",
"from scipy.optimize import linprog\n",
"\n",
"HAT_POLARS = importlib.util.find_spec(\"polars\") is not None\n",
"\n",
"\n",
"def erzeuge_daten(m: int, n: int, saat: int = 3\n",
" ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:\n",
" \"\"\"Transportproblem: m Werke, n Kunden.\n",
"\n",
" Liefert (kosten[m, n], angebot[m], bedarf[n]). Das Gesamtangebot liegt\n",
" 20 % ueber dem Gesamtbedarf, damit das Modell sicher loesbar ist.\n",
" \"\"\"\n",
" rng = np.random.default_rng(saat)\n",
" kosten = rng.uniform(1.0, 20.0, size=(m, n))\n",
" bedarf = rng.uniform(10.0, 50.0, size=n)\n",
" angebot = np.full(m, 1.2 * bedarf.sum() / m)\n",
" return kosten, angebot, bedarf\n",
"\n",
"\n",
"# --- Variante A: Modellierungsschicht, Bedingung fuer Bedingung -------------\n",
"\n",
"def loese_mit_modellierungsschicht(kosten: np.ndarray, angebot: np.ndarray,\n",
" bedarf: np.ndarray) -> tuple[float, float, float]:\n",
" \"\"\"So schreibt man ein Transportproblem zuerst hin - gut lesbar, nah an\n",
" der mathematischen Formulierung, jede Nebenbedingung ein eigener Aufruf.\n",
"\n",
" Jedes `s.Add(sum(...))` baut in Python einen Ausdrucksbaum aus m bzw. n\n",
" Termen auf und uebergibt ihn einzeln an die C++-Schicht. Das ist der\n",
" Preis der Bequemlichkeit - und er waechst linear mit der Modellgroesse.\n",
" \"\"\"\n",
" m, n = kosten.shape\n",
"\n",
" t0 = time.perf_counter()\n",
" s = pywraplp.Solver.CreateSolver(\"GLOP\")\n",
" x = [[s.NumVar(0, s.infinity(), f\"x_{i}_{j}\") for j in range(n)]\n",
" for i in range(m)]\n",
" for i in range(m):\n",
" s.Add(sum(x[i][j] for j in range(n)) <= angebot[i])\n",
" for j in range(n):\n",
" s.Add(sum(x[i][j] for i in range(m)) >= bedarf[j])\n",
" s.Minimize(sum(kosten[i][j] * x[i][j] for i in range(m) for j in range(n)))\n",
" t_aufbau = time.perf_counter() - t0\n",
"\n",
" t0 = time.perf_counter()\n",
" status = s.Solve()\n",
" t_loesen = time.perf_counter() - t0\n",
" if status != pywraplp.Solver.OPTIMAL:\n",
" raise RuntimeError(f\"Solver-Status: {status}\")\n",
" return t_aufbau, t_loesen, s.Objective().Value()\n",
"\n",
"\n",
"# --- Varianten B bis D: Matrix selbst bauen, dann SciPy/HiGHS ---------------\n",
"\n",
"def baue_mit_schleifen(kosten: np.ndarray, angebot: np.ndarray,\n",
" bedarf: np.ndarray):\n",
" \"\"\"Die Nebenbedingungsmatrix als COO-Tripel (Zeile, Spalte, Wert), erzeugt\n",
" in verschachtelten Python-Schleifen.\n",
"\n",
" Schon deutlich naeher am Blech als Variante A - es entsteht kein\n",
" Ausdrucksbaum mehr. Die Schleife selbst bleibt aber Python.\n",
" \"\"\"\n",
" m, n = kosten.shape\n",
" zeilen: list[int] = []\n",
" spalten: list[int] = []\n",
" werte: list[float] = []\n",
" rechte_seite: list[float] = []\n",
"\n",
" for i in range(m): # Angebot je Werk\n",
" for j in range(n):\n",
" zeilen.append(i)\n",
" spalten.append(i * n + j)\n",
" werte.append(1.0)\n",
" rechte_seite.append(float(angebot[i]))\n",
"\n",
" for j in range(n): # Bedarf je Kunde, als -x <= -bedarf\n",
" for i in range(m):\n",
" zeilen.append(m + j)\n",
" spalten.append(i * n + j)\n",
" werte.append(-1.0)\n",
" rechte_seite.append(-float(bedarf[j]))\n",
"\n",
" A_ub = sp.csr_matrix((werte, (zeilen, spalten)), shape=(m + n, m * n))\n",
" return A_ub, np.array(rechte_seite), kosten.ravel()\n",
"\n",
"\n",
"def baue_vektorisiert(kosten: np.ndarray, angebot: np.ndarray,\n",
" bedarf: np.ndarray):\n",
" \"\"\"Dieselbe Matrix ohne eine einzige Schleife - ueber Kronecker-Produkte.\n",
"\n",
" Die Angebotsmatrix ist kron(I_m, 1_n^T): je Werk eine Zeile mit Einsen\n",
" an genau den n Spalten dieses Werks. Die Bedarfsmatrix ist\n",
" kron(1_m^T, I_n). Beide entstehen in je einem Aufruf und sind sofort\n",
" duennbesetzt.\n",
" \"\"\"\n",
" m, n = kosten.shape\n",
" angebots_matrix = sp.kron(sp.identity(m, format=\"csr\"), np.ones((1, n)))\n",
" bedarfs_matrix = sp.kron(np.ones((1, m)), sp.identity(n, format=\"csr\"))\n",
"\n",
" A_ub = sp.vstack([angebots_matrix, -bedarfs_matrix], format=\"csr\")\n",
" b_ub = np.concatenate([angebot, -bedarf])\n",
" return A_ub, b_ub, kosten.ravel()\n",
"\n",
"\n",
"def baue_mit_polars(kosten: np.ndarray, angebot: np.ndarray, bedarf: np.ndarray):\n",
" \"\"\"Der realistische Fall: Die Kosten kommen als LANGE Tabelle\n",
" (werk, kunde, kosten) aus Datenbank, Data Lake oder CSV-Datei.\n",
"\n",
" Polars berechnet den Spaltenindex jeder Variablen in einem einzigen\n",
" Spaltenausdruck - ohne Python-Schleife ueber die Zeilen. Genau so baut man\n",
" Modelle aus Millionen Tabellenzeilen.\n",
" \"\"\"\n",
" import polars as pl\n",
"\n",
" m, n = kosten.shape\n",
" tabelle = pl.DataFrame({\n",
" \"werk\": np.repeat(np.arange(m), n),\n",
" \"kunde\": np.tile(np.arange(n), m),\n",
" \"kosten\": kosten.ravel(),\n",
" }).with_columns(\n",
" (pl.col(\"werk\") * n + pl.col(\"kunde\")).alias(\"var_index\")\n",
" )\n",
"\n",
" var_index = tabelle[\"var_index\"].to_numpy()\n",
" werk = tabelle[\"werk\"].to_numpy()\n",
" kunde = tabelle[\"kunde\"].to_numpy()\n",
" eins = np.ones(var_index.size)\n",
"\n",
" angebots_matrix = sp.csr_matrix((eins, (werk, var_index)), shape=(m, m * n))\n",
" bedarfs_matrix = sp.csr_matrix((eins, (kunde, var_index)), shape=(n, m * n))\n",
"\n",
" A_ub = sp.vstack([angebots_matrix, -bedarfs_matrix], format=\"csr\")\n",
" b_ub = np.concatenate([angebot, -bedarf])\n",
" return A_ub, b_ub, tabelle[\"kosten\"].to_numpy()\n",
"\n",
"\n",
"def messe_matrixvariante(bauer, kosten, angebot, bedarf\n",
" ) -> tuple[float, float, float]:\n",
" \"\"\"Liefert (Aufbauzeit, Loesezeit, Zielwert) fuer die Varianten B bis D.\"\"\"\n",
" t0 = time.perf_counter()\n",
" A_ub, b_ub, c = bauer(kosten, angebot, bedarf)\n",
" t_aufbau = time.perf_counter() - t0\n",
"\n",
" t0 = time.perf_counter()\n",
" ergebnis = linprog(c, A_ub=A_ub, b_ub=b_ub, bounds=(0, None), method=\"highs\")\n",
" t_loesen = time.perf_counter() - t0\n",
"\n",
" if not ergebnis.success:\n",
" raise RuntimeError(f\"Solver-Status: {ergebnis.message}\")\n",
" return t_aufbau, t_loesen, float(ergebnis.fun)\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 88)\n",
" print(\" MODELLAUFBAU: WO DIE ZEIT WIRKLICH HINGEHT\")\n",
" print(\"=\" * 88)\n",
" print(\"Transportproblem mit m Werken und n Kunden -> m*n Variablen.\\n\")\n",
"\n",
" varianten: list[tuple[str, object]] = [\n",
" (\"A: OR-Tools, Add() je NB\", None), # Sonderfall, eigener Messpfad\n",
" (\"B: COO in Schleifen\", baue_mit_schleifen),\n",
" (\"C: NumPy vektorisiert\", baue_vektorisiert),\n",
" ]\n",
" if HAT_POLARS:\n",
" varianten.append((\"D: Polars-Tabelle\", baue_mit_polars))\n",
" else:\n",
" print(\"Hinweis: polars nicht installiert - Variante D wird uebersprungen.\\n\")\n",
"\n",
" # Aufwaermlauf: Der erste Aufruf bezahlt Importe und einmalige\n",
" # Initialisierungen. Wer den mitmisst, vergleicht Startkosten statt\n",
" # Rechenarbeit - ein klassischer Benchmark-Fehler.\n",
" aufwaerm = erzeuge_daten(10, 10)\n",
" loese_mit_modellierungsschicht(*aufwaerm)\n",
" for _, bauer in varianten[1:]:\n",
" bauer(*aufwaerm)\n",
"\n",
" print(f\"{'Groesse':<26} {'Variante':<26} {'Aufbau':>9} {'Loesen':>9} \"\n",
" f\"{'Aufbauanteil':>13}\")\n",
" print(\"-\" * 88)\n",
"\n",
" for m, n in [(40, 40), (120, 120), (250, 250)]:\n",
" kosten, angebot, bedarf = erzeuge_daten(m, n)\n",
" zielwerte = []\n",
" for nummer, (name, bauer) in enumerate(varianten):\n",
" if bauer is None:\n",
" t_aufbau, t_loesen, ziel = loese_mit_modellierungsschicht(\n",
" kosten, angebot, bedarf)\n",
" else:\n",
" t_aufbau, t_loesen, ziel = messe_matrixvariante(\n",
" bauer, kosten, angebot, bedarf)\n",
" zielwerte.append(ziel)\n",
" anteil = 100.0 * t_aufbau / (t_aufbau + t_loesen)\n",
" groesse = f\"{m}x{n} = {m*n:,} Variablen\" if nummer == 0 else \"\"\n",
" print(f\"{groesse:<26} {name:<26} {t_aufbau:>8.3f}s {t_loesen:>8.3f}s \"\n",
" f\"{anteil:>12.0f} %\")\n",
"\n",
" # Alle Varianten muessen dasselbe Problem beschreiben.\n",
" spanne = max(zielwerte) - min(zielwerte)\n",
" assert spanne < 1e-6 * max(abs(z) for z in zielwerte), \\\n",
" f\"Varianten widersprechen sich: {zielwerte}\"\n",
" print(f\"{'':<26} {'-> Zielwert (alle gleich)':<26} {zielwerte[0]:>9.2f}\"\n",
" f\" Spanne {spanne:.1e}\")\n",
" print(\"-\" * 88)\n",
"\n",
" print(\"\\nZwei Lehren aus der Tabelle:\")\n",
" print(\"1. Bei Variante A geht mehr Zeit in den AUFBAU als ins Loesen. Wer hier\")\n",
" print(\" einen schnelleren Solver kauft, beschleunigt den kleineren Teil.\")\n",
" print(\"2. Zwischen B und C liegt keine andere Mathematik, nur eine andere\")\n",
" print(\" Schreibweise derselben Matrix - Schleife gegen Kronecker-Produkt.\")\n",
" print(\"\\nDie Lesbarkeit von Variante A ist trotzdem viel wert: Fangen Sie dort an,\")\n",
" print(\"und vektorisieren Sie erst, wenn die Messung es verlangt.\")\n",
" print(\"=\" * 88)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,285 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 15: Predict-then-Optimize — die bessere Prognose, die schlechtere Entscheidung\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Das Programm\n",
"\n",
"`Predict_then_Optimize.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Predict_then_Optimize.py\n",
"\"\"\"\n",
"Kapitel Prognose: Die bessere Prognose trifft die schlechtere Entscheidung.\n",
"\n",
"Eine Baeckerei muss jeden Abend entscheiden, wie viel sie fuer den naechsten Tag\n",
"ansetzt. Zu wenig kostet die Marge des entgangenen Verkaufs, zu viel kostet den\n",
"Einkaufspreis der Retoure. Das ist das Newsvendor-Problem aus dem Kapitel\n",
"Unsicherheit - nur dass die Nachfrage diesmal nicht aus einer Verteilung kommt,\n",
"sondern PROGNOSTIZIERT werden muss: aus Wochentag, Temperatur und Aktionstagen.\n",
"\n",
"Damit zerfaellt die Aufgabe in zwei Schritte, und genau an der Naht entsteht der\n",
"Fehler, um den es hier geht:\n",
"\n",
" PREDICT ein Modell schaetzt die Nachfrage\n",
" OPTIMIZE daraus wird eine Bestellmenge\n",
"\n",
"Der Prognostiker optimiert seinen Modellfehler, meist den MSE. Der Planer traegt\n",
"die Kosten. Beide messen etwas anderes - und die beiden Masse widersprechen\n",
"einander. Das Programm zeigt:\n",
"\n",
" 1. Vier Verfahren, verglichen nach MSE UND nach Entscheidungskosten. Das\n",
" Verfahren mit dem BESTEN MSE hat die HOECHSTEN Kosten.\n",
" 2. Warum ein pauschaler Sicherheitszuschlag zu kurz greift - die Streuung der\n",
" Nachfrage haengt selbst von den Merkmalen ab.\n",
" 3. Eine Messfalle, in die der Autor dieses Programms zuerst selbst getappt\n",
" ist: Bei kurzen Testzeitraeumen ist der MSE-Vergleich nicht stabil.\n",
"\n",
"Benoetigt: numpy, scipy, scikit-learn\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import numpy as np\n",
"from scipy.stats import norm\n",
"from sklearn.linear_model import LinearRegression, QuantileRegressor\n",
"\n",
"VERKAUFSPREIS = 9.0\n",
"EINKAUFSPREIS = 3.0\n",
"KOSTEN_FEHLMENGE = VERKAUFSPREIS - EINKAUFSPREIS # entgangene Marge: 6 EUR\n",
"KOSTEN_UEBERHANG = EINKAUFSPREIS # Retoure: 3 EUR\n",
"KRITISCHES_VERHAELTNIS = KOSTEN_FEHLMENGE / (KOSTEN_FEHLMENGE + KOSTEN_UEBERHANG)\n",
"\n",
"TAGE = 5000 # Simulation, siehe Hinweis unten\n",
"TRAINING = 1000\n",
"SAAT = 11\n",
"\n",
"\n",
"def erzeuge_daten(tage: int = TAGE, saat: int = SAAT):\n",
" \"\"\"Taegliche Nachfrage mit Wochentag, Temperatur und Aktionstagen.\n",
"\n",
" Die entscheidende Eigenschaft steckt in 'streuung': An Aktionstagen ist die\n",
" Nachfrage nicht nur hoeher, sondern auch viel UNSICHERER. Solche\n",
" heteroskedastischen Daten sind der Normalfall - und der Grund, warum ein\n",
" pauschaler Sicherheitszuschlag nicht genuegt.\n",
" \"\"\"\n",
" rng = np.random.default_rng(saat)\n",
" wochentag = np.arange(tage) % 7\n",
" temperatur = (12 + 10 * np.sin(2 * np.pi * np.arange(tage) / 365)\n",
" + rng.normal(0, 3, tage))\n",
" aktion = (rng.random(tage) < 0.15).astype(float)\n",
"\n",
" merkmale = np.column_stack([np.eye(7)[wochentag][:, 1:], temperatur, aktion])\n",
" erwartung = (120\n",
" + np.eye(7)[wochentag] @ np.array([0, 10, 12, 14, 18, 35, -40])\n",
" + 1.8 * temperatur + 45 * aktion)\n",
" streuung = 8 + 22 * aktion\n",
" nachfrage = np.maximum(0.0, erwartung + rng.normal(0, 1, tage) * streuung)\n",
" return merkmale, nachfrage, aktion\n",
"\n",
"\n",
"def tageskosten(bestellung: np.ndarray, nachfrage: np.ndarray) -> float:\n",
" \"\"\"Die Zahl, auf die es ankommt - und die kein Prognosemass kennt.\"\"\"\n",
" fehlmenge = np.maximum(0.0, nachfrage - bestellung)\n",
" ueberhang = np.maximum(0.0, bestellung - nachfrage)\n",
" return float((KOSTEN_FEHLMENGE * fehlmenge\n",
" + KOSTEN_UEBERHANG * ueberhang).mean())\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" merkmale, nachfrage, aktion = erzeuge_daten()\n",
" lernen = slice(0, TRAINING)\n",
" pruefen = slice(TRAINING, TAGE)\n",
"\n",
" print(\"=\" * 84)\n",
" print(\" DIE BESSERE PROGNOSE TRIFFT DIE SCHLECHTERE ENTSCHEIDUNG\")\n",
" print(\"=\" * 84)\n",
" print(f\"Verkaufspreis {VERKAUFSPREIS:.0f} EUR, Einkauf {EINKAUFSPREIS:.0f} EUR.\")\n",
" print(f\"Fehlmenge kostet {KOSTEN_FEHLMENGE:.0f} EUR, Ueberhang \"\n",
" f\"{KOSTEN_UEBERHANG:.0f} EUR je Stueck.\")\n",
" print(f\"Kritisches Verhaeltnis: {KRITISCHES_VERHAELTNIS:.3f} - der Planer sollte \"\n",
" f\"also das\")\n",
" print(f\"{KRITISCHES_VERHAELTNIS:.1%}-Quantil der Nachfrage bestellen, nicht ihren \"\n",
" f\"Erwartungswert.\")\n",
" print(f\"\\nTraining: Tag 1 bis {TRAINING}. Bewertung: die restlichen \"\n",
" f\"{TAGE - TRAINING} Tage.\\n\")\n",
"\n",
" # --- Die vier Verfahren ----------------------------------------------\n",
" kleinste_quadrate = LinearRegression().fit(merkmale[lernen], nachfrage[lernen])\n",
" punktprognose = kleinste_quadrate.predict(merkmale[pruefen])\n",
"\n",
" restfehler = nachfrage[lernen] - kleinste_quadrate.predict(merkmale[lernen])\n",
" pauschalzuschlag = norm.ppf(KRITISCHES_VERHAELTNIS) * restfehler.std()\n",
"\n",
" # Ein Zuschlag, der nicht aus der Normalverteilung kommt, sondern direkt\n",
" # auf den Trainingsdaten die Kosten minimiert.\n",
" kandidaten = np.linspace(-10.0, 30.0, 401)\n",
" trainingsprognose = kleinste_quadrate.predict(merkmale[lernen])\n",
" kostenzuschlag = float(kandidaten[np.argmin(\n",
" [tageskosten(trainingsprognose + z, nachfrage[lernen]) for z in kandidaten])])\n",
"\n",
" # Und das Verfahren, das von vornherein das richtige Quantil schaetzt.\n",
" quantilmodell = QuantileRegressor(quantile=KRITISCHES_VERHAELTNIS,\n",
" alpha=0.0, solver=\"highs\")\n",
" quantilmodell.fit(merkmale[lernen], nachfrage[lernen])\n",
" quantilprognose = quantilmodell.predict(merkmale[pruefen])\n",
"\n",
" verfahren = [\n",
" (\"bestelle die Punktprognose\", punktprognose, punktprognose),\n",
" (\"+ Zuschlag aus der Normalverteilung\",\n",
" punktprognose, punktprognose + pauschalzuschlag),\n",
" (\"+ Zuschlag auf Kosten trainiert\",\n",
" punktprognose, punktprognose + kostenzuschlag),\n",
" (\"Quantilregression aufs kritische Quantil\",\n",
" quantilprognose, quantilprognose),\n",
" ]\n",
"\n",
" print(f\" {'Verfahren':<42} {'MSE':>9} {'Kosten/Tag':>12} {'gegen Zeile 1':>14}\")\n",
" print(\" \" + \"-\" * 80)\n",
" ergebnisse = {}\n",
" for name, prognose, bestellung in verfahren:\n",
" mse = float(((prognose - nachfrage[pruefen]) ** 2).mean())\n",
" kosten = tageskosten(bestellung, nachfrage[pruefen])\n",
" ergebnisse[name] = (mse, kosten)\n",
" basis = ergebnisse[verfahren[0][0]][1]\n",
" vergleich = \"\" if name == verfahren[0][0] else f\"{(kosten - basis) / basis:+13.1%}\"\n",
" print(f\" {name:<42} {mse:>9.1f} {kosten:>10.2f} EUR {vergleich:>14}\")\n",
"\n",
" bester_mse = min(ergebnisse, key=lambda k: ergebnisse[k][0])\n",
" beste_kosten = min(ergebnisse, key=lambda k: ergebnisse[k][1])\n",
" print(f\"\\n bester MSE: {bester_mse}\")\n",
" print(f\" beste Kosten: {beste_kosten}\")\n",
" print(f\"\\n Das Verfahren mit dem besten MSE hat die HOECHSTEN Kosten, und das\")\n",
" print(f\" Verfahren mit den besten Kosten hat einen um \"\n",
" f\"{(ergebnisse[beste_kosten][0] / ergebnisse[bester_mse][0] - 1):.0%} SCHLECHTEREN MSE.\")\n",
" print(\" Wer Prognosemodelle nach MSE auswaehlt, waehlt hier das falsche.\")\n",
"\n",
" # --- Warum der pauschale Zuschlag zu kurz greift ---------------------\n",
" print(\"\\n\" + \"-\" * 84)\n",
" print(\"Warum ein pauschaler Zuschlag nicht genuegt\\n\")\n",
" ist_aktion = aktion[pruefen] > 0.5\n",
" print(f\" {'Verfahren':<42} {'normale Tage':>14} {'Aktionstage':>14}\")\n",
" print(\" \" + \"-\" * 74)\n",
" for name, _, bestellung in verfahren:\n",
" normal = tageskosten(bestellung[~ist_aktion], nachfrage[pruefen][~ist_aktion])\n",
" aktionstag = tageskosten(bestellung[ist_aktion], nachfrage[pruefen][ist_aktion])\n",
" print(f\" {name:<42} {normal:>10.2f} EUR {aktionstag:>10.2f} EUR\")\n",
"\n",
" # Nachgerechnet statt behauptet: Welcher Zuschlag waere je Tagesart richtig?\n",
" aktion_training = aktion[lernen] > 0.5\n",
" z = norm.ppf(KRITISCHES_VERHAELTNIS)\n",
" richtig_normal = z * restfehler[~aktion_training].std()\n",
" richtig_aktion = z * restfehler[aktion_training].std()\n",
" print(f\"\\n Der pauschale Zuschlag betraegt {pauschalzuschlag:.1f} Stueck. Aus den\")\n",
" print(f\" Trainingsresten getrennt nach Tagesart waere richtig:\")\n",
" print(f\" normale Tage : {richtig_normal:5.1f} Stueck\")\n",
" print(f\" Aktionstage : {richtig_aktion:5.1f} Stueck\")\n",
" print(f\" Ein Zuschlag fuer alle Tage kann nur einen Mittelweg treffen - hier\")\n",
" print(f\" ist er an normalen Tagen {pauschalzuschlag / richtig_normal:.1f}-mal zu gross und an\")\n",
" print(f\" Aktionstagen nur {pauschalzuschlag / richtig_aktion:.0%} dessen, was noetig waere.\")\n",
" print(f\"\\n Die Quantilregression schaetzt das {KRITISCHES_VERHAELTNIS:.1%}-Quantil \"\n",
" f\"direkt aus den\")\n",
" print(f\" Merkmalen und darf deshalb an verschiedenen Tagen verschieden weit\")\n",
" print(f\" ueber dem Erwartungswert liegen. Genau das ist der Unterschied\")\n",
" print(f\" zwischen 'ein Modell und danach eine Formel' und 'ein Modell, das\")\n",
" print(f\" weiss, wofuer es gebraucht wird'.\")\n",
"\n",
" # --- Die Messfalle ---------------------------------------------------\n",
" print(\"\\n\" + \"-\" * 84)\n",
" print(\"Eine Messfalle, in die der Autor zuerst selbst getappt ist\\n\")\n",
" print(\" Der erste Entwurf dieses Programms bewertete auf 230 Testtagen - ein\")\n",
" print(\" realistischer Zeitraum. Dort hatte die Quantilregression den BESSEREN\")\n",
" print(\" MSE, und die ganze Aussage des Kapitels stand auf dem Kopf.\")\n",
" print(\"\\n Wie oft das passiert, laesst sich ausmessen:\\n\")\n",
" rng = np.random.default_rng(0)\n",
" print(f\" {'Testfenster':>14} {'QR sieht MSE-besser aus':>26}\")\n",
" print(\" \" + \"-\" * 42)\n",
" for fenster in (180, 365, 730, 2000):\n",
" treffer = 0\n",
" versuche = 400\n",
" for _ in range(versuche):\n",
" start = int(rng.integers(TRAINING, TAGE - fenster))\n",
" ausschnitt = slice(start, start + fenster)\n",
" mse_punkt = ((kleinste_quadrate.predict(merkmale[ausschnitt])\n",
" - nachfrage[ausschnitt]) ** 2).mean()\n",
" mse_quantil = ((quantilmodell.predict(merkmale[ausschnitt])\n",
" - nachfrage[ausschnitt]) ** 2).mean()\n",
" treffer += mse_quantil < mse_punkt\n",
" print(f\" {fenster:>10} Tage {treffer / versuche:>24.1%}\")\n",
"\n",
" print(\"\\n Bei einem halben Jahr Testdaten sieht das schlechtere Modell in gut\")\n",
" print(\" jedem zehnten Fall besser aus. Das ist keine grosse Zahl - aber wer\")\n",
" print(\" EINMAL misst, hat genau eine Ziehung aus dieser Verteilung.\")\n",
" print(\"\\n Die Lehre ist nicht 'nimm 4.000 Testtage' - die hat niemand. Sie\")\n",
" print(\" lautet: Ein Kennzahlenvergleich ohne Angabe seiner Streuung ist keine\")\n",
" print(\" Aussage. Bei kurzen Zeitraeumen gehoert eine Kreuzvalidierung dazu.\")\n",
"\n",
" print(\"\\n\" + \"=\" * 84)\n",
" print(\" WAS MAN DARAUS MITNIMMT\")\n",
" print(\"=\" * 84)\n",
" print(\"Der Prognostiker optimiert den MSE, der Planer traegt die Kosten - und\")\n",
" print(\"die beiden Masse zeigen hier in verschiedene Richtungen. Drei Saetze:\")\n",
" print()\n",
" print(\" 1. Sagen Sie nicht den Erwartungswert vorher, sondern die Groesse, die\")\n",
" print(\" in die Entscheidung eingeht. Beim Newsvendor ist das das kritische\")\n",
" print(\" Quantil - und das kann man direkt schaetzen.\")\n",
" print(\" 2. Bewerten Sie Prognosemodelle an den ENTSCHEIDUNGSKOSTEN. Die sind\")\n",
" print(\" in Euro und damit vergleichbar; ein MSE ist es nicht.\")\n",
" print(\" 3. Ein pauschaler Sicherheitszuschlag ist besser als nichts und\")\n",
" print(\" schlechter als ein Modell, das die Unsicherheit selbst aus den\")\n",
" print(\" Merkmalen liest.\")\n",
" print()\n",
" print(\"Der naechste Schritt - Prognosemodelle so zu trainieren, dass sie die\")\n",
" print(\"Entscheidungskosten direkt minimieren (Smart Predict-then-Optimize,\")\n",
" print(\"differenzierbare Optimierungsschichten) - ist Forschungsstand und\")\n",
" print(\"erfordert Bibliotheken wie cvxpylayers. Die dritte Zeile der Tabelle\")\n",
" print(\"oben ist seine einfachste denkbare Form: ein einziger Parameter, auf\")\n",
" print(\"Kosten statt auf Fehler trainiert.\")\n",
" print(\"=\" * 84)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -0,0 +1,577 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 11: Quadratische und nichtlineare Optimierung — KKT, Lagrange, Konvexität\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Konvexität: die präzise Aussage\n",
"\n",
"`QP_Grundlagen.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# QP_Grundlagen.py\n",
"\"\"\"\n",
"Kapitel QP/NLP: Die drei Faelle aus der Konvexitaets-Tabelle (Abschnitt\n",
"'Das quadratische Programm') an\n",
"einem Mini-QP demonstriert: P positiv definit, P (singulaer) semidefinit,\n",
"P mit negativem Eigenwert.\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"import cvxpy as cp\n",
"\n",
"\n",
"def loese_qp(P, q, name):\n",
" n = len(q)\n",
" w = cp.Variable(n)\n",
" ziel = cp.Minimize(0.5 * cp.quad_form(w, P) + q @ w)\n",
" bedingungen = [cp.sum(w) == 1, w >= 0]\n",
" problem = cp.Problem(ziel, bedingungen)\n",
"\n",
" print(f\"\\n--- {name} ---\")\n",
" eigenwerte = np.linalg.eigvalsh(P)\n",
" print(f\"Eigenwerte von P: {np.round(eigenwerte, 4)}\")\n",
" print(f\"DCP-konvex (CVXPY-Pruefung)? {problem.is_dcp()}\")\n",
"\n",
" if not problem.is_dcp():\n",
" print(\"-> CVXPY lehnt das Problem ab, BEVOR ueberhaupt ein Solver laeuft.\")\n",
" return\n",
"\n",
" problem.solve()\n",
" print(f\"Status: {problem.status}\")\n",
" print(f\"w* = {np.round(w.value, 4)}\")\n",
" print(f\"Zielwert = {problem.value:.6f}\")\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" q = np.zeros(2)\n",
"\n",
" # Fall 1: P positiv definit -> eindeutiges Minimum\n",
" P_definit = np.array([[2.0, 0.5], [0.5, 1.0]])\n",
" loese_qp(P_definit, q, \"P positiv definit\")\n",
"\n",
" # Fall 2: P singulaer/semidefinit (zwei \"identische\" Assets) -> unendlich viele Minima\n",
" P_semidefinit = np.array([[1.0, 1.0], [1.0, 1.0]])\n",
" loese_qp(P_semidefinit, q, \"P positiv semidefinit (singulaer)\")\n",
"\n",
" # Fall 3: P mit negativem Eigenwert -> nicht konvex\n",
" P_indefinit = np.array([[1.0, 2.0], [2.0, 1.0]])\n",
" loese_qp(P_indefinit, q, \"P indefinit (negativer Eigenwert)\")\n",
"\n",
" print(\"\\n--- Nachweis: 'unendlich viele Minima' im semidefiniten Fall ---\")\n",
" for punkt in [np.array([1.0, 0.0]), np.array([0.0, 1.0]), np.array([0.3, 0.7])]:\n",
" wert = 0.5 * punkt @ P_semidefinit @ punkt\n",
" print(f\" w = {punkt} -> Zielwert = {wert:.4f} (identisch, obwohl w verschieden)\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Die vier KKT-Bedingungen\n",
"\n",
"`KKT_Nachweis.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# KKT_Nachweis.py\n",
"\"\"\"\n",
"Kapitel QP/NLP: Die KKT-Bedingungen numerisch nachpruefen.\n",
"\n",
"Loest ein QP mit CVXPY, liest die Dualwerte aus und prueft alle vier\n",
"KKT-Bedingungen einzeln nach. Das ist zugleich eine Vorlage fuer die\n",
"Qualitaetssicherung eigener Modelle.\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"import cvxpy as cp\n",
"\n",
"\n",
"def baue_gueltige_kovarianz(vola, korrelationen, seed=0):\n",
" \"\"\"\n",
" Baut Sigma = D * C * D aus Volatilitaeten und einer Korrelationsmatrix.\n",
" Dieses Vorgehen ist konstruktionsbedingt positiv semidefinit - im\n",
" Gegensatz zum nachtraeglichen Ueberschreiben der Diagonalen, das die\n",
" positive Semidefinitheit zerstoeren kann.\n",
" \"\"\"\n",
" C = np.array(korrelationen, dtype=float)\n",
" assert np.allclose(C, C.T), \"Korrelationsmatrix muss symmetrisch sein.\"\n",
" assert np.allclose(np.diag(C), 1.0), \"Diagonale der Korrelationsmatrix muss 1 sein.\"\n",
" eigen = np.linalg.eigvalsh(C)\n",
" assert eigen.min() > -1e-10, (\n",
" f\"Korrelationsmatrix ist nicht positiv semidefinit \"\n",
" f\"(kleinster Eigenwert {eigen.min():.4f}). Solche Korrelationen sind unmoeglich.\")\n",
" D = np.diag(vola)\n",
" return D @ C @ D\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" # --- Ein kleines Portfolio-QP ----------------------------------------\n",
" vola = np.array([0.20, 0.14, 0.30]) # Volatilitaeten\n",
" korr = [[1.00, 0.30, 0.10],\n",
" [0.30, 1.00, 0.25],\n",
" [0.10, 0.25, 1.00]]\n",
" Sigma = baue_gueltige_kovarianz(vola, korr)\n",
" mu = np.array([0.09, 0.05, 0.13]) # erwartete Renditen\n",
" lam = 3.0 # Risikoaversion\n",
"\n",
" eigenwerte = np.linalg.eigvalsh(Sigma)\n",
" print(\"=\" * 74)\n",
" print(\" KKT-BEDINGUNGEN AM PORTFOLIO-QP\")\n",
" print(\"=\" * 74)\n",
" print(f\"Eigenwerte von Sigma: {np.round(eigenwerte, 6)}\")\n",
" print(f\" -> positiv definit: {bool(eigenwerte.min() > 0)} \"\n",
" f\"(Problem ist streng konvex, Loesung eindeutig)\")\n",
" print(f\" -> Konditionszahl: {eigenwerte.max() / eigenwerte.min():.2f}\")\n",
"\n",
" # --- Modell: min lam/2 * w'Sigma w - mu'w u.d.N. sum(w)=1, w>=0 ----\n",
" n = len(mu)\n",
" w = cp.Variable(n)\n",
" ziel = cp.Minimize(0.5 * lam * cp.quad_form(w, Sigma) - mu @ w)\n",
" budget = cp.sum(w) == 1\n",
" nichtnegativ = w >= 0\n",
" problem = cp.Problem(ziel, [budget, nichtnegativ])\n",
" problem.solve()\n",
"\n",
" w_opt = w.value\n",
" nu = budget.dual_value # Multiplikator der Gleichung\n",
" lam_i = nichtnegativ.dual_value # Multiplikatoren der Ungleichungen\n",
"\n",
" print(f\"\\nStatus: {problem.status}\")\n",
" print(f\"Optimale Gewichte: {np.round(w_opt, 6)}\")\n",
" print(f\"Zielwert: {problem.value:.6f}\")\n",
" print(f\"Multiplikator der Budgetgleichung (nu): {nu:.6f}\")\n",
" print(f\"Multiplikatoren der w>=0-Bedingungen: {np.round(lam_i, 6)}\")\n",
"\n",
" # --- KKT-Bedingungen einzeln pruefen ---------------------------------\n",
" print(\"\\n--- Pruefung der vier KKT-Bedingungen ---\")\n",
"\n",
" # 1. Stationaritaet: grad f - lambda + nu*1 = 0\n",
" # f(w) = lam/2 w'Sigma w - mu'w -> grad f = lam*Sigma w - mu\n",
" # g_i(w) = -w_i <= 0 -> grad g_i = -e_i\n",
" # h(w) = sum(w) - 1 = 0 -> grad h = 1\n",
" grad_f = lam * (Sigma @ w_opt) - mu\n",
" stationaritaet = grad_f - lam_i + nu * np.ones(n)\n",
" print(f\"1. Stationaritaet : max|Residuum| = {np.abs(stationaritaet).max():.2e}\")\n",
"\n",
" # 2. Primale Zulaessigkeit\n",
" print(f\"2. Primal zulaessig : sum(w)-1 = {w_opt.sum()-1:.2e}, \"\n",
" f\"min(w) = {w_opt.min():.2e}\")\n",
"\n",
" # 3. Duale Zulaessigkeit\n",
" print(f\"3. Dual zulaessig : min(lambda) = {lam_i.min():.2e} (muss >= 0 sein)\")\n",
"\n",
" # 4. Komplementaerer Schlupf: lambda_i * w_i = 0\n",
" print(f\"4. Kompl. Schlupf : max|lambda_i * w_i| = \"\n",
" f\"{np.abs(lam_i * w_opt).max():.2e}\")\n",
"\n",
" alle_ok = (np.abs(stationaritaet).max() < 1e-6\n",
" and abs(w_opt.sum() - 1) < 1e-8\n",
" and w_opt.min() > -1e-8\n",
" and lam_i.min() > -1e-8\n",
" and np.abs(lam_i * w_opt).max() < 1e-6)\n",
" print(f\"\\nAlle vier KKT-Bedingungen erfuellt: {alle_ok}\")\n",
"\n",
" # --- Interpretation von nu -------------------------------------------\n",
" print(\"\\n--- Was bedeutet nu? ---\")\n",
" print(\"nu ist der Schattenpreis des Budgets: Um so viel aendert sich der\")\n",
" print(\"Zielwert, wenn man statt 100 % nur 99 % investieren duerfte.\")\n",
" problem2 = cp.Problem(cp.Minimize(0.5 * lam * cp.quad_form(w, Sigma) - mu @ w),\n",
" [cp.sum(w) == 1.01, w >= 0])\n",
" problem2.solve()\n",
" print(f\" Vorhergesagt (nu * 0.01): {nu * 0.01:+.6f}\")\n",
" print(f\" Tatsaechlich gemessen: {problem2.value - problem.value:+.6f}\")\n",
" print(\"=\" * 74)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Praxisfall: Entropie-maximierte Kapitalallokation\n",
"\n",
"`Entropie_Maximierte_Allokation.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Entropie_Maximierte_Allokation.py\n",
"\"\"\"\n",
"Kapitel QP/NLP: Nichtlineare Optimierung (NLP) mit scipy SLSQP.\n",
"Modell: Mean-Variance-Portfolio mit Shannon-Entropie-Diversifikation.\n",
"\n",
"Achtung, haeufiger Fehler: Sigma per A@A.T zu erzeugen und anschliessend die\n",
"Diagonale zu ueberschreiben zerstoert die positive Semidefinitheit - die\n",
"Matrix kann dadurch einen negativen Eigenwert bekommen und unmoegliche\n",
"Korrelationen (> 1) implizieren. Dieses Programm baut Sigma stattdessen als\n",
"D * C * D aus Volatilitaeten und einer echten Korrelationsmatrix -\n",
"konstruktionsbedingt immer PSD.\n",
"\n",
"Zusaetzlich: Multistart, weil SLSQP nur lokale Optima findet.\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"import pandas as pd\n",
"from scipy.optimize import minimize\n",
"\n",
"ASSETS = [\"Tech-Aktien\", \"Rohstoffe\", \"US-Treasuries\", \"Krypto\"]\n",
"MU = np.array([0.14, 0.07, 0.03, 0.22]) # erwartete Jahresrenditen\n",
"VOLA = np.array([0.20, 0.14, 0.07, 0.40]) # Volatilitaeten p.a.\n",
"KORRELATION = np.array([\n",
" [1.00, 0.25, -0.10, 0.55],\n",
" [0.25, 1.00, 0.05, 0.20],\n",
" [-0.10, 0.05, 1.00, -0.15],\n",
" [0.55, 0.20, -0.15, 1.00],\n",
"])\n",
"\n",
"ALPHA = 1.0 # Gewicht des Ertrags\n",
"BETA = 0.015 # Gewicht der Entropie-Diversifikation\n",
"UNTERGRENZE = 0.001\n",
"OBERGRENZE = 0.80\n",
"\n",
"\n",
"def baue_kovarianz() -> np.ndarray:\n",
" \"\"\"Sigma = D * C * D. Immer PSD, wenn C eine gueltige Korrelationsmatrix ist.\"\"\"\n",
" eig_c = np.linalg.eigvalsh(KORRELATION)\n",
" if eig_c.min() < -1e-10:\n",
" raise ValueError(f\"Korrelationsmatrix unmoeglich (Eigenwert {eig_c.min():.4f}).\")\n",
" D = np.diag(VOLA)\n",
" Sigma = D @ KORRELATION @ D\n",
" eig_s = np.linalg.eigvalsh(Sigma)\n",
" assert eig_s.min() > 0, \"Sigma nicht positiv definit!\"\n",
" return Sigma\n",
"\n",
"\n",
"SIGMA = baue_kovarianz()\n",
"\n",
"\n",
"def zielfunktion(w, alpha=ALPHA, beta=BETA):\n",
" \"\"\"Risiko - Ertrag - Entropiepraemie (wird minimiert).\"\"\"\n",
" varianz = 0.5 * w @ SIGMA @ w\n",
" ertrag = MU @ w\n",
" entropie = -np.sum(w * np.log(w + 1e-12))\n",
" return varianz - alpha * ertrag - beta * entropie\n",
"\n",
"\n",
"def gradient(w, alpha=ALPHA, beta=BETA):\n",
" \"\"\"Exakter analytischer Gradient - beschleunigt und stabilisiert SLSQP.\"\"\"\n",
" grad_varianz = SIGMA @ w # d/dw von 0.5 w'Sigma w\n",
" grad_ertrag = -alpha * MU\n",
" grad_entropie = beta * (np.log(w + 1e-12) + 1.0)\n",
" return grad_varianz + grad_ertrag + grad_entropie\n",
"\n",
"\n",
"def optimiere(startpunkt, alpha=ALPHA, beta=BETA):\n",
" \"\"\"alpha und beta werden durchgereicht - so bleibt die Funktion seiteneffektfrei.\"\"\"\n",
" return minimize(\n",
" zielfunktion, startpunkt, args=(alpha, beta), jac=gradient,\n",
" method=\"SLSQP\", bounds=[(UNTERGRENZE, OBERGRENZE)] * len(MU),\n",
" constraints=({\"type\": \"eq\",\n",
" \"fun\": lambda w: np.sum(w) - 1.0,\n",
" \"jac\": lambda w: np.ones(len(MU))}),\n",
" options={\"ftol\": 1e-12, \"maxiter\": 300})\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" n = len(MU)\n",
" eig = np.linalg.eigvalsh(SIGMA)\n",
"\n",
" print(\"=\" * 74)\n",
" print(\" NICHTLINEARE ENTROPIE-OPTIMIERTE ASSET-ALLOKATION\")\n",
" print(\"=\" * 74)\n",
" print(\"Pruefung der Kovarianzmatrix:\")\n",
" print(f\" Eigenwerte: {np.round(eig, 6)}\")\n",
" print(f\" Positiv definit: {bool(eig.min() > 0)}\")\n",
" print(f\" Groesste Korrelation ausserhalb der Diagonale: \"\n",
" f\"{np.abs(KORRELATION - np.eye(n)).max():.2f} (muss <= 1 sein)\")\n",
"\n",
" # --- Multistart: SLSQP findet nur lokale Optima -----------------------\n",
" rng = np.random.default_rng(7)\n",
" startpunkte = [np.ones(n) / n] # Gleichgewichtung\n",
" for _ in range(9):\n",
" z = rng.random(n) + 0.05\n",
" startpunkte.append(z / z.sum())\n",
"\n",
" ergebnisse = [optimiere(s) for s in startpunkte]\n",
" erfolgreich = [r for r in ergebnisse if r.success]\n",
" bestes = min(erfolgreich, key=lambda r: r.fun)\n",
" zielwerte = np.array([r.fun for r in erfolgreich])\n",
"\n",
" print(f\"\\nMultistart mit {len(startpunkte)} Startpunkten:\")\n",
" print(f\" Erfolgreich konvergiert: {len(erfolgreich)}\")\n",
" print(f\" Spannweite der Zielwerte: {zielwerte.max() - zielwerte.min():.2e}\")\n",
" print(\" -> \" + (\"alle Startpunkte fuehren zum selben Optimum (Indiz fuer Konvexitaet)\"\n",
" if zielwerte.max() - zielwerte.min() < 1e-6\n",
" else \"ACHTUNG: verschiedene lokale Optima gefunden!\"))\n",
"\n",
" w_opt = bestes.x\n",
" rendite = MU @ w_opt\n",
" volatilitaet = np.sqrt(w_opt @ SIGMA @ w_opt)\n",
" entropie = -np.sum(w_opt * np.log(w_opt))\n",
"\n",
" print(f\"\\nKonvergenz: {bestes.message}\")\n",
" print(f\"Iterationen: {bestes.nit}\")\n",
" print(f\"Erwartete Jahresrendite: {rendite * 100:6.2f} %\")\n",
" print(f\"Erwartete Volatilitaet: {volatilitaet * 100:6.2f} %\")\n",
" print(f\"Diversifikations-Entropie: {entropie:.4f} \"\n",
" f\"(Maximum bei Gleichgewichtung: {np.log(n):.4f})\")\n",
"\n",
" print(\"\\n\" + pd.DataFrame({\n",
" \"Asset\": ASSETS,\n",
" \"Erw. Rendite\": [f\"{r*100:.1f} %\" for r in MU],\n",
" \"Volatilitaet\": [f\"{v*100:.1f} %\" for v in VOLA],\n",
" \"Gewicht\": [f\"{w*100:6.2f} %\" for w in w_opt],\n",
" }).to_string(index=False))\n",
"\n",
" # --- Vergleich: was passiert ohne Entropieterm? ----------------------\n",
" ohne = optimiere(np.ones(n) / n, beta=0.0)\n",
" print(\"\\n--- Wirkung des Entropieterms ---\")\n",
" print(f\" Mit Entropie (beta={BETA}): Gewichte {np.round(w_opt * 100, 1)}\")\n",
" print(f\" Ohne Entropie (beta=0): Gewichte {np.round(ohne.x * 100, 1)}\")\n",
" print(\" Der Entropieterm zieht Kapital aus der Spitzenposition heraus,\")\n",
" print(\" ohne dass eine harte Obergrenze noetig waere.\")\n",
" print(\"=\" * 74)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Was ein lokales Optimum praktisch bedeutet\n",
"\n",
"`Lokale_Optima_Multistart.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Lokale_Optima_Multistart.py\n",
"\"\"\"\n",
"Kapitel QP/NLP: Was passiert, wenn die Konvexitaet fehlt.\n",
"\n",
"CVXPY verweigert nicht-konvexe Probleme - das ist sein Schutzmechanismus.\n",
"scipy.optimize.minimize verweigert nichts. Es rechnet, meldet 'success: True'\n",
"und liefert ein Ergebnis. Nur ist das Ergebnis dann kein Optimum, sondern\n",
"irgendein lokales Minimum, das vom Startpunkt abhaengt.\n",
"\n",
"Beispiel aus dem Einkauf: 700 Tonnen Rohstoff werden auf vier Lieferanten\n",
"verteilt. Jeder gewaehrt einen MENGENRABATT - der Stueckpreis faellt, je mehr\n",
"man bei ihm bestellt:\n",
"\n",
" preis_i(q) = basis_i * (1 - rabatt_i * (1 - exp(-q / skala_i)))\n",
"\n",
"Genau das macht die Zielfunktion nicht-konvex: Grosse Bestellungen lohnen\n",
"sich ueberproportional, es gibt also mehrere sinnvolle \"Cluster\"-Loesungen -\n",
"und dazwischen schlechtere Taeler.\n",
"\n",
"Das Programm zeigt drei Dinge:\n",
" 1. Ein einzelner Lauf liefert ein plausibles Ergebnis - ohne jede Warnung.\n",
" 2. 200 Startpunkte foerdern mehrere verschiedene lokale Optima zutage.\n",
" 3. Der Unterschied zwischen bestem und schlechtestem betraegt hier 8 %.\n",
"\n",
"Benoetigt: numpy, scipy\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import numpy as np\n",
"from scipy.optimize import minimize\n",
"\n",
"# Vier Lieferanten\n",
"NAMEN = [\"Nord AG\", \"Ost GmbH\", \"Sued KG\", \"West SE\"]\n",
"BASISPREIS = np.array([50.0, 47.0, 53.0, 45.0]) # EUR je Tonne ohne Rabatt\n",
"MAX_RABATT = np.array([0.30, 0.18, 0.35, 0.12]) # hoechstmoeglicher Rabatt\n",
"RABATT_SKALA = np.array([120.0, 260.0, 90.0, 300.0]) # wie schnell er greift\n",
"KAPAZITAET = np.array([400.0, 400.0, 400.0, 400.0])\n",
"BEDARF = 700.0\n",
"\n",
"RNG = np.random.default_rng(0)\n",
"\n",
"\n",
"def gesamtkosten(menge: np.ndarray) -> float:\n",
" \"\"\"Einkaufskosten bei mengenabhaengigem Stueckpreis.\n",
"\n",
" Der Rabatt waechst mit der Bestellmenge und laeuft gegen MAX_RABATT.\n",
" Dadurch ist der Stueckpreis fallend - und die Gesamtkostenfunktion\n",
" nicht mehr konvex.\n",
" \"\"\"\n",
" stueckpreis = BASISPREIS * (1 - MAX_RABATT * (1 - np.exp(-menge / RABATT_SKALA)))\n",
" return float(stueckpreis @ menge)\n",
"\n",
"\n",
"NEBENBEDINGUNGEN = [{\"type\": \"eq\", \"fun\": lambda q: q.sum() - BEDARF}]\n",
"GRENZEN = [(0.0, k) for k in KAPAZITAET]\n",
"\n",
"\n",
"def optimiere_von(startpunkt: np.ndarray):\n",
" \"\"\"Ein Lauf von einem gegebenen Startpunkt aus.\"\"\"\n",
" return minimize(gesamtkosten, startpunkt, method=\"SLSQP\",\n",
" bounds=GRENZEN, constraints=NEBENBEDINGUNGEN)\n",
"\n",
"\n",
"def zufaelliger_start() -> np.ndarray:\n",
" \"\"\"Zufaellige Aufteilung, die den Bedarf bereits erfuellt.\"\"\"\n",
" anteil = RNG.random(len(NAMEN))\n",
" return anteil / anteil.sum() * BEDARF\n",
"\n",
"\n",
"def zeige_plan(titel: str, menge: np.ndarray, kosten: float) -> None:\n",
" print(f\"\\n{titel}\")\n",
" for name, m in zip(NAMEN, menge):\n",
" anteil = m / BEDARF * 100\n",
" print(f\" {name:<10} {m:7.1f} t ({anteil:4.1f} %)\")\n",
" print(f\" {'Gesamtkosten':<10} {kosten:9,.2f} EUR\")\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 78)\n",
" print(\" NICHT-KONVEX: DERSELBE CODE, VERSCHIEDENE ERGEBNISSE\")\n",
" print(\"=\" * 78)\n",
" print(f\"{BEDARF:.0f} t Rohstoff auf {len(NAMEN)} Lieferanten mit Mengenrabatt.\")\n",
"\n",
" # --- 1. Ein einziger Lauf, so wie man es zuerst schreibt --------------\n",
" erster = optimiere_von(np.full(len(NAMEN), BEDARF / len(NAMEN)))\n",
" print(f\"\\n[1] EIN Lauf, Startpunkt 'gleichmaessig verteilt'\")\n",
" print(f\" scipy meldet: success={erster.success}, \"\n",
" f\"'{erster.message}'\")\n",
" zeige_plan(\" Ergebnis:\", erster.x, erster.fun)\n",
" print(\"\\n Nichts an dieser Ausgabe deutet darauf hin, dass etwas fehlt.\")\n",
"\n",
" # --- 1b. Der kaufmaennisch naheliegende Startpunkt --------------------\n",
" # \"Kaufe bei den beiden Lieferanten mit dem guenstigsten Basispreis\" -\n",
" # West SE (45) und Ost GmbH (47), jeweils bis zur Kapazitaetsgrenze.\n",
" guenstigste = np.argsort(BASISPREIS)[:2]\n",
" kaufmaennisch = np.zeros(len(NAMEN))\n",
" rest = BEDARF\n",
" for i in guenstigste:\n",
" kaufmaennisch[i] = min(KAPAZITAET[i], rest)\n",
" rest -= kaufmaennisch[i]\n",
" zweiter = optimiere_von(kaufmaennisch)\n",
" print(f\"\\n[1b] EIN Lauf, Startpunkt 'die zwei mit dem guenstigsten Basispreis'\")\n",
" print(f\" scipy meldet: success={zweiter.success}\")\n",
" zeige_plan(\" Ergebnis:\", zweiter.x, zweiter.fun)\n",
" print(f\"\\n Dasselbe Programm, derselbe Aufruf, ein anderer Startpunkt -\")\n",
" print(f\" und {zweiter.fun - erster.fun:,.2f} EUR Unterschied \"\n",
" f\"({(zweiter.fun / erster.fun - 1) * 100:.1f} %).\")\n",
"\n",
" # --- 2. Multistart: dasselbe Problem, viele Startpunkte ---------------\n",
" laeufe = []\n",
" for _ in range(200):\n",
" ergebnis = optimiere_von(zufaelliger_start())\n",
" if ergebnis.success:\n",
" laeufe.append((float(ergebnis.fun), ergebnis.x))\n",
"\n",
" # Ergebnisse, die sich um weniger als 1 Cent unterscheiden, sind dasselbe\n",
" # lokale Optimum - zusammenfassen, sonst zaehlt man Rundungsrauschen.\n",
" optima: list[tuple[float, np.ndarray]] = []\n",
" for wert, plan in sorted(laeufe, key=lambda t: t[0]):\n",
" if not optima or abs(wert - optima[-1][0]) > 0.01:\n",
" optima.append((wert, plan))\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(f\"[2] 200 zufaellige Startpunkte -> {len(laeufe)} erfolgreiche Laeufe\")\n",
" print(f\" darunter {len(optima)} VERSCHIEDENE lokale Optima:\")\n",
" print()\n",
" print(f\" {'Rang':>5} {'Kosten':>13} {'Abstand zum besten':>20} Aufteilung (t)\")\n",
" print(\" \" + \"-\" * 70)\n",
" bester = optima[0][0]\n",
" for rang, (wert, plan) in enumerate(optima, start=1):\n",
" abstand = (wert / bester - 1) * 100\n",
" aufteilung = \" \".join(f\"{m:5.0f}\" for m in plan)\n",
" print(f\" {rang:>5} {wert:>13,.2f} {abstand:>19.2f} % {aufteilung}\")\n",
"\n",
" # --- 3. Was das kostet ------------------------------------------------\n",
" schlechtester = optima[-1]\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" WAS AUF DEM SPIEL STEHT\")\n",
" print(\"=\" * 78)\n",
" zeige_plan(\"Bester gefundener Plan:\", optima[0][1], optima[0][0])\n",
" zeige_plan(\"Schlechtestes lokales Optimum:\", schlechtester[1], schlechtester[0])\n",
" unterschied = schlechtester[0] - optima[0][0]\n",
" print(f\"\\n Unterschied: {unterschied:,.2f} EUR \"\n",
" f\"({unterschied / optima[0][0] * 100:.1f} %)\")\n",
" print(f\"\\n Lauf [1] (gleichmaessiger Start): {erster.fun:>10,.2f} EUR\")\n",
" print(f\" Lauf [1b] (kaufmaennischer Start): {zweiter.fun:>10,.2f} EUR\"\n",
" f\" <- {(zweiter.fun / optima[0][0] - 1) * 100:.1f} % ueber dem besten\")\n",
" print()\n",
" print(\" Bemerkenswert: Der kaufmaennisch NAHELIEGENDE Startpunkt fuehrt in\")\n",
" print(\" das schlechteste Ergebnis von allen - schlechter als jedes der 200\")\n",
" print(\" zufaellig gefundenen lokalen Optima. Wer beim guenstigsten\")\n",
" print(\" Basispreis anfaengt, uebersieht, dass hier der Mengenrabatt\")\n",
" print(\" entscheidet und nicht der Listenpreis.\")\n",
"\n",
" print()\n",
" print(\"Drei Konsequenzen fuer die Praxis:\")\n",
" print(\" 1. 'success: True' heisst bei nicht-konvexen Problemen NICHT 'optimal'.\")\n",
" print(\" Es heisst nur: 'Ich bin an einer Stelle angekommen, an der es in\")\n",
" print(\" keine Richtung mehr bergab geht.'\")\n",
" print(\" 2. Ein einzelner Lauf ist wertlos. Nehmen Sie viele Startpunkte und\")\n",
" print(\" berichten Sie die STREUUNG mit - sie ist Ihre einzige Auskunft\")\n",
" print(\" darueber, wie zerklueftet die Landschaft ist.\")\n",
" print(\" 3. Auch Multistart liefert KEINE Garantie. Dass hier nichts unter\")\n",
" print(f\" {bester:,.2f} EUR gefunden wurde, beweist nicht, dass es nichts gibt.\")\n",
" print(\"=\" * 78)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -0,0 +1,393 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 17: Supply-Chain und Energieeinsatz unter Unsicherheit\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Das Programm\n",
"\n",
"`Kraftwerkseinsatz.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Kraftwerkseinsatz.py\n",
"\"\"\"\n",
"Kapitel Supply-Chain: Welche Bloecke laufen morgen? Und was, wenn kein Wind weht?\n",
"\n",
"Die Kraftwerkseinsatzplanung (englisch Unit Commitment) ist die Aufgabe, an der\n",
"sich in der Energiewirtschaft alles entscheidet: Fuer jede Stunde des naechsten\n",
"Tages muss feststehen, welche Bloecke am Netz sind. Ein Kernblock braucht acht\n",
"Stunden Mindestlaufzeit und 40.000 Euro Anfahrkosten - wer ihn abschaltet, hat\n",
"ihn fuer den Rest des Tages verloren.\n",
"\n",
"Das Besondere liegt in der Zeitstruktur, und es geht ueber das zweistufige\n",
"Modell aus dem Kapitel Unsicherheit hinaus:\n",
"\n",
" STUFE 1 Das AN/AUS je Block und Stunde. Binaer, und es steht am Vorabend\n",
" fest - bevor irgendjemand weiss, wie viel Wind morgen weht.\n",
" STUFE 2 Die Fahrweise: wie viel jeder laufende Block liefert. Das darf\n",
" sich stundenweise an die Wirklichkeit anpassen.\n",
"\n",
"Die erste Stufe ist also GANZZAHLIG und szenariouebergreifend gleich, die\n",
"zweite kontinuierlich und je Szenario verschieden. Genau diese Kombination\n",
"macht das Problem interessant - und sie ist der Grund, warum ein Plan, der auf\n",
"den Wind-Erwartungswert gerechnet wurde, in der Wirklichkeit teuer wird.\n",
"\n",
"Das Programm zeigt drei Plaene auf denselben Daten:\n",
"\n",
" 1. Deterministisch: gerechnet mit dem Wind-ERWARTUNGSWERT, danach gegen 40\n",
" Szenarien ausgewertet.\n",
" 2. Zweistufig: der Commitment-Plan sieht alle 40 Szenarien.\n",
" 3. Mit Versorgungssicherheit: die Nichtdeckung in den schlechtesten Faellen\n",
" wird begrenzt - und der Preis dafuer in Euro je vermiedener MWh\n",
" ausgewiesen.\n",
"\n",
"Benoetigt: numpy, ortools\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import time\n",
"\n",
"import numpy as np\n",
"from ortools.linear_solver import pywraplp\n",
"\n",
"# (Name, Mindestleistung, Nennleistung, Grenzkosten, Anfahrkosten, Mindestlaufzeit)\n",
"KRAFTWERKE = [\n",
" (\"Kernblock\", 200, 600, 22, 40_000, 8),\n",
" (\"Braunkohle\", 100, 400, 35, 18_000, 6),\n",
" (\"Steinkohle\", 80, 300, 48, 12_000, 4),\n",
" (\"Gas GuD\", 50, 250, 72, 6_000, 2),\n",
" (\"Gasturbine\", 20, 150, 105, 1_500, 1),\n",
"]\n",
"STUNDEN = 24\n",
"SZENARIEN = 40\n",
"FLAUTENANTEIL = 0.15\n",
"ALPHA = 0.90 # die schlechtesten 10 % der Szenarien\n",
"LASTABWURF = 3_000.0 # EUR je nicht gedeckter MWh (\"value of lost load\")\n",
"NIEDRIGER_ABWURFPREIS = 300.0 # zum Vergleich: ein Marktpreisdeckel\n",
"SAAT = 4\n",
"\n",
"_t = np.arange(STUNDEN)\n",
"LAST = 620 + 260 * np.sin((_t - 7) / 24 * 2 * np.pi) + 90 * np.sin((_t - 4) / 12 * 2 * np.pi)\n",
"WIND_ERWARTUNG = np.maximum(0.0, 160 + 130 * np.sin((_t - 14) / 24 * 2 * np.pi))\n",
"\n",
"\n",
"def erzeuge_windszenarien(anzahl: int = SZENARIEN, saat: int = SAAT):\n",
" \"\"\"Windeinspeisung je Szenario und Stunde.\n",
"\n",
" Zwei Zutaten, die zusammen den Unterschied machen: eine breite\n",
" lognormale Streuung des Tagesniveaus - und in 15 % der Faelle eine\n",
" DUNKELFLAUTE, in der praktisch gar kein Wind weht. Solche seltenen,\n",
" extremen Faelle sind der Grund, warum der Erwartungswert als\n",
" Planungsgrundlage nicht genuegt.\n",
" \"\"\"\n",
" rng = np.random.default_rng(saat)\n",
" tagesniveau = rng.lognormal(0, 0.40, (anzahl, 1))\n",
" wind = np.clip(WIND_ERWARTUNG * tagesniveau\n",
" + rng.normal(0, 25, (anzahl, STUNDEN)), 0, 420)\n",
" ist_flaute = rng.random(anzahl) < FLAUTENANTEIL\n",
" wind[ist_flaute] *= 0.05\n",
" return wind, ist_flaute\n",
"\n",
"\n",
"def plane(wind: np.ndarray, gewichte, cvar_grenze: float | None = None,\n",
" abwurfpreis: float = None, zeitlimit: float = 300.0):\n",
" \"\"\"Commitment (Stufe 1) und Fahrweise je Szenario (Stufe 2) in einem Modell.\n",
"\n",
" 'wind' hat die Form (Szenarien, Stunden). Mit einer einzigen Zeile\n",
" Windprognose wird daraus die deterministische Planung.\n",
"\n",
" 'cvar_grenze' begrenzt den CVaR der Nichtdeckung ueber die Szenarien -\n",
" dieselbe Rockafellar-Uryasev-Konstruktion wie im Kapitel CVaR, nur dass\n",
" hier keine Verluste in Euro, sondern Megawattstunden begrenzt werden.\n",
" \"\"\"\n",
" abwurfpreis = LASTABWURF if abwurfpreis is None else abwurfpreis\n",
" anzahl_szenarien = wind.shape[0]\n",
" anzahl_bloecke = len(KRAFTWERKE)\n",
" solver = pywraplp.Solver.CreateSolver(\"SCIP\")\n",
" solver.SetTimeLimit(int(zeitlimit * 1000))\n",
"\n",
" # --- Stufe 1: binaer, szenariouebergreifend gleich --------------------\n",
" laeuft = [[solver.BoolVar(f\"laeuft_{k}_{t}\") for t in range(STUNDEN)]\n",
" for k in range(anzahl_bloecke)]\n",
" faehrt_an = [[solver.BoolVar(f\"start_{k}_{t}\") for t in range(STUNDEN)]\n",
" for k in range(anzahl_bloecke)]\n",
"\n",
" # --- Stufe 2: kontinuierlich, je Szenario -----------------------------\n",
" leistung = [[[solver.NumVar(0, KRAFTWERKE[k][2], f\"p_{j}_{k}_{t}\")\n",
" for t in range(STUNDEN)] for k in range(anzahl_bloecke)]\n",
" for j in range(anzahl_szenarien)]\n",
" nichtdeckung = [[solver.NumVar(0, solver.infinity(), f\"y_{j}_{t}\")\n",
" for t in range(STUNDEN)] for j in range(anzahl_szenarien)]\n",
"\n",
" for k, (_, pmin, pmax, _, _, mindestlaufzeit) in enumerate(KRAFTWERKE):\n",
" for t in range(STUNDEN):\n",
" # Anfahren erkennen: aus im Vortakt, an im aktuellen\n",
" vorher = laeuft[k][t - 1] if t > 0 else 0\n",
" solver.Add(faehrt_an[k][t] >= laeuft[k][t] - vorher)\n",
" # Mindestlaufzeit: wer anfaehrt, laeuft die naechsten Stunden weiter\n",
" for spaeter in range(t, min(STUNDEN, t + mindestlaufzeit)):\n",
" solver.Add(laeuft[k][spaeter] >= faehrt_an[k][t])\n",
" # Ein laufender Block liefert zwischen Mindest- und Nennleistung,\n",
" # ein stehender gar nichts. Das koppelt beide Stufen.\n",
" for j in range(anzahl_szenarien):\n",
" solver.Add(leistung[j][k][t] >= pmin * laeuft[k][t])\n",
" solver.Add(leistung[j][k][t] <= pmax * laeuft[k][t])\n",
"\n",
" for j in range(anzahl_szenarien):\n",
" for t in range(STUNDEN):\n",
" solver.Add(sum(leistung[j][k][t] for k in range(anzahl_bloecke))\n",
" + float(wind[j, t]) + nichtdeckung[j][t] >= LAST[t])\n",
"\n",
" # --- Versorgungssicherheit als CVaR-Schranke --------------------------\n",
" if cvar_grenze is not None:\n",
" schwelle = solver.NumVar(-solver.infinity(), solver.infinity(), \"schwelle\")\n",
" ueberschuss = [solver.NumVar(0, solver.infinity(), f\"u_{j}\")\n",
" for j in range(anzahl_szenarien)]\n",
" for j in range(anzahl_szenarien):\n",
" solver.Add(ueberschuss[j] >= sum(nichtdeckung[j][t]\n",
" for t in range(STUNDEN)) - schwelle)\n",
" solver.Add(schwelle + (1.0 / (anzahl_szenarien * (1 - ALPHA)))\n",
" * sum(ueberschuss) <= cvar_grenze)\n",
"\n",
" anfahrkosten = sum(KRAFTWERKE[k][4] * faehrt_an[k][t]\n",
" for k in range(anzahl_bloecke) for t in range(STUNDEN))\n",
" betriebskosten = sum(\n",
" gewichte[j] * sum(KRAFTWERKE[k][3] * leistung[j][k][t]\n",
" for k in range(anzahl_bloecke) for t in range(STUNDEN))\n",
" for j in range(anzahl_szenarien))\n",
" abwurfkosten = sum(gewichte[j] * abwurfpreis\n",
" * sum(nichtdeckung[j][t] for t in range(STUNDEN))\n",
" for j in range(anzahl_szenarien))\n",
" solver.Minimize(anfahrkosten + betriebskosten + abwurfkosten)\n",
"\n",
" beginn = time.perf_counter()\n",
" status = solver.Solve()\n",
" dauer = time.perf_counter() - beginn\n",
" if status not in (pywraplp.Solver.OPTIMAL, pywraplp.Solver.FEASIBLE):\n",
" return None, dauer, False\n",
" plan = np.array([[laeuft[k][t].solution_value() for t in range(STUNDEN)]\n",
" for k in range(anzahl_bloecke)]).round()\n",
" return plan, dauer, status == pywraplp.Solver.OPTIMAL\n",
"\n",
"\n",
"def bewerte(plan: np.ndarray, wind: np.ndarray, abwurfpreis: float = None):\n",
" \"\"\"Commitment steht fest - nur noch die Fahrweise je Szenario optimieren.\n",
"\n",
" Das ist die ehrliche Bewertung eines Plans: Er wird der Wirklichkeit\n",
" ausgesetzt und darf nur noch das anpassen, was sich am Tag selbst\n",
" anpassen laesst.\n",
" \"\"\"\n",
" abwurfpreis = LASTABWURF if abwurfpreis is None else abwurfpreis\n",
" anzahl_bloecke = len(KRAFTWERKE)\n",
" kosten, fehlmengen = [], []\n",
" for j in range(wind.shape[0]):\n",
" solver = pywraplp.Solver.CreateSolver(\"GLOP\")\n",
" leistung = [[solver.NumVar(0, KRAFTWERKE[k][2], f\"p_{k}_{t}\")\n",
" for t in range(STUNDEN)] for k in range(anzahl_bloecke)]\n",
" fehlt = [solver.NumVar(0, solver.infinity(), f\"y_{t}\")\n",
" for t in range(STUNDEN)]\n",
" for k, (_, pmin, pmax, _, _, _) in enumerate(KRAFTWERKE):\n",
" for t in range(STUNDEN):\n",
" solver.Add(leistung[k][t] >= pmin * plan[k, t])\n",
" solver.Add(leistung[k][t] <= pmax * plan[k, t])\n",
" for t in range(STUNDEN):\n",
" solver.Add(sum(leistung[k][t] for k in range(anzahl_bloecke))\n",
" + float(wind[j, t]) + fehlt[t] >= LAST[t])\n",
" solver.Minimize(\n",
" sum(KRAFTWERKE[k][3] * leistung[k][t]\n",
" for k in range(anzahl_bloecke) for t in range(STUNDEN))\n",
" + sum(abwurfpreis * fehlt[t] for t in range(STUNDEN)))\n",
" solver.Solve()\n",
" kosten.append(solver.Objective().Value())\n",
" fehlmengen.append(sum(f.solution_value() for f in fehlt))\n",
"\n",
" anfahrten = sum(KRAFTWERKE[k][4]\n",
" * max(0.0, plan[k, t] - (plan[k, t - 1] if t > 0 else 0.0))\n",
" for k in range(anzahl_bloecke) for t in range(STUNDEN))\n",
" return np.array(kosten) + anfahrten, np.array(fehlmengen)\n",
"\n",
"\n",
"def cvar(werte: np.ndarray, alpha: float = ALPHA) -> float:\n",
" \"\"\"Mittelwert der schlechtesten (1-alpha) Faelle.\"\"\"\n",
" schwelle = np.quantile(werte, alpha)\n",
" schlechteste = werte[werte >= schwelle]\n",
" return float(schlechteste.mean()) if len(schlechteste) else 0.0\n",
"\n",
"\n",
"def zeige(name: str, plan, wind, abwurfpreis: float = None) -> dict:\n",
" kosten, fehl = bewerte(plan, wind, abwurfpreis)\n",
" kennzahlen = {\"mittel\": kosten.mean(), \"max\": kosten.max(),\n",
" \"fehl_mittel\": fehl.mean(), \"fehl_cvar\": cvar(fehl),\n",
" \"betroffen\": int((fehl > 0.01).sum()),\n",
" \"blockstunden\": int(plan.sum())}\n",
" print(f\" {name:<32} {kennzahlen['mittel']:>10,.0f} {kennzahlen['max']:>11,.0f} \"\n",
" f\"{kennzahlen['fehl_mittel']:>9.1f} {kennzahlen['fehl_cvar']:>10.1f} \"\n",
" f\"{kennzahlen['betroffen']:>6}/{len(fehl)}\")\n",
" return kennzahlen\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" wind, ist_flaute = erzeuge_windszenarien()\n",
"\n",
" print(\"=\" * 88)\n",
" print(\" KRAFTWERKSEINSATZ: DER PLAN STEHT, BEVOR DER WIND WEHT\")\n",
" print(\"=\" * 88)\n",
" print(f\"{len(KRAFTWERKE)} Bloecke, {STUNDEN} Stunden, {SZENARIEN} Windszenarien \"\n",
" f\"(davon {int(ist_flaute.sum())} Dunkelflauten).\")\n",
" print(f\"Last {LAST.min():.0f} bis {LAST.max():.0f} MW, \"\n",
" f\"Wind im Erwartungswert {WIND_ERWARTUNG.min():.0f} bis \"\n",
" f\"{WIND_ERWARTUNG.max():.0f} MW.\")\n",
" print(f\"Nicht gedeckte Last kostet {LASTABWURF:,.0f} EUR je MWh.\\n\")\n",
"\n",
" print(f\" {'Planungsgrundlage':<32} {'Kosten':>10} {'Kosten':>11} \"\n",
" f\"{'Fehlmenge':>9} {'Fehlmenge':>10} {'Szen. mit':>12}\")\n",
" print(f\" {'':<32} {'im Mittel':>10} {'schlimmst.':>11} \"\n",
" f\"{'Mittel':>9} {'CVaR 90%':>10} {'Abwurf':>12}\")\n",
" print(\" \" + \"-\" * 86)\n",
"\n",
" # --- 1. Deterministisch ----------------------------------------------\n",
" plan_det, dauer_det, _ = plane(WIND_ERWARTUNG.reshape(1, -1), [1.0])\n",
" det = zeige(\"nur Wind-Erwartungswert\", plan_det, wind)\n",
"\n",
" # --- 2. Zweistufig, risikoneutral ------------------------------------\n",
" gleich = [1.0 / SZENARIEN] * SZENARIEN\n",
" plan_sto, dauer_sto, optimal_sto = plane(wind, gleich)\n",
" sto = zeige(\"alle 40 Szenarien\", plan_sto, wind)\n",
"\n",
" # --- 3. Mit Versorgungssicherheit ------------------------------------\n",
" plan_sicher, dauer_sicher, optimal_sicher = plane(wind, gleich, cvar_grenze=0.0)\n",
" sicher = zeige(\"+ Nichtdeckung CVaR 90 % = 0\", plan_sicher, wind)\n",
"\n",
" print(f\"\\n Rechenzeiten: {dauer_det:.1f}s / {dauer_sto:.1f}s / {dauer_sicher:.1f}s \"\n",
" f\"(alle beweisbar optimal: {optimal_sto and optimal_sicher})\")\n",
"\n",
" # --- Was die Zeilen bedeuten -----------------------------------------\n",
" print(\"\\n\" + \"-\" * 88)\n",
" print(\"Was der Erwartungswert-Plan anrichtet\\n\")\n",
" print(f\" Er ist auf dem Papier der billigste - gerechnet auf den mittleren\")\n",
" print(f\" Wind kostet er weniger als jeder andere. In der Wirklichkeit liegt\")\n",
" print(f\" er {det['mittel'] / sto['mittel'] - 1:+.0%} ueber dem zweistufigen Plan, und sein \"\n",
" f\"schlimmster Tag\")\n",
" print(f\" kostet {det['max'] / sto['max']:.1f}-mal so viel.\")\n",
" print(f\"\\n Der Grund steht in der letzten Spalte: In {det['betroffen']} von {SZENARIEN} \"\n",
" f\"Szenarien muss\")\n",
" print(f\" Last abgeworfen werden. Was fehlt, sind nicht Kraftwerke, sondern\")\n",
" print(f\" ANGEFAHRENE Kraftwerke - und ein Block mit acht Stunden\")\n",
" print(f\" Mindestlaufzeit laesst sich um 18 Uhr nicht mehr herbeirufen.\")\n",
"\n",
" print(f\"\\n Der Unterschied im Plan ist klein:\")\n",
" for name, plan in [(\"Erwartungswert\", plan_det), (\"zweistufig\", plan_sto),\n",
" (\"mit Sicherheit\", plan_sicher)]:\n",
" laufend = plan.sum(axis=0).astype(int)\n",
" print(f\" {name:<16} {''.join(str(x) for x in laufend)} \"\n",
" f\"({int(plan.sum())} Blockstunden)\")\n",
" print(f\"\\n Der zweistufige Plan haelt {sto['blockstunden'] - det['blockstunden']} \"\n",
" f\"Blockstunden mehr vor - das genuegt,\")\n",
" print(f\" um alle {det['betroffen']} Lastabwuerfe zu vermeiden.\")\n",
"\n",
" # --- Der Preis der Sicherheit ----------------------------------------\n",
" print(\"\\n\" + \"-\" * 88)\n",
" print(\"Was Versorgungssicherheit kostet\\n\")\n",
" print(f\" Bei {LASTABWURF:,.0f} EUR/MWh kostet die CVaR-Schranke NICHTS: Der\")\n",
" print(\" risikoneutrale Plan haelt sie schon ein. Das ist kein Zufall - bei\")\n",
" print(\" diesem Preis lohnt sich Vorhaltung bereits im Erwartungswert.\")\n",
" print(\"\\n Interessant wird die Schranke dort, wo der Schaden ZU NIEDRIG\")\n",
" print(\" bepreist ist. Dieselbe Rechnung mit einem Marktpreisdeckel von\")\n",
" print(f\" {NIEDRIGER_ABWURFPREIS:,.0f} EUR/MWh statt {LASTABWURF:,.0f}:\\n\")\n",
"\n",
" print(f\" {'Planungsgrundlage':<32} {'Kosten':>10} {'Kosten':>11} \"\n",
" f\"{'Fehlmenge':>9} {'Fehlmenge':>10} {'Szen. mit':>12}\")\n",
" print(f\" {'':<32} {'im Mittel':>10} {'schlimmst.':>11} \"\n",
" f\"{'Mittel':>9} {'CVaR 90%':>10} {'Abwurf':>12}\")\n",
" print(\" \" + \"-\" * 86)\n",
" plan_billig, _, _ = plane(wind, gleich, abwurfpreis=NIEDRIGER_ABWURFPREIS)\n",
" billig = zeige(\"risikoneutral, billiger Abwurf\", plan_billig, wind,\n",
" NIEDRIGER_ABWURFPREIS)\n",
" plan_billig_sicher, _, _ = plane(wind, gleich, cvar_grenze=0.0,\n",
" abwurfpreis=NIEDRIGER_ABWURFPREIS)\n",
" billig_sicher = zeige(\"+ CVaR 90 % der Fehlmenge = 0\", plan_billig_sicher, wind,\n",
" NIEDRIGER_ABWURFPREIS)\n",
"\n",
" aufpreis = billig_sicher[\"mittel\"] - billig[\"mittel\"]\n",
" vermieden = billig[\"fehl_cvar\"] - billig_sicher[\"fehl_cvar\"]\n",
" print(f\"\\n Jetzt greift die Schranke: Der risikoneutrale Plan nimmt in\")\n",
" print(f\" {billig['betroffen']} von {SZENARIEN} Szenarien einen Lastabwurf in Kauf, weil er bei\")\n",
" print(f\" {NIEDRIGER_ABWURFPREIS:,.0f} EUR/MWh billiger ist als das Vorhalten eines Blocks.\")\n",
" print(f\"\\n Aufpreis fuer die Sicherheit: {aufpreis:,.0f} EUR je Tag\")\n",
" if vermieden > 0.01:\n",
" print(f\" Vermiedene Fehlmenge (CVaR 90 %): {vermieden:.1f} MWh\")\n",
" print(f\" -> {aufpreis / vermieden:,.0f} EUR je vermiedener MWh\")\n",
" print(f\"\\n Diese Zahl ist die Entscheidungsgrundlage - genau wie die Spalte\")\n",
" print(f\" 'EUR je kg' im Kapitel Mehrziel. Sie sagt der Aufsicht, was ihre\")\n",
" print(f\" Versorgungssicherheitsvorgabe tatsaechlich kostet, statt darueber\")\n",
" print(f\" zu streiten, wie wichtig sie ist.\")\n",
"\n",
" print(\"\\n\" + \"=\" * 88)\n",
" print(\" WAS MAN DARAUS MITNIMMT\")\n",
" print(\"=\" * 88)\n",
" print(\"1. Die erste Stufe ist binaer und traege. Was am Vorabend nicht\")\n",
" print(\" angefahren wurde, steht am naechsten Tag nicht zur Verfuegung -\")\n",
" print(\" egal, wie hoch der Preis dann steigt.\")\n",
" print(\"2. Deshalb ist der Erwartungswert als Planungsgrundlage nicht nur\")\n",
" print(\" ungenau, sondern SYSTEMATISCH zu knapp: Er plant fuer einen Tag,\")\n",
" print(\" der so nie eintritt, und laesst keine Reserve fuer die Haelfte\")\n",
" print(\" der Faelle, in denen es schlechter kommt.\")\n",
" print(\"3. Der Ausweg ist keine bessere Windprognose, sondern ein Modell,\")\n",
" print(\" das die Szenarien SIEHT. Die Rechenzeit dafuer liegt hier im\")\n",
" print(\" Sekundenbereich - der Aufwand ist die Modellierung, nicht der\")\n",
" print(\" Solver.\")\n",
" print(\"4. Wo der Lastabwurf teuer genug bepreist ist, deckt schon die\")\n",
" print(\" Minimierung der ERWARTETEN Kosten die Extremfaelle mit ab. Die\")\n",
" print(\" Risikoschranke wird genau dann gebraucht, wenn der Schaden ZU\")\n",
" print(\" NIEDRIG bepreist ist - was bei Versorgungssicherheit die Regel\")\n",
" print(\" ist, weil ein Marktpreisdeckel nicht den volkswirtschaftlichen\")\n",
" print(\" Schaden abbildet. Dann liefert sie den Preis der Vorgabe in Euro\")\n",
" print(\" je vermiedener MWh, und darueber laesst sich verhandeln.\")\n",
" print(\"=\" * 88)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,469 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 12: Optimierung unter Unsicherheit — Monte-Carlo, Stochastik, Robustheit\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Der Fluch des Durchschnitts\n",
"\n",
"`Fluch_des_Durchschnitts.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Fluch_des_Durchschnitts.py\n",
"\"\"\"\n",
"Kapitel Unsicherheit: Der Fluch des Durchschnitts (die Handrechnung dazu) als Scan ueber alle\n",
"moeglichen Kapazitaeten - zeigt, dass das Optimum nicht beim Mittelwert liegt.\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"\n",
"SZENARIEN = np.array([100, 250, 500])\n",
"WAHRSCHEINLICHKEITEN = np.array([0.5, 0.3, 0.2])\n",
"PREIS_VORAB = 40.0\n",
"PREIS_ZUKAUF = 120.0\n",
"PREIS_VERWALTUNG = 5.0\n",
"\n",
"ERWARTUNGSWERT = float(SZENARIEN @ WAHRSCHEINLICHKEITEN)\n",
"\n",
"\n",
"def erwartete_kosten(x):\n",
" kosten = np.where(\n",
" SZENARIEN >= x,\n",
" PREIS_VORAB * x + PREIS_ZUKAUF * (SZENARIEN - x),\n",
" PREIS_VORAB * x + PREIS_VERWALTUNG * (x - SZENARIEN),\n",
" )\n",
" return float(kosten @ WAHRSCHEINLICHKEITEN)\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 70)\n",
" print(\" DER FLUCH DES DURCHSCHNITTS\")\n",
" print(\"=\" * 70)\n",
" print(f\"Erwarteter Bedarf (Mittelwert): {ERWARTUNGSWERT:.1f}\")\n",
" print(f\"Kosten bei naiver Planung x={ERWARTUNGSWERT:.0f}: \"\n",
" f\"{erwartete_kosten(ERWARTUNGSWERT):,.2f} EUR\\n\")\n",
"\n",
" x_werte = np.arange(0, 501, 1)\n",
" kosten_werte = np.array([erwartete_kosten(x) for x in x_werte])\n",
" x_optimal = x_werte[np.argmin(kosten_werte)]\n",
" kosten_optimal = kosten_werte.min()\n",
"\n",
" print(f\"Optimales x (durch Scan gefunden): {x_optimal}\")\n",
" print(f\"Kosten beim Optimum: {kosten_optimal:,.2f} EUR\")\n",
" print(f\"Ersparnis gegenueber naiver Planung: \"\n",
" f\"{erwartete_kosten(ERWARTUNGSWERT) - kosten_optimal:,.2f} EUR\\n\")\n",
"\n",
" print(\"Kosten fuer ausgewaehlte x zum Vergleich:\")\n",
" for x in [100, 225, 250, 300, 500]:\n",
" markierung = \" <- Mittelwert\" if x == 225 else (\" <- Optimum\" if x == 250 else \"\")\n",
" print(f\" x={x:4d}: {erwartete_kosten(x):>12,.2f} EUR{markierung}\")\n",
"\n",
" print(\"\\nDas Optimum liegt exakt auf einem Szenariowert (250 = 'Volatil'),\")\n",
" print(\"nicht beim Mittelwert 225 - typisch fuer asymmetrische Kostenfunktionen.\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Monte-Carlo-Simulation\n",
"\n",
"`Monte_Carlo.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Monte_Carlo.py\n",
"\"\"\"\n",
"Kapitel Unsicherheit: Monte-Carlo-Bewertung von Kapazitaetsplaenen.\n",
"\n",
"Monte Carlo OPTIMIERT nicht - es BEWERTET. Der Nutzen liegt darin, dass man\n",
"beliebige Kennzahlen ablesen kann: Erwartungswert, Quantile, Ausfallwahr-\n",
"scheinlichkeit, Worst Case. Genau diese Groessen braucht man, um zwischen\n",
"Plaenen zu entscheiden.\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"\n",
"KOSTEN_VORAB = 40.0 # EUR je Einheit, im Voraus gekauft\n",
"KOSTEN_SPOT = 120.0 # EUR je Einheit, kurzfristig zugekauft\n",
"KOSTEN_LEERLAUF = 5.0 # EUR je ungenutzter Einheit\n",
"\n",
"ANZAHL_ZIEHUNGEN = 100_000\n",
"\n",
"\n",
"def ziehe_bedarf(rng, ziehungen):\n",
" \"\"\"\n",
" Bedarfsmodell: Mischverteilung aus Normalbetrieb und seltenen Lastspitzen.\n",
" Realistischer als eine reine Normalverteilung - Krisen sind selten,\n",
" aber extrem (fat tail).\n",
" \"\"\"\n",
" normal = rng.normal(loc=150, scale=40, size=ziehungen)\n",
" spitze = rng.normal(loc=450, scale=80, size=ziehungen)\n",
" ist_spitze = rng.random(ziehungen) < 0.15 # 15 % Lastspitzen\n",
" return np.maximum(np.where(ist_spitze, spitze, normal), 0.0)\n",
"\n",
"\n",
"def kosten_fuer(kapazitaet, bedarf):\n",
" \"\"\"Gesamtkosten je Szenario fuer eine gegebene Vorabkapazitaet.\"\"\"\n",
" unterdeckung = np.maximum(bedarf - kapazitaet, 0.0)\n",
" ueberdeckung = np.maximum(kapazitaet - bedarf, 0.0)\n",
" return (KOSTEN_VORAB * kapazitaet\n",
" + KOSTEN_SPOT * unterdeckung\n",
" + KOSTEN_LEERLAUF * ueberdeckung)\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" rng = np.random.default_rng(2026)\n",
" bedarf = ziehe_bedarf(rng, ANZAHL_ZIEHUNGEN)\n",
"\n",
" print(\"=\" * 88)\n",
" print(f\" MONTE-CARLO-BEWERTUNG ({ANZAHL_ZIEHUNGEN:,} Szenarien)\")\n",
" print(\"=\" * 88)\n",
" print(f\"Bedarfsverteilung: Mittelwert {bedarf.mean():.1f} | \"\n",
" f\"Median {np.median(bedarf):.1f} | \"\n",
" f\"95%-Quantil {np.percentile(bedarf, 95):.1f} | \"\n",
" f\"Maximum {bedarf.max():.1f}\")\n",
" print(\"Der Median liegt deutlich unter dem Mittelwert - die Verteilung ist\")\n",
" print(\"rechtsschief. Genau hier fuehrt Planung mit dem Mittelwert in die Irre.\\n\")\n",
"\n",
" print(f\"{'Kapazitaet':>10} | {'Erw. Kosten':>12} | {'Median':>10} | \"\n",
" f\"{'95%-Quantil':>12} | {'Unterdeckung':>12}\")\n",
" print(\"-\" * 88)\n",
"\n",
" kandidaten = [150, 200, 225, 250, 300, 350, 400]\n",
" ergebnisse = []\n",
" for kapazitaet in kandidaten:\n",
" kosten = kosten_fuer(kapazitaet, bedarf)\n",
" p_unterdeckung = float(np.mean(bedarf > kapazitaet))\n",
" ergebnisse.append((kapazitaet, kosten.mean(), p_unterdeckung))\n",
" print(f\"{kapazitaet:>10} | {kosten.mean():>12,.0f} | \"\n",
" f\"{np.median(kosten):>10,.0f} | {np.percentile(kosten, 95):>12,.0f} | \"\n",
" f\"{p_unterdeckung*100:>11.1f} %\")\n",
"\n",
" beste = min(ergebnisse, key=lambda t: t[1])\n",
" print(\"-\" * 88)\n",
" print(f\"Bester Kandidat: Kapazitaet {beste[0]} mit erwarteten Kosten \"\n",
" f\"{beste[1]:,.0f} EUR\")\n",
"\n",
" # --- Feinsuche ueber ein Raster ---------------------------------------\n",
" raster = np.arange(100, 500, 5)\n",
" erwartete = np.array([kosten_fuer(k, bedarf).mean() for k in raster])\n",
" optimum = raster[int(np.argmin(erwartete))]\n",
" print(f\"Feinsuche (Raster 100..500): Optimum bei Kapazitaet {optimum}, \"\n",
" f\"Kosten {erwartete.min():,.0f} EUR\")\n",
"\n",
" # --- Vergleich mit der naiven Mittelwertplanung ----------------------\n",
" naiv = int(round(bedarf.mean()))\n",
" kosten_naiv = kosten_fuer(naiv, bedarf).mean()\n",
" kosten_opt = kosten_fuer(optimum, bedarf).mean()\n",
" print(\"\\n--- Fluch des Durchschnitts, gemessen ---\")\n",
" print(f\" Planung mit Mittelwert ({naiv}): {kosten_naiv:,.0f} EUR\")\n",
" print(f\" Monte-Carlo-Optimum ({optimum}): {kosten_opt:,.0f} EUR\")\n",
" print(f\" Mehrkosten der naiven Planung: {kosten_naiv - kosten_opt:,.0f} EUR \"\n",
" f\"({(kosten_naiv/kosten_opt - 1)*100:.1f} %)\")\n",
" print(\"=\" * 88)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Zweistufige stochastische Programmierung\n",
"\n",
"`Stochastische_Optimierung.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Stochastische_Optimierung.py\n",
"\"\"\"\n",
"Kapitel Unsicherheit: Two-Stage Stochastic Programming mit CVXPY.\n",
"\n",
"Eigenschaften:\n",
" * Die Analyse am Ende wird BERECHNET statt fest verdrahtet\n",
" * Vergleich gegen drei Alternativen: Mittelwert, Worst Case, perfekte Voraussicht\n",
" * Kennzahl EVPI (Wert perfekter Information) wird ausgewiesen\n",
"\"\"\"\n",
"\n",
"import cvxpy as cp\n",
"import numpy as np\n",
"import pandas as pd\n",
"\n",
"SZENARIEN = [\"Ruhig\", \"Volatil\", \"Crash\"]\n",
"WAHRSCHEINLICHKEIT = np.array([0.50, 0.30, 0.20])\n",
"BEDARF = np.array([100.0, 250.0, 500.0])\n",
"\n",
"KOSTEN_VORAB = 40.0\n",
"KOSTEN_SPOT = 120.0\n",
"KOSTEN_LEERLAUF = 5.0\n",
"\n",
"S = len(SZENARIEN)\n",
"\n",
"\n",
"def loese_stochastisch():\n",
" \"\"\"Zweistufiges Modell: eine Vorabentscheidung, szenarioabhaengige Korrektur.\"\"\"\n",
" x = cp.Variable(nonneg=True, name=\"Basiskapazitaet\") # Stufe 1\n",
" y_spot = cp.Variable(S, nonneg=True, name=\"Spot_Zukauf\") # Stufe 2\n",
" y_leer = cp.Variable(S, nonneg=True, name=\"Leerlauf\") # Stufe 2\n",
"\n",
" # Kopplung: Basis + Zukauf - Leerlauf == Bedarf (je Szenario)\n",
" nebenbedingungen = [x + y_spot[s] - y_leer[s] == BEDARF[s] for s in range(S)]\n",
"\n",
" erwartete_korrektur = sum(\n",
" WAHRSCHEINLICHKEIT[s] * (KOSTEN_SPOT * y_spot[s] + KOSTEN_LEERLAUF * y_leer[s])\n",
" for s in range(S))\n",
"\n",
" problem = cp.Problem(cp.Minimize(KOSTEN_VORAB * x + erwartete_korrektur),\n",
" nebenbedingungen)\n",
" problem.solve()\n",
" return problem, x, y_spot, y_leer\n",
"\n",
"\n",
"def kosten_bei(kapazitaet):\n",
" \"\"\"Erwartete Gesamtkosten fuer eine fest vorgegebene Kapazitaet.\"\"\"\n",
" unter = np.maximum(BEDARF - kapazitaet, 0.0)\n",
" ueber = np.maximum(kapazitaet - BEDARF, 0.0)\n",
" je_szenario = KOSTEN_VORAB * kapazitaet + KOSTEN_SPOT * unter + KOSTEN_LEERLAUF * ueber\n",
" return float(WAHRSCHEINLICHKEIT @ je_szenario)\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" problem, x, y_spot, y_leer = loese_stochastisch()\n",
" kapazitaet = float(x.value)\n",
" mittelwert = float(WAHRSCHEINLICHKEIT @ BEDARF)\n",
"\n",
" print(\"=\" * 82)\n",
" print(\" STOCHASTISCHE TWO-STAGE OPTIMIERUNG (KAPAZITAETSPLANUNG)\")\n",
" print(\"=\" * 82)\n",
" print(f\"Status: {problem.status}\")\n",
" print(f\"Erwarteter Bedarf (Mittelwert): {mittelwert:.1f} Einheiten\")\n",
" print(f\"Optimale Stufe-1-Kapazitaet x*: {kapazitaet:.1f} Einheiten\")\n",
" print(f\"Minimale erwartete Gesamtkosten: {problem.value:,.2f} EUR\\n\")\n",
"\n",
" tabelle = pd.DataFrame({\n",
" \"Szenario\": SZENARIEN,\n",
" \"Wahrsch.\": [f\"{p*100:.0f} %\" for p in WAHRSCHEINLICHKEIT],\n",
" \"Bedarf\": BEDARF,\n",
" \"Basis genutzt\": [min(kapazitaet, b) for b in BEDARF],\n",
" \"Spot-Zukauf\": np.round(y_spot.value, 1),\n",
" \"Leerlauf\": np.round(y_leer.value, 1),\n",
" \"Kosten (EUR)\": [f\"{KOSTEN_VORAB*kapazitaet + KOSTEN_SPOT*y_spot.value[s] + KOSTEN_LEERLAUF*y_leer.value[s]:,.0f}\"\n",
" for s in range(S)],\n",
" })\n",
" print(tabelle.to_string(index=False))\n",
"\n",
" # --- Vergleich mit Alternativstrategien (berechnet, nicht behauptet) --\n",
" print(\"\\n\" + \"-\" * 82)\n",
" print(\"Vergleich verschiedener Planungsstrategien:\")\n",
" print(f\"{'Strategie':<34} {'Kapazitaet':>11} {'Erw. Kosten':>14} {'Mehrkosten':>13}\")\n",
" print(\"-\" * 82)\n",
"\n",
" optimal = problem.value\n",
" strategien = [\n",
" (\"Stochastisch optimal\", kapazitaet),\n",
" (\"Naiv: Mittelwert einsetzen\", mittelwert),\n",
" (\"Vorsichtig: Worst Case abdecken\", float(BEDARF.max())),\n",
" (\"Optimistisch: Bestfall\", float(BEDARF.min())),\n",
" ]\n",
" for name, kap in strategien:\n",
" kosten = kosten_bei(kap)\n",
" print(f\"{name:<34} {kap:>11.1f} {kosten:>14,.0f} \"\n",
" f\"{kosten - optimal:>+13,.0f}\")\n",
"\n",
" # --- EVPI: Was waere perfekte Voraussicht wert? ----------------------\n",
" # Bei perfekter Information wuerde man je Szenario genau den Bedarf kaufen.\n",
" kosten_perfekt = float(WAHRSCHEINLICHKEIT @ (KOSTEN_VORAB * BEDARF))\n",
" evpi = optimal - kosten_perfekt\n",
" print(\"-\" * 82)\n",
" print(f\"Kosten bei perfekter Voraussicht: {kosten_perfekt:>10,.0f} EUR\")\n",
" print(f\"Wert perfekter Information (EVPI): {evpi:>10,.0f} EUR \"\n",
" f\"({evpi/optimal*100:.1f} % der Kosten)\")\n",
" print(\" -> So viel duerfte eine perfekte Bedarfsprognose hoechstens kosten.\")\n",
"\n",
" # --- Automatische Interpretation --------------------------------------\n",
" print(\"-\" * 82)\n",
" if kapazitaet > mittelwert + 1e-6:\n",
" print(f\"Analyse: Der Solver waehlt {kapazitaet:.0f} Einheiten und damit MEHR als\")\n",
" print(f\"den Mittelwert ({mittelwert:.0f}), weil Unterdeckung ({KOSTEN_SPOT:.0f} EUR)\")\n",
" print(f\"deutlich teurer ist als Leerlauf ({KOSTEN_LEERLAUF:.0f} EUR).\")\n",
" elif kapazitaet < mittelwert - 1e-6:\n",
" print(f\"Analyse: Der Solver waehlt {kapazitaet:.0f} und damit WENIGER als den\")\n",
" print(f\"Mittelwert ({mittelwert:.0f}) - Leerlauf ist hier teurer als Zukauf.\")\n",
" else:\n",
" print(\"Analyse: Kapazitaet entspricht dem Mittelwert (symmetrische Kosten).\")\n",
" print(\"=\" * 82)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Robuste Optimierung: gegen den Worst Case absichern\n",
"\n",
"`Robuste_Optimierung.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Robuste_Optimierung.py\n",
"\"\"\"\n",
"Kapitel Unsicherheit: Robuste Portfolio-Optimierung.\n",
"\n",
"Vergleicht drei Haltungen zur Unsicherheit:\n",
" (a) nominal - vertraut den Punktschaetzungen blind\n",
" (b) robust - sichert gegen Box-Unsicherheit ab (Worst Case)\n",
" (c) stochastisch - optimiert den Erwartungswert ueber Szenarien\n",
"\n",
"und misst den \"Preis der Robustheit\": Wie viel Ertrag kostet die Absicherung\n",
"im Normalfall - und wie viel Verlust erspart sie im Ernstfall?\n",
"\"\"\"\n",
"\n",
"import cvxpy as cp\n",
"import numpy as np\n",
"\n",
"ASSETS = [\"Aktien Welt\", \"Anleihen\", \"Rohstoffe\", \"Immobilien\"]\n",
"MU_SCHAETZUNG = np.array([0.085, 0.030, 0.055, 0.060]) # Punktschaetzung\n",
"UNSICHERHEIT = np.array([0.040, 0.008, 0.045, 0.025]) # +/- delta je Titel\n",
"VOLA = np.array([0.17, 0.05, 0.22, 0.12])\n",
"KORR = np.array([\n",
" [1.00, -0.15, 0.35, 0.55],\n",
" [-0.15, 1.00, -0.05, 0.10],\n",
" [0.35, -0.05, 1.00, 0.25],\n",
" [0.55, 0.10, 0.25, 1.00],\n",
"])\n",
"SIGMA = np.diag(VOLA) @ KORR @ np.diag(VOLA)\n",
"LAMBDA = 4.0 # Risikoaversion\n",
"N = len(ASSETS)\n",
"\n",
"\n",
"def optimiere(mu_effektiv):\n",
" \"\"\"Standard-Mean-Variance mit vorgegebenem Renditevektor.\"\"\"\n",
" w = cp.Variable(N, nonneg=True)\n",
" ziel = cp.Maximize(mu_effektiv @ w - 0.5 * LAMBDA * cp.quad_form(w, SIGMA))\n",
" problem = cp.Problem(ziel, [cp.sum(w) == 1])\n",
" problem.solve()\n",
" return w.value\n",
"\n",
"\n",
"def kennzahlen(w, mu):\n",
" ertrag = float(mu @ w)\n",
" risiko = float(np.sqrt(w @ SIGMA @ w))\n",
" return ertrag, risiko\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 88)\n",
" print(\" ROBUSTE vs. NOMINALE PORTFOLIO-OPTIMIERUNG\")\n",
" print(\"=\" * 88)\n",
" print(f\"{'Asset':<14} {'Erw. Rendite':>14} {'Unsicherheit':>14} \"\n",
" f\"{'Worst Case':>12} {'Volatilitaet':>13}\")\n",
" print(\"-\" * 88)\n",
" for i, name in enumerate(ASSETS):\n",
" print(f\"{name:<14} {MU_SCHAETZUNG[i]*100:>13.1f} % \"\n",
" f\"{'+/- ' + format(UNSICHERHEIT[i]*100, '.1f') + ' %':>14} \"\n",
" f\"{(MU_SCHAETZUNG[i]-UNSICHERHEIT[i])*100:>11.1f} % \"\n",
" f\"{VOLA[i]*100:>12.1f} %\")\n",
"\n",
" # (a) nominal: vertraut den Schaetzungen\n",
" w_nominal = optimiere(MU_SCHAETZUNG)\n",
" # (b) robust: rechnet mit dem Worst Case der Box-Unsicherheitsmenge\n",
" w_robust = optimiere(MU_SCHAETZUNG - UNSICHERHEIT)\n",
"\n",
" print(\"\\n\" + \"-\" * 88)\n",
" print(f\"{'':<14} {'nominal':>22} {'robust':>22}\")\n",
" print(\"-\" * 88)\n",
" for i, name in enumerate(ASSETS):\n",
" print(f\"{name:<14} {w_nominal[i]*100:>21.1f} % {w_robust[i]*100:>21.1f} %\")\n",
"\n",
" # --- Bewertung in beiden Welten --------------------------------------\n",
" mu_worst = MU_SCHAETZUNG - UNSICHERHEIT\n",
" e_nom_gut, r_nom = kennzahlen(w_nominal, MU_SCHAETZUNG)\n",
" e_rob_gut, r_rob = kennzahlen(w_robust, MU_SCHAETZUNG)\n",
" e_nom_schlecht, _ = kennzahlen(w_nominal, mu_worst)\n",
" e_rob_schlecht, _ = kennzahlen(w_robust, mu_worst)\n",
"\n",
" print(\"\\n\" + \"-\" * 88)\n",
" print(f\"{'Bewertung':<34} {'nominales Portfolio':>22} {'robustes Portfolio':>22}\")\n",
" print(\"-\" * 88)\n",
" print(f\"{'Ertrag, wenn Schaetzung stimmt':<34} {e_nom_gut*100:>21.2f} % \"\n",
" f\"{e_rob_gut*100:>21.2f} %\")\n",
" print(f\"{'Ertrag im Worst Case':<34} {e_nom_schlecht*100:>21.2f} % \"\n",
" f\"{e_rob_schlecht*100:>21.2f} %\")\n",
" print(f\"{'Volatilitaet':<34} {r_nom*100:>21.2f} % {r_rob*100:>21.2f} %\")\n",
"\n",
" print(\"\\n\" + \"-\" * 88)\n",
" print(f\"Preis der Robustheit (Ertragsverzicht im Normalfall): \"\n",
" f\"{(e_nom_gut - e_rob_gut)*100:+.2f} Prozentpunkte\")\n",
" print(f\"Nutzen der Robustheit (Vorteil im Worst Case): \"\n",
" f\"{(e_rob_schlecht - e_nom_schlecht)*100:+.2f} Prozentpunkte\")\n",
" verhaeltnis = ((e_rob_schlecht - e_nom_schlecht)\n",
" / max(e_nom_gut - e_rob_gut, 1e-9))\n",
" print(f\"Verhaeltnis Nutzen/Preis: {verhaeltnis:.2f}\")\n",
" print(\" -> Werte > 1 bedeuten: Die Absicherung bringt im Ernstfall mehr,\")\n",
" print(\" als sie im Normalfall kostet.\")\n",
" print(\"=\" * 88)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -0,0 +1,174 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Vorwort & Lesehilfe\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Installationstest {-}\n",
"\n",
"`Installationstest.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Installationstest.py\n",
"\"\"\"\n",
"Vorspann: Prüft die vollständige Kurs-Installation.\n",
"Ausgabe: eine Zeile pro Paket plus ein gelöstes Mini-Modell je Solver-Familie.\n",
"\"\"\"\n",
"\n",
"import importlib.metadata\n",
"import importlib.util\n",
"import logging\n",
"import sys\n",
"\n",
"PAKETE = [\n",
" (\"numpy\", \"Numerische Basis (Vektoren, Matrizen)\"),\n",
" (\"scipy\", \"Wissenschaftliche Algorithmen, linprog/minimize\"),\n",
" (\"pandas\", \"Tabellen und Zeitreihen\"),\n",
" (\"matplotlib\", \"Diagramme\"),\n",
" (\"ortools\", \"Google OR-Tools: CP-SAT und Routing\"),\n",
" (\"highspy\", \"HiGHS-Solver, direkte Steuerung\"),\n",
" (\"cvxpy\", \"Konvexe Optimierung (Portfolio, CVaR)\"),\n",
" (\"sklearn\", \"Ledoit-Wolf-Shrinkage der Kovarianzmatrix\"),\n",
" (\"yfinance\", \"Kursdatenbezug (nur die Finanzkapitel)\"),\n",
"]\n",
"\n",
"# Nur auf Anwesenheit prüfen, NICHT importieren (siehe Kapitel Ökosystem):\n",
"# - highspy: seine HiGHS-Bibliothek verträgt sich nicht mit der Kopie von\n",
"# ortools im selben Prozess.\n",
"# - cvxpy: importiert bei der Solver-Erkennung ein installiertes highspy\n",
"# selbst mit und löst so denselben Konflikt aus. Importiert wird cvxpy\n",
"# erst im Funktionstest, nachdem ortools bereits geladen ist.\n",
"NUR_PRUEFEN = {\"highspy\", \"cvxpy\"}\n",
"\n",
"def paket_version(name: str) -> str:\n",
" \"\"\"Liefert die installierte Version; ImportError, falls das Paket fehlt.\"\"\"\n",
" if name in NUR_PRUEFEN:\n",
" if importlib.util.find_spec(name) is None:\n",
" raise ImportError(name) # nicht installiert\n",
" try:\n",
" # Version aus den Metadaten — das Modul wird ja nicht geladen\n",
" return importlib.metadata.version(name)\n",
" except importlib.metadata.PackageNotFoundError:\n",
" return \"unbekannt\" # installiert, aber ohne Metadaten\n",
" modul = importlib.import_module(name)\n",
" return getattr(modul, \"__version__\", \"unbekannt\")\n",
"\n",
"def pruefe_pakete() -> list[str]:\n",
" \"\"\"Prüft jedes Paket und meldet Version oder Fehlgrund.\"\"\"\n",
" fehlend = []\n",
" print(f\"Python-Version: {sys.version.split()[0]}\\n\")\n",
" print(f\"{'Paket':<12} {'Version':<12} {'Zweck'}\")\n",
" print(\"-\" * 78)\n",
" for name, zweck in PAKETE:\n",
" try:\n",
" print(f\"{name:<12} {paket_version(name):<12} {zweck}\")\n",
" except ImportError:\n",
" print(f\"{name:<12} {'FEHLT':<12} {zweck}\")\n",
" fehlend.append(name)\n",
" return fehlend\n",
"\n",
"def teste_cp_sat() -> bool:\n",
" \"\"\"Löst 'maximiere x+y unter x+2y<=10, x<=4' mit CP-SAT. Erwartet: x=4, y=3.\"\"\"\n",
" from ortools.sat.python import cp_model\n",
" modell = cp_model.CpModel()\n",
" x = modell.NewIntVar(0, 4, \"x\")\n",
" y = modell.NewIntVar(0, 10, \"y\")\n",
" modell.Add(x + 2 * y <= 10)\n",
" modell.Maximize(x + y)\n",
" loeser = cp_model.CpSolver()\n",
" status = loeser.Solve(modell)\n",
" ok = status == cp_model.OPTIMAL and loeser.Value(x) == 4 and loeser.Value(y) == 3\n",
" print(f\"CP-SAT : x={loeser.Value(x)}, y={loeser.Value(y)} -> {'OK' if ok else 'FEHLER'}\")\n",
" return ok\n",
"\n",
"def teste_scipy_linprog() -> bool:\n",
" \"\"\"Löst dasselbe Problem kontinuierlich mit HiGHS über SciPy. Erwartet: x=4, y=3.\"\"\"\n",
" from scipy.optimize import linprog\n",
" # linprog minimiert -> Zielfunktion negieren, um zu maximieren\n",
" ergebnis = linprog(c=[-1, -1], A_ub=[[1, 2]], b_ub=[10],\n",
" bounds=[(0, 4), (0, 10)], method=\"highs\")\n",
" ok = ergebnis.success and abs(ergebnis.x[0] - 4) < 1e-6 and abs(ergebnis.x[1] - 3) < 1e-6\n",
" print(f\"SciPy/HiGHS : x={ergebnis.x[0]:.2f}, y={ergebnis.x[1]:.2f} -> {'OK' if ok else 'FEHLER'}\")\n",
" return ok\n",
"\n",
"def teste_cvxpy() -> bool:\n",
" \"\"\"Minimiert (x-2)^2 unter x<=1 mit CVXPY. Erwartet: x=1.\"\"\"\n",
" # CVXPY warnt beim Import, wenn sein HIGHS-Interface wegen der\n",
" # HiGHS-Kollision (siehe Kapitel Ökosystem) nicht lädt — für diesen Test\n",
" # folgenlos, deshalb die Warnung kurz stillstellen.\n",
" logging.disable(logging.WARNING)\n",
" import cvxpy as cp\n",
" logging.disable(logging.NOTSET)\n",
" x = cp.Variable()\n",
" problem = cp.Problem(cp.Minimize(cp.square(x - 2)), [x <= 1])\n",
" problem.solve()\n",
" ok = problem.status == \"optimal\" and abs(x.value - 1.0) < 1e-6\n",
" print(f\"CVXPY : x={x.value:.4f} -> {'OK' if ok else 'FEHLER'}\")\n",
" return ok\n",
"\n",
"if __name__ == \"__main__\":\n",
" # ortools' native Bibliothek zuerst laden (Kapitel Ökosystem): die zuerst\n",
" # geladene HiGHS-Kopie gewinnt — und das soll die von ortools sein.\n",
" if importlib.util.find_spec(\"ortools\") is not None:\n",
" from ortools.sat.python import cp_model\n",
"\n",
" fehlend = pruefe_pakete()\n",
" print(\"\\nSolver-Funktionstest\")\n",
" print(\"-\" * 78)\n",
" if fehlend:\n",
" print(f\"Abbruch: Es fehlen {len(fehlend)} Pakete: {', '.join(fehlend)}\")\n",
" print(\"Installation: pip install \" + \" \".join(\n",
" \"scikit-learn\" if p == \"sklearn\" else p for p in fehlend))\n",
" sys.exit(1)\n",
"\n",
" alle_ok = all([teste_cp_sat(), teste_scipy_linprog(), teste_cvxpy()])\n",
" print(\"-\" * 78)\n",
" print(\"Alles bereit — Sie können mit dem ersten Kapitel beginnen.\"\n",
" if alle_ok else \"Mindestens ein Solver arbeitet fehlerhaft.\")\n",
" sys.exit(0 if alle_ok else 1)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}