operations_research/OR_HTML_04/Notebooks_04/unsicherheit.ipynb

714 lines
32 KiB
Text
Raw Normal View History

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>
2026-09-08 01:20:09 +02:00
{
"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)"
]
Phase 6.1: Chance Constraints - die Zusage "mit 95 % Sicherheit" Neuer Abschnitt im Kapitel Unsicherheit plus Chance_Constraints.py (74. Programm). Das Kapitel hatte Monte-Carlo, Zweistufigkeit und Worst-Case-Robustheit; die Wahrscheinlichkeitszusage war die fehlende vierte Antwort - und die, nach der das Management tatsaechlich fragt. Setzt Paket 4 aus Verbesserungen_02.md um. Beide Wege an DERSELBEN Instanz (Kraftwerkspark, 500 MW gesicherte Zusage): analytisch als Second-Order-Cone-Bedingung (CLARABEL) und szenariobasiert als Big-M-MILP (SciPy/HiGHS) - beides in einem Prozess, ohne ortools- oder highspy-Import. Drei gemessene Befunde: * Der Mittelwertplan haelt 50,08 %. Kein Fehler, sondern die Definition des Erwartungswerts. * Sicherheit ist konvex bepreist: 56.289 EUR je Prozentpunkt auf dem Weg zu 80 %, 253.848 EUR zwischen 95 und 99 % - das 4,5-fache. Das Programm rechnet die Tabelle selbst aus, statt sie zu behaupten. * Die Zusage gilt nur fuer die unterstellte Verteilung: Der 95-%-Plan haelt gemessen 87,44 %, sobald die Testverteilung eine Kaeltewelle mit Dunkelflaute enthaelt, in der alles zugleich einbricht - auch das Gaskraftwerk. In einer reinen Normalwelt liefert derselbe Plan 94,97 %. Zwei eigene Fehlgriffe, beide durch Messen aufgefallen und korrigiert: Die erste Kostentarierung ergab eine entartete Loesung (alles ins Gaskraftwerk), womit die Kovarianzmatrix wirkungslos war - und gerade sie begruendet die Kegelform. Und der erste Kaelteeinbruch traf nur Wind und Sonne; der SOC-Plan hatte die ohnehin herausgehalten und war zufaellig robust, das Argument trug nicht. Ehrlich berichtet statt geglaettet: Die Szenariomethode ueberanpasst. Ueber zwoelf Laeufe (S = 200, 400, 800) lag die tatsaechliche Quote zwischen 93,14 % und 96,71 %, und die Spanne wurde von S = 400 auf 800 wieder breiter. Beide Solver bestaetigen optimal bei identischen Kosten - echte Ueberanpassung, kein Solverartefakt. Steht als Warnkasten im Abschnitt. Die Laufzeitmessung ist aus der Ausgabe entfernt: Eine Wanduhrzeit ist nie byteidentisch reproduzierbar (2,2 s / 2,3 s zwischen zwei Laeufen) und haette Regel 4 dauerhaft gebrochen. Danach drei Laeufe zeichengleich, und die abgedruckte Ausgabe stimmt mit dem Lauf des extrahierten Programms ueberein. Mitgezogen: Kapitelkopf, Lernziele, Uebersichtstabelle (drei -> vier Ansaetze), Selbsttest, Zusammenfassung, Vorwort-Programmverzeichnis, eine neue Uebungsaufgabe und ihre Loesung in Anhang A. pyproject.toml korrigiert: cvxpy lag in [finance], wird aber von 13 Programmen gebraucht, darunter dreien in diesem Kapitel - jetzt in der Grundausstattung. Es zieht kein highspy nach, der Solverkonflikt bleibt auf [large-scale] beschraenkt. Stand: 293 Abschnitte, 712 Querverweise, 327 Indexmarken, 74 Programme (0 Fehler), 33 pytest-Tests, PDF 725 Seiten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:57:19 +02:00
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Der Fall: ein Kraftwerkspark mit 500 MW Zusage\n",
"\n",
"`Chance_Constraints.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Chance_Constraints.py\n",
"\"\"\"\n",
"Kapitel Unsicherheit: Wahrscheinlichkeitsbeschraenkungen.\n",
"\n",
"Das Management fragt selten \"was ist im Mittel am besten?\", sondern \"mit\n",
"welcher Sicherheit haelt der Plan?\". Genau das formuliert eine Chance\n",
"Constraint: P(Versorgung >= Bedarf) >= 1 - alpha.\n",
"\n",
"Gezeigt werden beide Wege dorthin, an derselben Instanz:\n",
" (a) analytisch - unter Normalverteilungsannahme wird daraus eine\n",
" Second-Order-Cone-Bedingung, loesbar mit CVXPY\n",
" (b) szenariobasiert - Big-M mit Binaervariablen, fuer beliebige\n",
" empirische Verteilungen\n",
"\n",
"Und die beiden Messungen, auf die es ankommt: Was kostet ein Prozentpunkt\n",
"Versorgungssicherheit - und haelt die Zusage auch dann, wenn die Verteilung\n",
"nicht normal ist?\n",
"\n",
"Solver: CLARABEL (Kegel) und SciPy/HiGHS (gemischt-ganzzahlig). Weder ortools\n",
"noch ein direkter highspy-Import, damit alles in einem Prozess laeuft.\n",
"\"\"\"\n",
"\n",
"import cvxpy as cp\n",
"import numpy as np\n",
"from scipy.stats import norm\n",
"\n",
"# --- Der Kraftwerkspark ----------------------------------------------------\n",
"# Die Verfuegbarkeit ist der Anteil, den eine installierte MW im Mittel\n",
"# wirklich liefert: bei Wind und Sonne klein und stark schwankend, bei Gas und\n",
"# Biomasse gross und stabil. Die Ausbaugrenze ist der Standort - Flaeche,\n",
"# Genehmigung, Brennstoffversorgung.\n",
"TECHNIK = [\"Gaskraftwerk\", \"Windpark\", \"Solarpark\", \"Biomasse\"]\n",
"KOSTEN = np.array([65_000.0, 24_000.0, 12_000.0, 60_000.0]) # EUR je MW und Jahr\n",
"VERFUEGBAR = np.array([0.92, 0.35, 0.18, 0.85])\n",
"STREUUNG = np.array([0.05, 0.16, 0.10, 0.04])\n",
"GRENZE = np.array([400.0, 250.0, 600.0, 350.0]) # MW\n",
"\n",
"# Wind und Sonne sind leicht gegenlaeufig: Ein Tiefdruckgebiet bringt Wind und\n",
"# Wolken zugleich. Genau diese Korrelation macht die Bedingung zu einem Kegel\n",
"# und nicht zu einer Summe unabhaengiger Einzelzuschlaege.\n",
"KORRELATION = np.array([\n",
" [1.00, 0.00, 0.00, 0.05],\n",
" [0.00, 1.00, -0.25, 0.00],\n",
" [0.00, -0.25, 1.00, 0.00],\n",
" [0.05, 0.00, 0.00, 1.00],\n",
"])\n",
"SIGMA = np.diag(STREUUNG) @ KORRELATION @ np.diag(STREUUNG)\n",
"WURZEL = np.linalg.cholesky(SIGMA) # L mit L @ L.T == SIGMA\n",
"\n",
"BEDARF = 500.0 # MW, die gesichert bereitstehen muessen\n",
"N = len(TECHNIK)\n",
"\n",
"# Die Kaeltewelle mit Dunkelflaute: selten, aber sie trifft alles zugleich.\n",
"# Wind und Sonne brechen fast vollstaendig weg - und, das ist der Punkt, das\n",
"# Gaskraftwerk liefert ebenfalls weniger, weil bei Frost der Netzdruck faellt.\n",
"# Eine Kovarianzmatrix mit Korrelationen um null kann das nicht ausdruecken.\n",
"P_KAELTEWELLE = 0.08\n",
"EINBRUCH = np.array([0.72, 0.10, 0.15, 0.88])\n",
"\n",
"\n",
"def mittelwertplan():\n",
" \"\"\"Plant mit den Erwartungswerten - ignoriert die Streuung vollstaendig.\"\"\"\n",
" x = cp.Variable(N, nonneg=True)\n",
" problem = cp.Problem(cp.Minimize(KOSTEN @ x),\n",
" [VERFUEGBAR @ x >= BEDARF, x <= GRENZE])\n",
" problem.solve(solver=cp.CLARABEL)\n",
" return problem.value, x.value\n",
"\n",
"\n",
"def chance_constraint_analytisch(alpha):\n",
" \"\"\"\n",
" P(a^T x >= BEDARF) >= 1 - alpha unter a ~ N(VERFUEGBAR, SIGMA).\n",
"\n",
" Aequivalent zu: VERFUEGBAR^T x - z * ||L^T x||_2 >= BEDARF\n",
" mit z = Phi^-1(1 - alpha). Das ist eine Second-Order-Cone-Bedingung: Der\n",
" Sicherheitszuschlag ist eine Norm ueber x, kein fester Aufschlag je Anlage.\n",
" Deshalb belohnt sie Mischung - zwei gegenlaeufige Quellen schwanken\n",
" gemeinsam weniger als jede fuer sich.\n",
" \"\"\"\n",
" x = cp.Variable(N, nonneg=True)\n",
" z = norm.ppf(1 - alpha)\n",
" bedingung = VERFUEGBAR @ x - z * cp.norm(WURZEL.T @ x, 2) >= BEDARF\n",
" problem = cp.Problem(cp.Minimize(KOSTEN @ x), [bedingung, x <= GRENZE])\n",
" problem.solve(solver=cp.CLARABEL)\n",
" return problem.value, x.value\n",
"\n",
"\n",
"def chance_constraint_szenarien(a, alpha):\n",
" \"\"\"\n",
" Dieselbe Zusage ohne Verteilungsannahme: Fuer jedes Szenario s sagt eine\n",
" Binaervariable z_s, ob es verletzt werden darf.\n",
"\n",
" a_s^T x >= BEDARF - M * z_s fuer alle s\n",
" sum_s z_s <= alpha * S\n",
"\n",
" M = BEDARF ist die kleinstmoegliche gueltige Schranke, denn a_s^T x >= 0:\n",
" Groesser kann eine Verletzung gar nicht ausfallen. Ein unnoetig grosses M\n",
" wuerde die LP-Relaxierung aufweichen und die Suche verlangsamen - die\n",
" Big-M-Falle aus dem Kapitel Gemischt-ganzzahlige Optimierung.\n",
" \"\"\"\n",
" S = len(a)\n",
" x = cp.Variable(N, nonneg=True)\n",
" z = cp.Variable(S, boolean=True)\n",
" problem = cp.Problem(\n",
" cp.Minimize(KOSTEN @ x),\n",
" [a @ x >= BEDARF - BEDARF * z, cp.sum(z) <= alpha * S, x <= GRENZE])\n",
" problem.solve(solver=cp.SCIPY)\n",
" return problem.value, x.value\n",
"\n",
"\n",
"def ziehe_wetter(anzahl, seed):\n",
" \"\"\"Mischverteilung: normales Wetter, mit P_KAELTEWELLE ein Einbruch.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" a = rng.multivariate_normal(VERFUEGBAR, SIGMA, size=anzahl)\n",
" getroffen = rng.random(anzahl) < P_KAELTEWELLE\n",
" a[getroffen] *= EINBRUCH\n",
" return np.clip(a, 0.0, 1.0)\n",
"\n",
"\n",
"def sicherheit(x, a):\n",
" \"\"\"Anteil der Szenarien, in denen der Plan den Bedarf deckt.\"\"\"\n",
" return float(np.mean(a @ x >= BEDARF))\n",
"\n",
"\n",
"KOPF = (f\"{'Plan':<20} {'Kosten (EUR)':>13} {'Gas':>4} {'Wind':>4} \"\n",
" f\"{'Sol':>4} {'Bio':>4} {'Normalwelt':>11} {'echte Welt':>11}\")\n",
"\n",
"\n",
"def zeile(name, kosten, x, in_normal, in_echt):\n",
" mix = \" \".join(f\"{w:4.0f}\" for w in x)\n",
" return (f\"{name:<20} {kosten:>13,.0f} {mix} \"\n",
" f\"{in_normal * 100:>9.2f} % {in_echt * 100:>9.2f} %\")\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" print(\"=\" * 78)\n",
" print(\" WAHRSCHEINLICHKEITSBESCHRAENKUNGEN IM KRAFTWERKSPARK\")\n",
" print(\"=\" * 78)\n",
" print(f\"Gesichert bereitzustellen: {BEDARF:.0f} MW\\n\")\n",
" print(f\"{'Technologie':<14} {'EUR/MW':>8} {'verfuegbar':>11} {'Streuung':>9} \"\n",
" f\"{'Grenze':>8} {'EUR je erw. MW':>15}\")\n",
" print(\"-\" * 78)\n",
" for i, name in enumerate(TECHNIK):\n",
" print(f\"{name:<14} {KOSTEN[i]:>8,.0f} {VERFUEGBAR[i]:>10.2f} \"\n",
" f\"{STREUUNG[i]:>8.2f} {GRENZE[i]:>7.0f} \"\n",
" f\"{KOSTEN[i] / VERFUEGBAR[i]:>15,.0f}\")\n",
" print(f\"\\nVollausbau liefert im Mittel {VERFUEGBAR @ GRENZE:.0f} MW, \"\n",
" f\"in der Kaeltewelle {(VERFUEGBAR * EINBRUCH) @ GRENZE:.0f} MW.\")\n",
"\n",
" # Zwei grosse, unabhaengige Testmengen. An ihnen wird JEDER Plan gemessen -\n",
" # einmal in der unterstellten Normalwelt, einmal in der echten Verteilung.\n",
" echt = ziehe_wetter(200_000, seed=771)\n",
" normalwelt = np.random.default_rng(4711).multivariate_normal(\n",
" VERFUEGBAR, SIGMA, size=200_000)\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (1) Mittelwertplan und Chance Constraints im Vergleich\")\n",
" print(\"=\" * 78)\n",
" print(KOPF)\n",
" print(\"-\" * 78)\n",
"\n",
" k_mittel, x_mittel = mittelwertplan()\n",
" stufen = [(\"Mittelwert\", k_mittel, sicherheit(x_mittel, normalwelt))]\n",
" print(zeile(\"Mittelwert\", k_mittel, x_mittel,\n",
" sicherheit(x_mittel, normalwelt), sicherheit(x_mittel, echt)))\n",
"\n",
" plaene = {}\n",
" for alpha in (0.20, 0.10, 0.05, 0.01):\n",
" kosten, x = chance_constraint_analytisch(alpha)\n",
" plaene[alpha] = (kosten, x)\n",
" quote_normal = sicherheit(x, normalwelt)\n",
" stufen.append((f\"Zusage {(1 - alpha) * 100:.0f} %\", kosten, quote_normal))\n",
" print(zeile(f\"Zusage {(1 - alpha) * 100:.0f} %\", kosten, x,\n",
" quote_normal, sicherheit(x, echt)))\n",
"\n",
" print(\"\\n In der Normalwelt trifft jede Zusage ihren Wert - das Verfahren\")\n",
" print(\" rechnet richtig. Die Spalte 'echte Welt' kommt in Teil (3).\")\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (2) Was kostet ein Prozentpunkt Versorgungssicherheit?\")\n",
" print(\"=\" * 78)\n",
" print(f\"{'von -> nach':<26} {'Prozentpunkte':>13} {'Mehrkosten':>13} \"\n",
" f\"{'EUR je Punkt':>13}\")\n",
" print(\"-\" * 78)\n",
" for (n1, k1, q1), (n2, k2, q2) in zip(stufen, stufen[1:]):\n",
" d_punkte = (q2 - q1) * 100\n",
" d_kosten = k2 - k1\n",
" print(f\"{n1 + ' -> ' + n2:<26} {d_punkte:>13.2f} {d_kosten:>13,.0f} \"\n",
" f\"{d_kosten / d_punkte:>13,.0f}\")\n",
" erst = (stufen[1][1] - stufen[0][1]) / ((stufen[1][2] - stufen[0][2]) * 100)\n",
" letzt = (stufen[-1][1] - stufen[-2][1]) / ((stufen[-1][2] - stufen[-2][2]) * 100)\n",
" print(f\"\\n Der letzte Prozentpunkt kostet das {letzt / erst:.1f}-fache des ersten.\")\n",
" print(\" Sicherheit ist konvex bepreist - genau deshalb muss jemand\")\n",
" print(\" entscheiden, wie viel davon das Unternehmen kaufen will.\")\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (3) Und wenn die Verteilung nicht normal ist?\")\n",
" print(\"=\" * 78)\n",
" k_soc, x_soc = plaene[0.05]\n",
" print(f\"Die echte Welt kennt die Kaeltewelle mit Dunkelflaute: in \"\n",
" f\"{P_KAELTEWELLE * 100:.0f} % der Faelle liefern\")\n",
" print(f\"Wind {EINBRUCH[1] * 100:.0f} %, Sonne {EINBRUCH[2] * 100:.0f} % \"\n",
" f\"und - entscheidend - auch das Gaskraftwerk nur \"\n",
" f\"{EINBRUCH[0] * 100:.0f} %\")\n",
" print(\"des Ueblichen. Alles bricht gleichzeitig ein.\\n\")\n",
"\n",
" a_bau = ziehe_wetter(400, seed=20260908)\n",
" k_sz, x_sz = chance_constraint_szenarien(a_bau, 0.05)\n",
"\n",
" print(KOPF)\n",
" print(\"-\" * 78)\n",
" print(zeile(\"SOC, Zusage 95 %\", k_soc, x_soc,\n",
" sicherheit(x_soc, normalwelt), sicherheit(x_soc, echt)))\n",
" print(zeile(\"Szenarien, 95 %\", k_sz, x_sz,\n",
" sicherheit(x_sz, normalwelt), sicherheit(x_sz, echt)))\n",
"\n",
" print(f\"\\n Der SOC-Plan verspricht 95 % und haelt \"\n",
" f\"{sicherheit(x_soc, echt) * 100:.1f} %. Nicht das Verfahren ist\")\n",
" print(f\" falsch, sondern die Annahme: In der Normalwelt liefert derselbe\")\n",
" print(f\" Plan {sicherheit(x_soc, normalwelt) * 100:.1f} %.\")\n",
" print(f\"\\n Der Szenarioplan ({len(a_bau)} Szenarien, {len(a_bau)} \"\n",
" f\"Binaervariablen) kommt auf\")\n",
" print(f\" {sicherheit(x_sz, echt) * 100:.1f} % - und kostet dafuer \"\n",
" f\"{(k_sz / k_soc - 1) * 100:+.1f} %. Er kauft keine Windkraft mehr:\")\n",
" print(\" Was im Ernstfall ausfaellt, hilft der Zusage nicht.\")\n",
" print(\"=\" * 78)"
]
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>
2026-09-08 01:20:09 +02:00
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}