Version 04 als eigenes Repository

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

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

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

Zusaetzlich in diesem Commit:

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

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

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

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

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

577
Notebooks_04/qp-nlp.ipynb Normal file
View file

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