Version 04 als eigenes Repository
Erster Commit des Strangs "Optimierte Entscheidungsfindung mit Python" (Version 04). Die Historie der 71 Commits bis zur Trennung bleibt im uebergeordneten Repository OR_mit_Python liegen, das ab jetzt nur noch Version_03 (eingefroren) verwaltet und Version_04/ ignoriert. Bewusst kein "git subtree split": Der Pfad Version_04/ existiert erst seit der Verzeichnistrennung, ein Split braechte daher nur 7 der 41 einschlaegigen Commits - eine Teilhistorie, die vollstaendig aussieht und es nicht ist. Stand: 5 Teile, 23 Kapitel, 5 Anhaenge, 292 Abschnitte, 703 Querverweise, 325 Indexmarken, 73 Beispielprogramme, 32 SVGs, 4 Plotly-Figuren, 25 Notebooks, PDF mit 715 Seiten. Zusaetzlich in diesem Commit: * pyproject.toml mit Abhaengigkeitsgruppen finance, large-scale, api, figures, dev, empfehlungen. Die abgedruckte requirements.txt bleibt unveraendert daneben bestehen. ortools steht in der Grundausstattung, highspy erst in [large-scale] - so kann der HiGHS-Symbolkonflikt bei der schlanken Installation gar nicht erst auftreten. * Dabei zwei Funde: graphviz wird von erzeuge_architektur_diagramme.py importiert, fehlt aber in requirements.txt (jetzt in [figures]); pymoo steht in requirements.txt, wird aber von keinem Programm importiert, sondern nur im Kapitel Metaheuristiken empfohlen (jetzt in [empfehlungen]). * NEUER_TITEL.md nach Kritik_und_Verbesserungsvorschlaege/ verschoben - es ist die Vorlage des Titelblatts, kein Bestandteil des Werks. Die beiden Fundstellen in PROGRESS.md und erzeuge_titelseite.py nachgezogen. * PROGRESS.md nannte noch den Untertitel der ersten Fassung; auf den tatsaechlichen aus erzeuge_titelseite.py korrigiert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
b7af2f1d9a
468 changed files with 262141 additions and 0 deletions
280
Notebooks_04/anhang-fehlerdiagnose.ipynb
Normal file
280
Notebooks_04/anhang-fehlerdiagnose.ipynb
Normal 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
|
||||
}
|
||||
330
Notebooks_04/bruecke.ipynb
Normal file
330
Notebooks_04/bruecke.ipynb
Normal 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
|
||||
}
|
||||
902
Notebooks_04/cpsat.ipynb
Normal file
902
Notebooks_04/cpsat.ipynb
Normal 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
|
||||
}
|
||||
365
Notebooks_04/cvar.ipynb
Normal file
365
Notebooks_04/cvar.ipynb
Normal 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
|
||||
}
|
||||
347
Notebooks_04/dekomposition.ipynb
Normal file
347
Notebooks_04/dekomposition.ipynb
Normal 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
|
||||
}
|
||||
368
Notebooks_04/dynamische-programmierung.ipynb
Normal file
368
Notebooks_04/dynamische-programmierung.ipynb
Normal 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
|
||||
}
|
||||
484
Notebooks_04/einfuehrung.ipynb
Normal file
484
Notebooks_04/einfuehrung.ipynb
Normal 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
|
||||
}
|
||||
540
Notebooks_04/finanzdaten.ipynb
Normal file
540
Notebooks_04/finanzdaten.ipynb
Normal 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
|
||||
}
|
||||
579
Notebooks_04/fundament.ipynb
Normal file
579
Notebooks_04/fundament.ipynb
Normal 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
|
||||
}
|
||||
644
Notebooks_04/graphen.ipynb
Normal file
644
Notebooks_04/graphen.ipynb
Normal 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
|
||||
}
|
||||
623
Notebooks_04/handelsmaschine.ipynb
Normal file
623
Notebooks_04/handelsmaschine.ipynb
Normal 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
|
||||
}
|
||||
633
Notebooks_04/lp.ipynb
Normal file
633
Notebooks_04/lp.ipynb
Normal 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
|
||||
}
|
||||
542
Notebooks_04/markowitz.ipynb
Normal file
542
Notebooks_04/markowitz.ipynb
Normal 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
|
||||
}
|
||||
315
Notebooks_04/mehrziel.ipynb
Normal file
315
Notebooks_04/mehrziel.ipynb
Normal 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
|
||||
}
|
||||
906
Notebooks_04/metaheuristiken.ipynb
Normal file
906
Notebooks_04/metaheuristiken.ipynb
Normal 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
|
||||
}
|
||||
1049
Notebooks_04/milp.ipynb
Normal file
1049
Notebooks_04/milp.ipynb
Normal file
File diff suppressed because it is too large
Load diff
298
Notebooks_04/modellierung.ipynb
Normal file
298
Notebooks_04/modellierung.ipynb
Normal 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
|
||||
}
|
||||
709
Notebooks_04/oekosystem.ipynb
Normal file
709
Notebooks_04/oekosystem.ipynb
Normal 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
|
||||
}
|
||||
1324
Notebooks_04/praxisfallen.ipynb
Normal file
1324
Notebooks_04/praxisfallen.ipynb
Normal file
File diff suppressed because it is too large
Load diff
285
Notebooks_04/prognose.ipynb
Normal file
285
Notebooks_04/prognose.ipynb
Normal 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
|
||||
}
|
||||
577
Notebooks_04/qp-nlp.ipynb
Normal file
577
Notebooks_04/qp-nlp.ipynb
Normal 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
|
||||
}
|
||||
393
Notebooks_04/supplychain.ipynb
Normal file
393
Notebooks_04/supplychain.ipynb
Normal 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
|
||||
}
|
||||
1111
Notebooks_04/testing.ipynb
Normal file
1111
Notebooks_04/testing.ipynb
Normal file
File diff suppressed because it is too large
Load diff
469
Notebooks_04/unsicherheit.ipynb
Normal file
469
Notebooks_04/unsicherheit.ipynb
Normal 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
|
||||
}
|
||||
174
Notebooks_04/vorwort.ipynb
Normal file
174
Notebooks_04/vorwort.ipynb
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue