diff --git a/Notebooks_04/unsicherheit.ipynb b/Notebooks_04/unsicherheit.ipynb
index 2d53647..82b5421 100644
--- a/Notebooks_04/unsicherheit.ipynb
+++ b/Notebooks_04/unsicherheit.ipynb
@@ -451,6 +451,251 @@
" print(\" als sie im Normalfall kostet.\")\n",
" print(\"=\" * 88)"
]
+ },
+ {
+ "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)"
+ ]
}
],
"metadata": {
diff --git a/OR_HTML_04/Notebooks_04/unsicherheit.ipynb b/OR_HTML_04/Notebooks_04/unsicherheit.ipynb
index 2d53647..82b5421 100644
--- a/OR_HTML_04/Notebooks_04/unsicherheit.ipynb
+++ b/OR_HTML_04/Notebooks_04/unsicherheit.ipynb
@@ -451,6 +451,251 @@
" print(\" als sie im Normalfall kostet.\")\n",
" print(\"=\" * 88)"
]
+ },
+ {
+ "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)"
+ ]
}
],
"metadata": {
diff --git a/OR_HTML_04/Operations_Research_mit_Python_Version_04.pdf b/OR_HTML_04/Operations_Research_mit_Python_Version_04.pdf
index 3c608b7..d5e8848 100644
Binary files a/OR_HTML_04/Operations_Research_mit_Python_Version_04.pdf and b/OR_HTML_04/Operations_Research_mit_Python_Version_04.pdf differ
diff --git a/OR_HTML_04/anhang-loesungen.html b/OR_HTML_04/anhang-loesungen.html
index bda4b0b..eb8f287 100644
--- a/OR_HTML_04/anhang-loesungen.html
+++ b/OR_HTML_04/anhang-loesungen.html
@@ -801,11 +801,12 @@
A.12 Lösungen zu Kapitel „Optimierung unter Unsicherheit — Monte-Carlo, Stochastik, Robustheit“
9.1 — Fluch des Durchschnitts. Weil sich Verzögerungen fortpflanzen und die Verteilung rechtsschief ist: Ein Gewerk, das früher fertig wird, beschleunigt selten den Gesamtablauf (der nächste Schritt ist noch nicht bereit), ein verspätetes verzögert alles. Weitere Beispiele: Wartezeiten in Warteschlangen (nichtlinear in der Auslastung), Projektkosten mit Nachträgen.
-
9.2 — Ansatz wählen. (a) robust (Ausfall ist existenziell, Wahrscheinlichkeiten unzuverlässig). (b) stochastisch (Verteilung bekannt). (c) robust bzw. Extremwertstatistik (seltene Ereignisse, katastrophale Folgen). (d) stochastisch (Szenarien mit Wahrscheinlichkeiten liegen vor).
+
9.2 — Ansatz wählen. (a) robust (Ausfall ist existenziell, Wahrscheinlichkeiten unzuverlässig). (b) stochastisch (Verteilung bekannt). (c) robust bzw. Extremwertstatistik (seltene Ereignisse, katastrophale Folgen). (d) stochastisch (Szenarien mit Wahrscheinlichkeiten liegen vor). (e) Chance Constraint — die Zusage ist bereits als Quote formuliert („an 99 % aller Wintertage“). Robust wäre hier zu teuer (es gibt immer einen kälteren Tag), der Erwartungswert zu schwach (er sagt nichts über die Zusage).
9.3 — Zweistufig rechnen. (a) Mit Spot = 60: Ableitung bei x \in (100,250): 40 - 60\cdot0{,}5 + 5\cdot0{,}5 = 40-30+2{,}5 = +12{,}5 > 0 → Kapazität senken. Bei x < 100: 40 - 60 = -20 < 0 → erhöhen. Optimum daher x^* = 100. (b) Billige Nachbesserung macht Vorhalten unattraktiv. (c) Bei x = 225 optimal müsste die Ableitung dort das Vorzeichen wechseln — das ist bei diskreten Szenarien nur zufällig der Fall; der Mittelwert ist nur bei symmetrischen Kosten und stetiger Verteilung optimal.
9.4 — EVPI interpretieren. Selbst eine perfekte Prognose wäre nur 8 400 € pro Jahr wert. Eine Lösung für 15 000 € kann sich also niemals rechnen — unabhängig von ihrer Güte. Argument: „Der theoretische Maximalnutzen liegt unter dem Preis.“
9.5 — Monte-Carlo erweitern. (a) Servicelevel 95 %: Kapazität = 95 %-Quantil des Bedarfs (ca. 485). Die Zusatzkosten gegenüber dem Kostenoptimum (180) betragen ein Vielfaches — Servicelevel ist teuer, und genau diese Zahl braucht die Geschäftsleitung für die Entscheidung. (b) cvar = kosten[kosten >= np.quantile(kosten, 0.95)].mean(). (c) Erwartetes Bild: Bei kleiner Kapazität ist die Kostenverteilung stark rechtsschief (seltene, teure Notzukäufe); bei großer Kapazität schmal und nach rechts verschoben.
-
9.6 — Budgeted Uncertainty.
+
9.6 — Den Preis der Zusage selbst bestimmen. (a) Die Kosten je Prozentpunkt steigen weiter steil an; der Mix verschiebt sich dabei fast nur noch zugunsten des Gaskraftwerks, weil Biomasse längst an ihrer Ausbaugrenze liegt. Wind und Sonne bleiben bei einem kleinen Rest — sie senken über ihre Gegenläufigkeit die Norm, tragen zur gesicherten Leistung aber kaum bei. (b) Der Unterschied ist nicht numerisch, sondern inhaltlich. Bei 200 MW Biomasse liefert selbst der Vollausbau in der Kältewelle nur rund 440 MW — 7,5 % der Szenarien sind schlicht nicht bedienbar, egal wie viel Geld man ausgibt. Das Szenariomodell sieht diese Fälle in den Daten und meldet korrekt infeasible, sobald die Zusage über 92 % steigt. Das analytische Modell kennt sie nicht: Für eine Normalverteilung ist jede Zusage unter 100 % erfüllbar, wenn man nur genug Streuung wegkauft. Es meldet optimal — und der Plan hält gemessen 87,4 %. Glauben sollte man dem Szenariomodell: Ein infeasible ist hier die richtige Antwort. Es sagt, dass die Zusage nicht am Budget scheitert, sondern am Kraftwerkspark, und dass nicht mehr Geld hilft, sondern nur eine andere Anlage oder eine kleinere Zusage. (c) Sinngemäß: „99,99 % kosten nicht 5 % mehr als 99 %, sondern ein Vielfaches — der letzte Prozentpunkt ist bereits 4,5-mal so teuer wie der erste. Sagen Sie mir, was ein Ausfalltag das Unternehmen kostet, dann rechne ich aus, welche Quote sich lohnt. Und die Zusage gilt nur für die Wetterlagen, die wir modelliert haben — die Kältewelle gehört hinein.“
\Gamma = 0 entspricht dem nominalen Fall, \Gamma = n dem vollen Worst Case. Dazwischen steuert \Gamma die Vorsicht stufenlos — der praktisch nützlichste Bereich liegt meist bei \Gamma \approx \sqrt{n}.
Die Differenz zwischen Kosten unter Unsicherheit und Kosten bei perfektem Wissen — eine Obergrenze für den Wert jeder Prognoseverbesserung.
Weil sie nur eine Menge möglicher Werte braucht, nicht deren Verteilung — sie optimiert gegen das schlechteste Element dieser Menge.
Für einfache Mengen (Box, Ellipsoid) lässt sich das innere Maximum geschlossen ausrechnen und wird zu einem Abzugsterm; das Gesamtproblem bleibt konvex.
+
Weil die Streuung der Summe \mathbf{a}^\top\mathbf{x} nicht die Summe der Streuungen ist: \sqrt{\mathbf{x}^\top\boldsymbol{\Sigma}\mathbf{x}} enthält die Kovarianzen. Ein fester Zuschlag je Variable wäre linear und würde deshalb übersehen, dass sich gegenläufige Größen teilweise aufheben — die Norm belohnt Mischung, ein linearer Aufschlag nicht.
+
Weil die Zusage nur so gut ist wie die unterstellte Verteilung. Die Normalverteilung kennt keine Ereignisse, in denen alle Quellen gleichzeitig ausfallen; ihre schwachen Korrelationen unterschätzen genau den Fall, der die Zusage bricht. Die szenariobasierte Formulierung hat diese Fälle in den Daten und trifft die Quote — erkauft mit Mehrkosten und mit dem Risiko, sich an die verwendete Stichprobe anzupassen.
A.13 Lösungen zu Kapitel „Dynamische Programmierung — Die Bellman-Gleichung und Order-Execution“
diff --git a/OR_HTML_04/assets/programme.js b/OR_HTML_04/assets/programme.js
index 0bb6ed2..5c39e70 100644
--- a/OR_HTML_04/assets/programme.js
+++ b/OR_HTML_04/assets/programme.js
@@ -1 +1 @@
-window.OR_PROGRAMME = ["Backtest_Fallen.py", "Bausteine_Vorlage.py", "Bellman_Minimalbeispiel.py", "Benchmark_Skalierung.py", "Betriebsueberwachung.py", "Big_M_Falle.py", "Bot_Allokation.py", "Brute_Force_Vergleich.py", "CP_SAT_Statusfaelle.py", "CP_SAT_Vertretungssystem.py", "CVaR_Portfolio.py", "Data_Snooping.py", "Diversifikation_Demo.py", "Dualitaet_Nachweis.py", "Ein_System_Vier_Ansaetze.py", "Entropie_Maximierte_Allokation.py", "Erklaerbarkeit.py", "Excel_Bruecke.py", "Finanzdaten_Ledoit_Wolf.py", "Fluch_des_Durchschnitts.py", "Infeasibility_Diagnose.py", "Installationstest.py", "JobShop_Intervalle.py", "KKT_Nachweis.py", "Konfliktsuche.py", "Konvexitaet_Demo.py", "Kovarianz_Falle.py", "Kraftwerkseinsatz.py", "Large_Neighborhood_Search.py", "Lokale_Optima_Multistart.py", "MILP_Portfolio_Fixgebuehren.py", "Markowitz_CVXPY.py", "Matrixform.py", "Mehrperiodige_Order_Execution.py", "Mehrziel_Pareto.py", "Metaheuristik_vs_Exakt.py", "Min_Cost_Flow.py", "Modellierungsschichten.py", "Monte_Carlo.py", "Mutationstest.py", "Optimierungsdienst.py", "Predict_then_Optimize.py", "Propagation_Demo.py", "QP_Grundlagen.py", "QuantitativeTradingEngine.py", "Renditen_Vergleich.py", "Renditeschaetzung_Falle.py", "Robuste_Optimierung.py", "Rucksack.py", "Runden_Gegenbeispiel.py", "Schaetzrauschen_Demo.py", "Sensitivitaetsanalyse.py", "Simplex_Tableau_LP.py", "Simulated_Annealing.py", "Skalierung_Kondition.py", "Solver_Wahl.py", "Solverstatus_und_Gap.py", "Solverwechsel_CPSAT_HiGHS.py", "Spaltengenerierung.py", "Stochastische_Optimierung.py", "Strafgewichte.py", "Strukturbruecke.py", "Toleranzen_und_Entartung.py", "VRP_Flotten_Routing.py", "VRP_Kapazitaetsfalle.py", "VaR_CVaR_Demo.py", "Vektorisierte_Modellgenerierung.py", "Visualisierung_Loesungsraum.py", "Vom_Wunsch_zum_Modell.py", "Warmstart_Effekt.py", "Zuordnung_Ungarisch.py", "or_kern.py", "test_or_kern.py"];
+window.OR_PROGRAMME = ["Backtest_Fallen.py", "Bausteine_Vorlage.py", "Bellman_Minimalbeispiel.py", "Benchmark_Skalierung.py", "Betriebsueberwachung.py", "Big_M_Falle.py", "Bot_Allokation.py", "Brute_Force_Vergleich.py", "CP_SAT_Statusfaelle.py", "CP_SAT_Vertretungssystem.py", "CVaR_Portfolio.py", "Chance_Constraints.py", "Data_Snooping.py", "Diversifikation_Demo.py", "Dualitaet_Nachweis.py", "Ein_System_Vier_Ansaetze.py", "Entropie_Maximierte_Allokation.py", "Erklaerbarkeit.py", "Excel_Bruecke.py", "Finanzdaten_Ledoit_Wolf.py", "Fluch_des_Durchschnitts.py", "Infeasibility_Diagnose.py", "Installationstest.py", "JobShop_Intervalle.py", "KKT_Nachweis.py", "Konfliktsuche.py", "Konvexitaet_Demo.py", "Kovarianz_Falle.py", "Kraftwerkseinsatz.py", "Large_Neighborhood_Search.py", "Lokale_Optima_Multistart.py", "MILP_Portfolio_Fixgebuehren.py", "Markowitz_CVXPY.py", "Matrixform.py", "Mehrperiodige_Order_Execution.py", "Mehrziel_Pareto.py", "Metaheuristik_vs_Exakt.py", "Min_Cost_Flow.py", "Modellierungsschichten.py", "Monte_Carlo.py", "Mutationstest.py", "Optimierungsdienst.py", "Predict_then_Optimize.py", "Propagation_Demo.py", "QP_Grundlagen.py", "QuantitativeTradingEngine.py", "Renditen_Vergleich.py", "Renditeschaetzung_Falle.py", "Robuste_Optimierung.py", "Rucksack.py", "Runden_Gegenbeispiel.py", "Schaetzrauschen_Demo.py", "Sensitivitaetsanalyse.py", "Simplex_Tableau_LP.py", "Simulated_Annealing.py", "Skalierung_Kondition.py", "Solver_Wahl.py", "Solverstatus_und_Gap.py", "Solverwechsel_CPSAT_HiGHS.py", "Spaltengenerierung.py", "Stochastische_Optimierung.py", "Strafgewichte.py", "Strukturbruecke.py", "Toleranzen_und_Entartung.py", "VRP_Flotten_Routing.py", "VRP_Kapazitaetsfalle.py", "VaR_CVaR_Demo.py", "Vektorisierte_Modellgenerierung.py", "Visualisierung_Loesungsraum.py", "Vom_Wunsch_zum_Modell.py", "Warmstart_Effekt.py", "Zuordnung_Ungarisch.py", "or_kern.py", "test_or_kern.py"];
diff --git a/OR_HTML_04/assets/search-index.js b/OR_HTML_04/assets/search-index.js
index d2bd3d0..8325380 100644
--- a/OR_HTML_04/assets/search-index.js
+++ b/OR_HTML_04/assets/search-index.js
@@ -1 +1 @@
-window.OR_SEARCH_INDEX = [{"seite":"vorwort.html","titel":"Vorwort & Lesehilfe","anker":null,"text":""},{"seite":"notation.html","titel":"Notation & Abkürzungen","anker":null,"text":""},{"seite":"notation.html","titel":"1. Die Symbole der Optimierung","anker":"die-symbole-der-optimierung","text":""},{"seite":"notation.html","titel":"1.1 Grundgrößen jedes Modells","anker":"grundgrößen-jedes-modells","text":""},{"seite":"notation.html","titel":"1.2 Mengen und Zahlbereiche","anker":"mengen-und-zahlbereiche","text":""},{"seite":"notation.html","titel":"1.3 Operatoren und Schreibweisen","anker":"operatoren-und-schreibweisen","text":""},{"seite":"notation.html","titel":"1.4 Symbole der Finanzkapitel (Teil IV)","anker":"symbole-der-finanzkapitel-teil-iv","text":""},{"seite":"notation.html","titel":"1.5 Symbole der Verfahrenskapitel (Teil II und III)","anker":"symbole-der-verfahrenskapitel-teil-ii-und-iii","text":"Diese Zeichen kommen erst in den Kapiteln vor, die über das klassische LP hinausgehen."},{"seite":"notation.html","titel":"2. Abkürzungen — vollständig ausgeschrieben","anker":"abkürzungen-vollständig-ausgeschrieben","text":""},{"seite":"notation.html","titel":"2.1 Problemklassen und Verfahren","anker":"problemklassen-und-verfahren","text":""},{"seite":"notation.html","titel":"2.2 Probleme mit Eigennamen","anker":"probleme-mit-eigennamen","text":""},{"seite":"notation.html","titel":"2.3 Finanz- und Risikokennzahlen","anker":"finanz--und-risikokennzahlen","text":""},{"seite":"notation.html","titel":"2.4 Software, Solver und Schnittstellen","anker":"software-solver-und-schnittstellen","text":""},{"seite":"notation.html","titel":"2.5 Begriffe aus dem Solver-Alltag","anker":"begriffe-aus-dem-solver-alltag","text":""},{"seite":"notation.html","titel":"3. Konventionen in diesem Buch","anker":"konventionen-in-diesem-buch","text":"Vektoren sind grundsätzlich Spaltenvektoren und werden fett gesetzt: \\mathbf{x}. Ein transponierter Vektor \\mathbf{x}^\\top ist eine Zeile. Skalare bleiben mager: n, Z."},{"seite":"einfuehrung.html","titel":"Kapitel 1: Einführung in Operations Research — Vom Ursprung zur mathematischen Entscheidungsfindung","anker":null,"text":""},{"seite":"einfuehrung.html","titel":"1.1 In 5 Minuten gelöst","anker":"sec:einfuehrung-schnellstart","text":"Bevor wir über Theorie sprechen, lösen Sie ein echtes Problem. Kopieren Sie den folgenden Block, führen Sie ihn aus, sehen Sie das Ergebnis — die Erklärung kommt danach."},{"seite":"einfuehrung.html","titel":"1.2 Lernziele","anker":"sec:einfuehrung-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"einfuehrung.html","titel":"1.3 Was ist Operations Research wirklich?","anker":"sec:einfuehrung-was-ist-operations-research-wirklich","text":"Im Alltag lösen wir Probleme intuitiv: durch Ausprobieren, Daumenregeln, Erfahrung. Das funktioniert erstaunlich gut — bis eine bestimmte Schwelle überschritten wird. Dann bricht d"},{"seite":"einfuehrung.html","titel":"Die drei Stufen der Analytik","anker":"die-drei-stufen-der-analytik","text":"OR beantwortet nicht die beschreibende Frage „Was ist passiert?“ und nicht die vorhersagende Frage „Was wird passieren?“, sondern die vorschreibende (präskriptive) Kernfrage:"},{"seite":"einfuehrung.html","titel":"1.4 Warum Ausprobieren scheitert — mit eigener Rechnung","anker":"sec:einfuehrung-warum-ausprobieren-scheitert-mit-eigener-rechnung","text":"Die Behauptung „vollständiges Durchprobieren ist unmöglich“ wird oft aufgestellt und selten belegt. Rechnen wir sie einmal selbst nach — das Ergebnis prägt sich besser ein als jede"},{"seite":"einfuehrung.html","titel":"1.5 Historischer Kontext und Evolution","anker":"sec:einfuehrung-historischer-kontext-und-evolution","text":"Der Begriff entstand im Vorfeld des Zweiten Weltkriegs in Großbritannien. Militärische Führungsstäbe standen vor neuartigen logistischen und strategischen Fragen: Wie platziert man"},{"seite":"einfuehrung.html","titel":"1.6 Die vier universellen Bausteine jedes OR-Problems","anker":"sec:einfuehrung-die-vier-universellen-bausteine-jedes-or","text":"Dies ist der wichtigste Abschnitt des Kapitels. Jedes Optimierungsproblem der Welt lässt sich — unabhängig von der Branche — auf vier Elemente reduzieren. Wenn Sie später vor einem"},{"seite":"einfuehrung.html","titel":"Baustein 1 — Entscheidungsvariablen (x)","anker":"baustein-1-entscheidungsvariablen-x","text":"Die Stellschrauben des Modells: Werte, die der Algorithmus aktiv festlegen darf."},{"seite":"einfuehrung.html","titel":"Baustein 2 — Parameter / Eingabedaten (c, A, b)","anker":"baustein-2-parameter-eingabedaten-c-a-b","text":"Die unveränderlichen Fakten der Realität: historische Renditen, Maschinenstundensätze, Lagerkapazitäten, Steuerquoten, Entfernungen, Qualifikationen."},{"seite":"einfuehrung.html","titel":"Baustein 3 — Zielfunktion (f(x))","anker":"baustein-3-zielfunktion-fx","text":"Ein mathematischer Ausdruck, der die Güte einer Lösung zu einer einzigen Zahl verdichtet:"},{"seite":"einfuehrung.html","titel":"Baustein 4 — Nebenbedingungen (Constraints)","anker":"baustein-4-nebenbedingungen-constraints","text":"Regeln, die den Raum der erlaubten Lösungen einschränken:"},{"seite":"einfuehrung.html","titel":"Die vier Bausteine als eine Formel","anker":"die-vier-bausteine-als-eine-formel","text":"Setzt man die vier Bausteine zusammen, entsteht die Standardform, in der Ihnen jedes Optimierungsmodell dieses Buches begegnen wird. Sie sieht auf den ersten Blick sperrig aus — de"},{"seite":"einfuehrung.html","titel":"Die Bausteine in der Praxis: eine Vorlage","anker":"die-bausteine-in-der-praxis-eine-vorlage","text":"Damit die Zerlegung zur Gewohnheit wird, hier eine Vorlage, die Sie bei jedem neuen Problem ausfüllen können — vor der ersten Codezeile."},{"seite":"einfuehrung.html","titel":"1.7 Erstes Python-Vollbeispiel: Ressourcenallokation im Rechenzentrum","anker":"sec:einfuehrung-erstes-python-vollbeispiel-ressourcenallokation-im-rechenzentrum","text":""},{"seite":"einfuehrung.html","titel":"Szenario","anker":"szenario","text":"Ein quantitatives Handelshaus betreibt Serverkapazitäten für zwei Algorithmentypen:"},{"seite":"einfuehrung.html","titel":"Mathematische Formulierung","anker":"mathematische-formulierung","text":"Entscheidungsvariablen:"},{"seite":"einfuehrung.html","titel":"Umsetzung mit Google OR-Tools","anker":"umsetzung-mit-google-or-tools","text":""},{"seite":"einfuehrung.html","titel":"1.8 Von Excel zu Python: Ihre Daten liegen schon da","anker":"sec:einfuehrung-excel-bruecke","text":"In den allermeisten Betrieben liegen die Zahlen, die ein Optimierungsmodell braucht, nicht in einer Datenbank, sondern in einer Tabellenkalkulation. Und viele Leserinnen und Leser "},{"seite":"einfuehrung.html","titel":"1.9 Übungsaufgaben","anker":"sec:einfuehrung-uebungsaufgaben","text":""},{"seite":"einfuehrung.html","titel":"1.10 Finde den Denkfehler","anker":"sec:einfuehrung-denkfehler","text":"Ein Programm, das abstürzt, ist harmlos — Sie sehen sofort, dass etwas nicht stimmt. Gefährlich sind Programme, die fehlerfrei durchlaufen, einen Status OPTIMAL melden und eine Zah"},{"seite":"einfuehrung.html","titel":"1.11 Micro-Quiz","anker":"sec:einfuehrung-quiz","text":""},{"seite":"einfuehrung.html","titel":"1.12 Selbsttest","anker":"sec:einfuehrung-selbsttest","text":""},{"seite":"einfuehrung.html","titel":"1.13 Zusammenfassung","anker":"sec:einfuehrung-zusammenfassung","text":""},{"seite":"fundament.html","titel":"Kapitel 2: Das mathematische Fundament — Vektoren, Matrizen, Konvexität","anker":null,"text":""},{"seite":"fundament.html","titel":"2.1 In 5 Minuten gelöst","anker":"sec:fundament-schnellstart","text":"Dieses Kapitel handelt von Geometrie. Bevor wir Vektoren und Matrizen einführen, sehen Sie die zentrale Einsicht in Aktion — ganz ohne Solver, mit acht Zeilen NumPy."},{"seite":"fundament.html","titel":"2.2 Lernziele","anker":"sec:fundament-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"fundament.html","titel":"2.3 Warum überhaupt Vektoren und Matrizen?","anker":"sec:fundament-warum-ueberhaupt-vektoren-und-matrizen","text":"In Kapitel 1 hatten wir zwei Variablen und drei Nebenbedingungen. Das ließ sich bequem ausschreiben. Reale Modelle haben hunderte bis hunderttausende Variablen — dort wäre Ausschre"},{"seite":"fundament.html","titel":"Die Bausteine","anker":"die-bausteine","text":"Sei n die Anzahl der Entscheidungsvariablen und m die Anzahl der Nebenbedingungen."},{"seite":"fundament.html","titel":"Die kanonische Standardform","anker":"die-kanonische-standardform","text":"Damit lässt sich jedes lineare Programm in drei Zeilen schreiben:"},{"seite":"fundament.html","titel":"Dasselbe in Python","anker":"dasselbe-in-python","text":""},{"seite":"fundament.html","titel":"2.4 Der zulässige Lösungsraum und das Polyeder","anker":"sec:fundament-der-zulaessige-loesungsraum-und-das-polyeder","text":"Jede lineare Ungleichung a_{i1}x_1 + a_{i2}x_2 \\le b_i definiert geometrisch eine Halbebene (im \\mathbb{R}^2) bzw. einen Halbraum (im \\mathbb{R}^n): Die zugehörige Gleichung ist ei"},{"seite":"fundament.html","titel":"Der Fundamentalsatz der linearen Optimierung","anker":"der-fundamentalsatz-der-linearen-optimierung","text":""},{"seite":"fundament.html","titel":"2.5 Konvexität: die Grenze zwischen leicht und schwer","anker":"sec:fundament-konvexitaet-die-grenze-zwischen-leicht-und","text":"Hier kommt die vielleicht wichtigste Einsicht des ganzen Buches. In der Optimierungstheorie verläuft die Trennlinie zwischen „zuverlässig lösbar“ und „im Allgemeinen hoffnungslos“ "},{"seite":"fundament.html","titel":"Konvexe Menge","anker":"konvexe-menge","text":"Eine Menge \\mathcal{C} \\subseteq \\mathbb{R}^n heißt konvex, wenn für alle Punkte \\mathbf{x}, \\mathbf{y} \\in \\mathcal{C} und jedes \\theta \\in [0,1] gilt:"},{"seite":"fundament.html","titel":"Konvexe Funktion","anker":"konvexe-funktion","text":"Eine Funktion f: \\mathcal{C} \\to \\mathbb{R} heißt konvex, wenn für alle \\mathbf{x}, \\mathbf{y} \\in \\mathcal{C} und \\theta \\in [0,1] gilt:"},{"seite":"fundament.html","titel":"Der zentrale Satz","anker":"der-zentrale-satz","text":""},{"seite":"fundament.html","titel":"Was ist konvex, was nicht?","anker":"was-ist-konvex-was-nicht","text":""},{"seite":"fundament.html","titel":"Konvexität sichtbar machen","anker":"konvexität-sichtbar-machen","text":""},{"seite":"fundament.html","titel":"2.6 Geometrische Visualisierung des Lösungsraums","anker":"sec:fundament-geometrische-visualisierung-des-loesungsraums","text":"Wir zeichnen nun den Lösungsraum aus Kapitel 1 und lesen das Optimum ab. Zweidimensionale Bilder sind der schnellste Weg, Intuition für höhere Dimensionen aufzubauen — auch wenn ma"},{"seite":"fundament.html","titel":"2.7 Wenn die Zahlen nicht zusammenpassen: Kondition und Skalierung","anker":"sec:fundament-kondition","text":"Bis hierher war die lineare Algebra exakt. Ein Rechner rechnet aber nicht exakt, sondern mit rund 16 signifikanten Stellen — und Ihre Betriebsdaten sind ohnehin nur auf wenige Stel"},{"seite":"fundament.html","titel":"Die geometrische Vorstellung: zwei fast parallele Linien","anker":"die-geometrische-vorstellung-zwei-fast-parallele-linien","text":"Zwei Nebenbedingungen legen im Zweidimensionalen einen Schnittpunkt fest. Stehen die zugehörigen Geraden kreuzweise aufeinander, ist der Schnittpunkt robust: Verschiebt man eine Ge"},{"seite":"fundament.html","titel":"Woher schlechte Kondition im Alltag kommt","anker":"woher-schlechte-kondition-im-alltag-kommt","text":"Fast nie aus exotischer Mathematik — fast immer aus Einheiten:"},{"seite":"fundament.html","titel":"Ruiz-Equilibrierung: das Modell gesundrechnen","anker":"ruiz-equilibrierung-das-modell-gesundrechnen","text":"Die Gegenmaßnahme ist erstaunlich schlicht. Man multipliziert jede Zeile und jede Spalte der Matrix mit einem Faktor, sodass die Beträge überall in derselben Größenordnung landen:"},{"seite":"fundament.html","titel":"Das Experiment","anker":"das-experiment","text":"Das folgende Programm führt beides vor — die Fehlerverstärkung und ihre Behebung — und schließt mit der praktischen Konsequenz, die Sie in Kapitel 6 wieder brauchen werden: warum e"},{"seite":"fundament.html","titel":"2.8 Übungsaufgaben","anker":"sec:fundament-uebungsaufgaben","text":""},{"seite":"fundament.html","titel":"2.9 Finde den Denkfehler","anker":"sec:fundament-denkfehler","text":""},{"seite":"fundament.html","titel":"2.10 Micro-Quiz","anker":"sec:fundament-quiz","text":""},{"seite":"fundament.html","titel":"2.11 Selbsttest","anker":"sec:fundament-selbsttest","text":""},{"seite":"fundament.html","titel":"2.12 Zusammenfassung","anker":"sec:fundament-zusammenfassung","text":""},{"seite":"oekosystem.html","titel":"Kapitel 3: Das Python-Ökosystem für OR — Solver, Bindings und Modellierungsschichten","anker":null,"text":""},{"seite":"oekosystem.html","titel":"3.1 In 5 Minuten gelöst","anker":"sec:oekosystem-schnellstart","text":""},{"seite":"oekosystem.html","titel":"3.2 Lernziele","anker":"sec:oekosystem-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"oekosystem.html","titel":"3.3 Die Zwei-Schichten-Architektur","anker":"sec:oekosystem-die-zwei-schichten-architektur","text":"Im modernen Operations Research schreibt man Optimierungsalgorithmen nicht selbst. Man nutzt eine zweischichtige Architektur:"},{"seite":"oekosystem.html","titel":"3.4 Die Werkzeuge im Vergleich","anker":"sec:oekosystem-die-werkzeuge-im-vergleich","text":""},{"seite":"oekosystem.html","titel":"Die Entscheidung in drei Fragen","anker":"die-entscheidung-in-drei-fragen","text":"Statt die Tabelle auswendig zu lernen, beantworten Sie drei Fragen:"},{"seite":"oekosystem.html","titel":"3.5 Ein System — vier Programmieransätze","anker":"sec:oekosystem-ein-system-vier-programmieransaetze","text":"Nichts macht die Unterschiede so deutlich wie dasselbe Problem, viermal gelöst. Wir nehmen:"},{"seite":"oekosystem.html","titel":"Was das CSR-Format bedeutet","anker":"was-das-csr-format-bedeutet","text":"highspy erwartet die Nebenbedingungsmatrix im CSR-Format (Compressed Sparse Row, komprimierte Zeilendarstellung). Statt der vollen Matrix speichert man nur die Einträge ungleich nu"},{"seite":"oekosystem.html","titel":"3.6 Wann lohnt sich welche Ebene?","anker":"sec:oekosystem-wann-lohnt-sich-welche-ebene","text":""},{"seite":"oekosystem.html","titel":"3.7 Modellierungsschichten für große Modelle: Pyomo und Linopy","anker":"sec:oekosystem-pyomo-linopy","text":"Die vier Bibliotheken aus dem Vierfach-Vergleich decken den Alltag weitgehend ab. Sobald Modelle industrielle Größe erreichen — zehntausende Variablen, Daten aus mehreren Systemen,"},{"seite":"oekosystem.html","titel":"Pyomo: die algebraische Denkweise","anker":"pyomo-die-algebraische-denkweise","text":"Pyomo ist im deutschsprachigen Raum der De-facto-Standard für große LP- und MILP-Modelle in Energiewirtschaft, Chemie und Logistik. Sein Kennzeichen: Es denkt in Mengen und Indizes"},{"seite":"oekosystem.html","titel":"Linopy: eine Zeile, zehntausend Nebenbedingungen","anker":"linopy-eine-zeile-zehntausend-nebenbedingungen","text":"Linopy verfolgt einen anderen Ansatz: Variablen sind beschriftete Arrays (xarray), keine indizierten Einzelobjekte. Damit wird aus"},{"seite":"oekosystem.html","titel":"Die Schichten im Überblick","anker":"die-schichten-im-überblick","text":""},{"seite":"oekosystem.html","titel":"3.8 Wo die Zeit wirklich hingeht: vektorisierte Modellgenerierung","anker":"sec:oekosystem-vektorisierung","text":"Eine der hartnäckigsten Fehlannahmen in Optimierungsprojekten lautet: „Wenn es zu langsam ist, brauchen wir einen besseren Solver.“ Messen Sie erst — oft stimmt das nicht."},{"seite":"oekosystem.html","titel":"Vier Stufen an einem Transportproblem","anker":"vier-stufen-an-einem-transportproblem","text":"Wir bauen dasselbe Transportproblem (m Werke, n Kunden, m \\cdot n Variablen) auf vier Arten auf:"},{"seite":"oekosystem.html","titel":"3.9 Übungsaufgaben","anker":"sec:oekosystem-uebungsaufgaben","text":""},{"seite":"oekosystem.html","titel":"3.10 Finde den Denkfehler","anker":"sec:oekosystem-denkfehler","text":""},{"seite":"oekosystem.html","titel":"3.11 Micro-Quiz","anker":"sec:oekosystem-quiz","text":""},{"seite":"oekosystem.html","titel":"3.12 Selbsttest","anker":"sec:oekosystem-selbsttest","text":""},{"seite":"oekosystem.html","titel":"3.13 Zusammenfassung","anker":"sec:oekosystem-zusammenfassung","text":""},{"seite":"modellierung.html","titel":"Kapitel 4: Vom Management-Wunsch zum Modell","anker":null,"text":""},{"seite":"modellierung.html","titel":"4.1 In 5 Minuten gelöst","anker":"sec:modellierung-schnellstart","text":""},{"seite":"modellierung.html","titel":"4.2 Lernziele","anker":"sec:modellierung-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"modellierung.html","titel":"4.3 Ein Satz ist noch kein Modell","anker":"sec:modellierung-vier-fragen","text":"Was aus einer Besprechung mitkommt, klingt meist so:"},{"seite":"modellierung.html","titel":"4.4 Derselbe Datensatz, fünf Modelle","anker":"sec:modellierung-fuenf-modelle","text":"Das Programm dieses Kapitels arbeitet die vier Fragen an einer Auftragsannahme ab: 14 Anfragen verlangen 800 Maschinenstunden, verfügbar sind 300."},{"seite":"modellierung.html","titel":"Teil 1: Drei Ziele, drei Pläne","anker":"teil-1-drei-ziele-drei-pläne","text":""},{"seite":"modellierung.html","titel":"Teil 2: Ein Satz, drei Lesarten","anker":"teil-2-ein-satz-drei-lesarten","text":"„Stammkunden dürfen wir nicht verlieren.” Jeder im Raum nickt. Niemand merkt, dass drei verschiedene Modelle gemeint sein können:"},{"seite":"modellierung.html","titel":"Teil 3: Hart oder weich","anker":"teil-3-hart-oder-weich","text":"Dieselbe Regel als Strafkosten je abgelehntem Stammkundenauftrag:"},{"seite":"modellierung.html","titel":"Teil 4: Ein Quartal später","anker":"teil-4-ein-quartal-später","text":"Die harte Lesart A wurde eingebaut, alles lief drei Monate lang. Dann fragt Weber einen Auftrag über 320 Maschinenstunden an — mehr, als der ganze Zeitraum hergibt."},{"seite":"modellierung.html","titel":"4.5 Der Gesprächsleitfaden","anker":"sec:modellierung-leitfaden","text":"Was aus den vier Teilen als Handwerkszeug bleibt — Fragen, die man in der ersten Besprechung stellt, bevor eine Zeile Code entsteht:"},{"seite":"modellierung.html","titel":"4.6 Übungsaufgaben","anker":"sec:modellierung-uebungsaufgaben","text":""},{"seite":"modellierung.html","titel":"4.7 Finde den Denkfehler","anker":"sec:modellierung-denkfehler","text":""},{"seite":"modellierung.html","titel":"4.8 Micro-Quiz","anker":"sec:modellierung-quiz","text":""},{"seite":"modellierung.html","titel":"4.9 Selbsttest","anker":"sec:modellierung-selbsttest","text":""},{"seite":"modellierung.html","titel":"4.10 Zusammenfassung","anker":"sec:modellierung-zusammenfassung","text":""},{"seite":"lp.html","titel":"Kapitel 5: Lineare Programmierung — Simplex, Dualität und Schattenpreise","anker":null,"text":""},{"seite":"lp.html","titel":"5.1 In 5 Minuten gelöst","anker":"sec:lp-schnellstart","text":"Bisher hat ein Solver Ihnen gesagt, was Sie tun sollen. Jetzt sagt er Ihnen zusätzlich, was eine zusätzliche Stunde wert wäre — und das ist die Zahl, für die Sie im Betrieb gehört "},{"seite":"lp.html","titel":"5.2 Lernziele","anker":"sec:lp-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"lp.html","titel":"5.3 Die Standardform und Schlupfvariablen","anker":"sec:lp-die-standardform-und-schlupfvariablen","text":"Lineare Programme zeichnen sich dadurch aus, dass Zielfunktion und alle Nebenbedingungen strikt linear sind — keine Produkte von Variablen, keine Quadrate, keine Logarithmen."},{"seite":"lp.html","titel":"5.4 Der Simplex-Algorithmus Schritt für Schritt","anker":"sec:lp-der-simplex-algorithmus-schritt-fuer-schritt","text":"Der von George Dantzig 1947 formulierte Simplex-Algorithmus nutzt den Fundamentalsatz aus Kapitel 2: Das Optimum liegt in einer Ecke. Statt alle Ecken aufzuzählen, wandert er von E"},{"seite":"lp.html","titel":"Der Ablauf","anker":"der-ablauf","text":""},{"seite":"lp.html","titel":"5.5 Das Simplex-Tableau in Python","anker":"sec:lp-das-simplex-tableau-in-python","text":""},{"seite":"lp.html","titel":"5.6 Dualität und Schattenpreise","anker":"sec:lp-dualitaet-und-schattenpreise","text":"Zu jedem linearen Optimierungsproblem (dem primalen Problem) existiert ein spiegelbildliches Zwillingsproblem: das duale Problem."},{"seite":"lp.html","titel":"Der starke Dualitätssatz","anker":"der-starke-dualitätssatz","text":""},{"seite":"lp.html","titel":"Der Schattenpreis als Ableitung","anker":"der-schattenpreis-als-ableitung","text":"Der Dualwert y_i^* ist die Grenzproduktivität der Ressource i:"},{"seite":"lp.html","titel":"5.7 Die Vorzeichenfalle bei Schattenpreisen","anker":"sec:lp-die-vorzeichenfalle-bei-schattenpreisen","text":"Diese Falle sollten Sie sich merken, weil sie in jedem Projekt wieder auftaucht."},{"seite":"lp.html","titel":"5.8 Praxisfall: Sensitivitätsanalyse mit korrekten Schattenpreisen","anker":"sec:lp-praxisfall-sensitivitaetsanalyse-mit-korrekten-schattenpreisen","text":"Szenario. Ein Fertigungsbetrieb stellt drei Produkte her und will wissen:"},{"seite":"lp.html","titel":"5.9 Wann Schattenpreise lügen: Entartung und Toleranzen","anker":"sec:lp-entartung","text":"Der vorige Abschnitt hat gezeigt, wie wertvoll Schattenpreise sind. Dieser zeigt die zwei Fälle, in denen sie eine Entscheidung nicht tragen — und wie man beide erkennt, bevor jema"},{"seite":"lp.html","titel":"Fall 1: Entartung","anker":"fall-1-entartung","text":"Erinnern Sie sich an Abschnitt 2.4: In zwei Dimensionen legen zwei sich schneidende Geraden eine Ecke fest, in drei Dimensionen drei Ebenen, allgemein n Restriktionen bei n Variabl"},{"seite":"lp.html","titel":"Fall 2: Toleranzen","anker":"fall-2-toleranzen","text":"Der zweite Fall ist unscheinbarer und deshalb häufiger. Wie stellt man fest, ob eine Ressource ausgelastet ist? Naheliegend wäre if schlupf == 0. Genau das ist falsch."},{"seite":"lp.html","titel":"Die ehrliche Auskunft: Schattenpreis-Spannen","anker":"die-ehrliche-auskunft-schattenpreis-spannen","text":"Was tut man also, wenn das Modell entartet ist? Man meldet keinen Einzelwert, sondern die Spanne. Die Menge aller optimalen Dualvektoren ist selbst ein Polyeder:"},{"seite":"lp.html","titel":"5.10 Übungsaufgaben","anker":"sec:lp-uebungsaufgaben","text":""},{"seite":"lp.html","titel":"5.11 Finde den Denkfehler","anker":"sec:lp-denkfehler","text":""},{"seite":"lp.html","titel":"5.12 Micro-Quiz","anker":"sec:lp-quiz","text":""},{"seite":"lp.html","titel":"5.13 Selbsttest","anker":"sec:lp-selbsttest","text":""},{"seite":"lp.html","titel":"5.14 Zusammenfassung","anker":"sec:lp-zusammenfassung","text":""},{"seite":"milp.html","titel":"Kapitel 6: Gemischt-ganzzahlige Optimierung — Diskrete Entscheidungen und Branch-and-Bound","anker":null,"text":""},{"seite":"milp.html","titel":"6.1 In 5 Minuten gelöst","anker":"sec:milp-schnellstart","text":""},{"seite":"milp.html","titel":"6.2 Lernziele","anker":"sec:milp-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"milp.html","titel":"6.3 Warum Runden fundamental scheitert","anker":"sec:milp-warum-runden-fundamental-scheitert","text":"In der realen Welt sind viele Entscheidungen nicht teilbar. Man kann nicht 0,47 Flugzeuge kaufen, keine halbe Lagerhalle bauen, keinen Mitarbeiter zu 38 % einstellen. Noch wichtige"},{"seite":"milp.html","titel":"6.4 Branch-and-Bound","anker":"sec:milp-branch-and-bound","text":"MILP-Probleme sind NP-schwer. Der Standardansatz moderner Solver (HiGHS, SCIP, Gurobi) heißt Branch-and-Cut — Branch-and-Bound plus Schnittebenen."},{"seite":"milp.html","titel":"Die drei Phasen","anker":"die-drei-phasen","text":""},{"seite":"milp.html","titel":"6.5 Modellierungstricks: Big-M und logische Bedingungen","anker":"sec:milp-modellierungstricks-big-m-und-logische-bedingungen","text":"Hier kommt das Handwerkszeug, das MILP so mächtig macht: Man übersetzt Geschäftsregeln in lineare Ungleichungen — mithilfe binärer Hilfsvariablen y \\in \\{0,1\\} und einer hinreichen"},{"seite":"milp.html","titel":"Muster 1 — Fixkosten / Aktivierungsschalter","anker":"muster-1-fixkosten-aktivierungsschalter","text":"Regel: Wird Maschine j genutzt (x_j > 0), fallen Rüstkosten F_j an."},{"seite":"milp.html","titel":"Muster 2 — Entweder-Oder (disjunctive constraints)","anker":"muster-2-entweder-oder-disjunctive-constraints","text":"Regel: Es muss entweder f(\\mathbf{x}) \\le b_1 oder g(\\mathbf{x}) \\le b_2 gelten."},{"seite":"milp.html","titel":"Muster 3 — Wenn-Dann (Implikation)","anker":"muster-3-wenn-dann-implikation","text":"Regel: Wenn y_1 = 1, dann muss auch y_2 = 1 sein."},{"seite":"milp.html","titel":"Muster 4 — Kardinalität („höchstens K aus N“)","anker":"muster-4-kardinalität-höchstens-k-aus-n","text":"\\sum_{j=1}^N y_j \\le K"},{"seite":"milp.html","titel":"Muster 5 — Semikontinuierlich („entweder 0 oder mindestens L“)","anker":"muster-5-semikontinuierlich-entweder-0-oder-mindestens-l","text":"Regel: Eine Position ist entweder gar nicht besetzt oder mit mindestens L Euro."},{"seite":"milp.html","titel":"6.6 Beispiel: Das Rucksackproblem","anker":"sec:milp-beispiel-das-rucksackproblem","text":"Bevor wir zum Portfolio kommen, das klassische Einstiegsproblem — kurz, verständlich und überall wiederzuerkennen."},{"seite":"milp.html","titel":"6.7 Praxisfall: Portfolio mit Ordergebühren und Kardinalitätsgrenze","anker":"sec:milp-praxisfall-portfolio-mit-ordergebuehren-und-kardinalitaetsgrenze","text":"Szenario. Ein Investor verteilt 100\\,000\\,\\text{€} auf sechs Anlageklassen."},{"seite":"milp.html","titel":"6.8 Wenn der Solver nicht fertig wird: Gap, Zeitlimit und Warm-Start","anker":"sec:milp-gap","text":"Bei einem LP gibt es zwei Ausgänge: eine optimale Lösung oder eine klare Absage. Bei einem MILP gibt es einen dritten, und im Betrieb ist er der häufigste:"},{"seite":"milp.html","titel":"Die zwei Zahlen, die zählen","anker":"die-zwei-zahlen-die-zählen","text":"Branch-and-Bound führt zu jedem Zeitpunkt zwei Werte mit:"},{"seite":"milp.html","titel":"Alle Statusfälle behandeln","anker":"alle-statusfälle-behandeln","text":"Der zweite Teil der Professionalität ist unspektakulär, aber entscheidend: jeden Rückgabewert auswerten, nicht nur OPTIMAL."},{"seite":"milp.html","titel":"Warm-Starts: was sie können und was nicht","anker":"warm-starts-was-sie-können-und-was-nicht","text":"Ein Warm-Start gibt dem Solver eine bekannte Lösung als Startpunkt mit. Die Idee ist verlockend: Wer schon eine brauchbare Lösung hat, muss nicht bei null anfangen."},{"seite":"milp.html","titel":"6.9 Übungsaufgaben","anker":"sec:milp-uebungsaufgaben","text":""},{"seite":"milp.html","titel":"6.10 Finde den Denkfehler","anker":"sec:milp-denkfehler","text":"Die Warnung „M so klein wie möglich“ wird meist mit der Laufzeit begründet: schwache Relaxation, mehr Knoten. Das stimmt — und ist die harmlosere Hälfte der Wahrheit."},{"seite":"milp.html","titel":"6.11 Micro-Quiz","anker":"sec:milp-quiz","text":""},{"seite":"milp.html","titel":"6.12 Selbsttest","anker":"sec:milp-selbsttest","text":""},{"seite":"milp.html","titel":"6.13 Zusammenfassung","anker":"sec:milp-zusammenfassung","text":""},{"seite":"cpsat.html","titel":"Kapitel 7: Constraint Programming mit CP-SAT — Logik, Scheduling und Zuweisung","anker":null,"text":""},{"seite":"cpsat.html","titel":"7.1 In 5 Minuten gelöst","anker":"sec:cpsat-schnellstart","text":""},{"seite":"cpsat.html","titel":"7.2 Lernziele","anker":"sec:cpsat-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"cpsat.html","titel":"7.3 Ein anderes Denkmodell","anker":"sec:cpsat-ein-anderes-denkmodell","text":"Während LP und MILP auf algebraischen Gleichungen und Simplex-Matrizen beruhen, kommt Constraint Programming (CP) aus der künstlichen Intelligenz:"},{"seite":"cpsat.html","titel":"Was Propagation leistet","anker":"was-propagation-leistet","text":""},{"seite":"cpsat.html","titel":"7.4 Globale Constraints — die Bausteine von CP-SAT","anker":"sec:cpsat-globale-constraints-die-bausteine-von-cp","text":"CP-SAT stellt mächtige Makros bereit, die in MILP hunderte Big-M-Ungleichungen erfordern würden. Jedes davon hat einen spezialisierten, hocheffizienten Propagator."},{"seite":"cpsat.html","titel":"7.5 Praxisfall: Dynamisches Vertretungssystem","anker":"sec:cpsat-praxisfall-dynamisches-vertretungssystem","text":"Problemstellung. Eine Schule muss nach spontanen Ausfällen vier Unterrichtsstunden nachbesetzen."},{"seite":"cpsat.html","titel":"7.6 Intervallvariablen: Job-Shop-Scheduling","anker":"sec:cpsat-intervallvariablen-job-shop-scheduling","text":"Die Königsdisziplin von CP-SAT sind Zeitplanungsprobleme. Intervallvariablen sind dabei das zentrale Werkzeug."},{"seite":"cpsat.html","titel":"7.7 Die fünf Antworten von CP-SAT","anker":"sec:cpsat-statusfaelle","text":"Abschnitt 6.8 hat gezeigt, warum OPTIMAL bei ganzzahligen Problemen nicht der Normalfall ist. Bei CP-SAT kommt eine Besonderheit hinzu: Es gibt fünf verschiedene Antworten, und dre"},{"seite":"cpsat.html","titel":"7.8 Übungsaufgaben","anker":"sec:cpsat-uebungsaufgaben","text":""},{"seite":"cpsat.html","titel":"7.9 Finde den Denkfehler","anker":"sec:cpsat-denkfehler","text":""},{"seite":"cpsat.html","titel":"7.10 Micro-Quiz","anker":"sec:cpsat-quiz","text":""},{"seite":"cpsat.html","titel":"7.11 Selbsttest","anker":"sec:cpsat-selbsttest","text":""},{"seite":"cpsat.html","titel":"7.12 Zusammenfassung","anker":"sec:cpsat-zusammenfassung","text":""},{"seite":"graphen.html","titel":"Kapitel 8: Graphen, Flüsse und Touren — Min-Cost-Flow, Matching und VRP","anker":null,"text":""},{"seite":"graphen.html","titel":"8.1 In 5 Minuten gelöst","anker":"sec:graphen-schnellstart","text":""},{"seite":"graphen.html","titel":"8.2 Lernziele","anker":"sec:graphen-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"graphen.html","titel":"8.3 Graphen als Modellsprache","anker":"sec:graphen-graphen-als-modellsprache","text":"Viele reale Probleme in Logistik, Kommunikation und Finanzströmen sind keine flachen Ungleichungssysteme, sondern Graphen G = (V, E):"},{"seite":"graphen.html","titel":"Das Minimum-Cost-Flow-Problem (MCNFP)","anker":"das-minimum-cost-flow-problem-mcnfp","text":"Minimum-Cost Network Flow Problem — deutsch: kostenminimales Flussproblem. Es ist das mathematische Fundament für Transportketten, Liquiditätsrouting und Datenverteilung."},{"seite":"graphen.html","titel":"8.4 Bipartites Matching: das Zuordnungsproblem","anker":"sec:graphen-bipartites-matching-das-zuordnungsproblem","text":"Wenn N Aufgaben auf N Ressourcen eins zu eins verteilt werden — Orders auf Broker, Schichten auf Mitarbeitende, Aufträge auf Maschinen — spricht man von bipartitem Matching."},{"seite":"graphen.html","titel":"Der Satz von Birkhoff und von Neumann","anker":"der-satz-von-birkhoff-und-von-neumann","text":""},{"seite":"graphen.html","titel":"8.5 Das Vehicle Routing Problem mit Zeitfenstern","anker":"sec:graphen-das-vehicle-routing-problem-mit-zeitfenstern","text":"Das Traveling Salesperson Problem (TSP), deutsch Problem des Handlungsreisenden, fragt nach der kürzesten Rundreise durch N Städte. Das Capacitated Vehicle Routing Problem with Tim"},{"seite":"graphen.html","titel":"Kurzzyklen verhindern","anker":"kurzzyklen-verhindern","text":"Ein naives Modell erlaubt Subtouren: isolierte Kreise, die das Depot nie anfahren. Die klassische Gegenmaßnahme ist die MTZ-Formulierung nach Miller, Tucker und Zemlin. Man führt R"},{"seite":"graphen.html","titel":"Praxisbeispiel: Flotten-Routing","anker":"praxisbeispiel-flotten-routing","text":""},{"seite":"graphen.html","titel":"8.6 Übungsaufgaben","anker":"sec:graphen-uebungsaufgaben","text":""},{"seite":"graphen.html","titel":"8.7 Finde den Denkfehler","anker":"sec:graphen-denkfehler","text":""},{"seite":"graphen.html","titel":"8.8 Micro-Quiz","anker":"sec:graphen-quiz","text":""},{"seite":"graphen.html","titel":"8.9 Selbsttest","anker":"sec:graphen-selbsttest","text":""},{"seite":"graphen.html","titel":"8.10 Zusammenfassung","anker":"sec:graphen-zusammenfassung","text":""},{"seite":"metaheuristiken.html","titel":"Kapitel 9: Metaheuristiken — wenn der exakte Solver aussteigt","anker":null,"text":""},{"seite":"metaheuristiken.html","titel":"9.1 In 5 Minuten gelöst","anker":"sec:metaheuristiken-schnellstart","text":""},{"seite":"metaheuristiken.html","titel":"9.2 Lernziele","anker":"sec:metaheuristiken-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"metaheuristiken.html","titel":"9.3 Die Aufgabe: Rüstzeiten an der Lackieranlage","anker":"sec:metaheuristiken-aufgabe","text":"Der Schnellstart war ein Spielzeug. Die Aufgabe dieses Kapitels ist dieselbe, nur in Betriebsgröße: n Aufträge in eine Reihenfolge bringen, so dass die Summe der Rüstzeiten minimal"},{"seite":"metaheuristiken.html","titel":"Die Instanz","anker":"die-instanz","text":"Alle Programme dieses Kapitels benutzen dieselbe Aufgabe. Die Rüstzeit setzt sich aus zwei Teilen zusammen: einem unregelmäßigen Reinigungsaufwand zwischen den Produktfamilien und "},{"seite":"metaheuristiken.html","titel":"9.4 Lokale Suche: der Zug und seine Kosten","anker":"sec:metaheuristiken-lokale-suche","text":"Eine lokale Suche braucht drei Dinge: eine Startlösung, einen Zug und eine Regel, welche Züge übernommen werden."},{"seite":"metaheuristiken.html","titel":"9.5 Simulated Annealing","anker":"sec:metaheuristiken-annealing","text":"Reines Bergsteigen — nur Verbesserungen annehmen — bleibt im ersten lokalen Optimum stehen. Simulated Annealing nimmt Verschlechterungen mit einer Wahrscheinlichkeit an, die von de"},{"seite":"metaheuristiken.html","titel":"Was die Messung zeigt — und was sie nicht zeigt","anker":"was-die-messung-zeigt-und-was-sie-nicht-zeigt","text":"Erstens: Der Beitrag des Annealings ist bescheiden. Bergsteigen kommt auf 917 Minuten, Annealing auf 907 — das simple Verfahren holt 93 % des Ertrags. Das ist kein Argument gegen A"},{"seite":"metaheuristiken.html","titel":"9.6 Ab wann lohnt es sich?","anker":"sec:metaheuristiken-umschlagpunkt","text":"„Kleine Probleme exakt, große mit Metaheuristiken“ ist richtig und nutzlos, solange niemand sagt, wo groß anfängt. Diese Zahl lässt sich messen."},{"seite":"metaheuristiken.html","titel":"Die Spalte, die man nicht weglassen darf","anker":"die-spalte-die-man-nicht-weglassen-darf","text":"Bei 500 Aufträgen haben wir die CP-SAT-Lösung verworfen. Seine Schranke ist trotzdem das Wertvollste in der ganzen Tabelle."},{"seite":"metaheuristiken.html","titel":"9.7 Large Neighborhood Search","anker":"sec:metaheuristiken-lns","text":"Die Tabelle oben endet mit einem Widerspruch. Bei 500 Aufträgen scheitert der exakte Solver an der Größe; die Metaheuristik scheitert an der Kleinheit ihrer Züge — sie verschiebt j"},{"seite":"metaheuristiken.html","titel":"Das Fenster ist die eine Stellschraube","anker":"das-fenster-ist-die-eine-stellschraube","text":""},{"seite":"metaheuristiken.html","titel":"Das Gesamtbild","anker":"das-gesamtbild","text":""},{"seite":"metaheuristiken.html","titel":"9.8 Was dieses Kapitel nicht behandelt","anker":"sec:metaheuristiken-abgrenzung","text":"Damit die Landkarte vollständig ist — drei Verfahren, die in dieselbe Familie gehören und hier bewusst nur benannt werden:"},{"seite":"metaheuristiken.html","titel":"9.9 Übungsaufgaben","anker":"sec:metaheuristiken-uebungsaufgaben","text":""},{"seite":"metaheuristiken.html","titel":"9.10 Finde den Denkfehler","anker":"sec:metaheuristiken-denkfehler","text":""},{"seite":"metaheuristiken.html","titel":"9.11 Micro-Quiz","anker":"sec:metaheuristiken-quiz","text":""},{"seite":"metaheuristiken.html","titel":"9.12 Selbsttest","anker":"sec:metaheuristiken-selbsttest","text":""},{"seite":"metaheuristiken.html","titel":"9.13 Zusammenfassung","anker":"sec:metaheuristiken-zusammenfassung","text":""},{"seite":"dekomposition.html","titel":"Kapitel 10: Spaltengenerierung — das Modell umbauen statt die Lösung raten","anker":null,"text":""},{"seite":"dekomposition.html","titel":"10.1 In 5 Minuten gelöst","anker":"sec:dekomposition-schnellstart","text":""},{"seite":"dekomposition.html","titel":"10.2 Lernziele","anker":"sec:dekomposition-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"dekomposition.html","titel":"10.3 Warum das naheliegende Modell scheitert","anker":"sec:dekomposition-symmetrie","text":"Der erste Entwurf eines Zuschnittmodells sieht fast immer so aus: eine Binärvariable z_{jr} für „Stück j liegt auf Rolle r“, dazu y_r für „Rolle r wird benutzt”."},{"seite":"dekomposition.html","titel":"10.4 Rechnen mit Mustern, ohne sie aufzuschreiben","anker":"sec:dekomposition-schleife","text":"Die Idee ist alt (Gilmore und Gomory, 1961) und in einem Satz gesagt:"},{"seite":"dekomposition.html","titel":"10.5 Das Programm","anker":"sec:dekomposition-programm","text":""},{"seite":"dekomposition.html","titel":"10.6 Was die Zahlen zeigen","anker":"sec:dekomposition-befund","text":""},{"seite":"dekomposition.html","titel":"Die Musterzahl explodiert, der Bedarf nicht","anker":"die-musterzahl-explodiert-der-bedarf-nicht","text":""},{"seite":"dekomposition.html","titel":"Der unbequeme Teil: Es lohnt sich nicht immer","anker":"der-unbequeme-teil-es-lohnt-sich-nicht-immer","text":""},{"seite":"dekomposition.html","titel":"10.7 Übungsaufgaben","anker":"sec:dekomposition-uebungsaufgaben","text":""},{"seite":"dekomposition.html","titel":"10.8 Finde den Denkfehler","anker":"sec:dekomposition-denkfehler","text":""},{"seite":"dekomposition.html","titel":"10.9 Micro-Quiz","anker":"sec:dekomposition-quiz","text":""},{"seite":"dekomposition.html","titel":"10.10 Selbsttest","anker":"sec:dekomposition-selbsttest","text":""},{"seite":"dekomposition.html","titel":"10.11 Zusammenfassung","anker":"sec:dekomposition-zusammenfassung","text":""},{"seite":"qp-nlp.html","titel":"Kapitel 11: Quadratische und nichtlineare Optimierung — KKT, Lagrange, Konvexität","anker":null,"text":""},{"seite":"qp-nlp.html","titel":"11.1 In 5 Minuten gelöst","anker":"sec:qp-nlp-schnellstart","text":""},{"seite":"qp-nlp.html","titel":"11.2 Lernziele","anker":"sec:qp-nlp-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"qp-nlp.html","titel":"Auffrischung: Gradient in einer Minute","anker":"auffrischung-gradient-in-einer-minute","text":"Der Gradient \\nabla f(\\mathbf{x}) ist der Vektor aller partiellen Ableitungen:"},{"seite":"qp-nlp.html","titel":"11.3 Das quadratische Programm (QP)","anker":"sec:qp-nlp-das-quadratische-programm-qp","text":"Ein quadratisches Programm hat eine quadratische Zielfunktion und lineare Nebenbedingungen:"},{"seite":"qp-nlp.html","titel":"Konvexität: die präzise Aussage","anker":"konvexität-die-präzise-aussage","text":"Eine verbreitete, aber zu stark formulierte Aussage lautet: „Wenn \\mathbf{P} positiv semi-definit ist, ist das QP streng konvex; es existiert ein eindeutiges globales Minimum.“ Kor"},{"seite":"qp-nlp.html","titel":"11.4 Die Karush-Kuhn-Tucker-Bedingungen","anker":"sec:qp-nlp-die-karush-kuhn-tucker-bedingungen","text":"Die KKT-Bedingungen verallgemeinern den Lagrange-Ansatz auf Probleme mit Ungleichungen. Sie sind notwendig für ein Optimum — und bei konvexen Problemen auch hinreichend."},{"seite":"qp-nlp.html","titel":"Die vier KKT-Bedingungen","anker":"die-vier-kkt-bedingungen","text":"Am Optimum (\\mathbf{x}^*, \\boldsymbol{\\lambda}^*, \\boldsymbol{\\nu}^*) gilt:"},{"seite":"qp-nlp.html","titel":"11.5 Nichtlineare Optimierung mit scipy.optimize.minimize","anker":"sec:qp-nlp-nichtlineare-optimierung-mit-scipy-optimize-minimize","text":"Wenn Zielfunktion oder Nebenbedingungen weder linear noch quadratisch sind — Logarithmen, Wurzeln, Exponentialfunktionen — nutzt man gradientenbasierte Verfahren wie SLSQP (Sequent"},{"seite":"qp-nlp.html","titel":"Praxisfall: Entropie-maximierte Kapitalallokation","anker":"praxisfall-entropie-maximierte-kapitalallokation","text":"Wir optimieren ein Portfolio nicht nur nach Varianz, sondern maximieren zusätzlich die Shannon-Entropie der Gewichte, um Klumpenrisiken glatt zu bestrafen:"},{"seite":"qp-nlp.html","titel":"11.6 Jenseits der Konvexität: lokale Optima, Multistart und MINLP","anker":"sec:qp-nlp-nichtkonvex","text":"Alles bisher in diesem Kapitel stand unter einer Bedingung: Konvexität. Sie ist der Grund, warum CVXPY eine Optimalitätsgarantie geben kann und warum die KKT-Bedingungen nicht nur "},{"seite":"qp-nlp.html","titel":"Woher Nicht-Konvexität im Alltag kommt","anker":"woher-nicht-konvexität-im-alltag-kommt","text":"Nicht aus exotischer Mathematik, sondern aus ganz gewöhnlichen betriebswirtschaftlichen Effekten:"},{"seite":"qp-nlp.html","titel":"Was ein lokales Optimum praktisch bedeutet","anker":"was-ein-lokales-optimum-praktisch-bedeutet","text":"scipy.optimize.minimize verweigert nichts. Es rechnet, meldet success: True und liefert eine Zahl. Diese Meldung heißt aber nicht „das ist das Optimum“, sondern nur:"},{"seite":"qp-nlp.html","titel":"Die Zahlen im Klartext","anker":"die-zahlen-im-klartext","text":""},{"seite":"qp-nlp.html","titel":"Ausblick: MINLP und globale Solver","anker":"ausblick-minlp-und-globale-solver","text":"Wird zusätzlich noch ganzzahlig entschieden — „welche Anlage läuft überhaupt“ und „wie stark“ —, entsteht ein MINLP (Mixed-Integer Nonlinear Program), die schwierigste der in diese"},{"seite":"qp-nlp.html","titel":"11.7 Übungsaufgaben","anker":"sec:qp-nlp-uebungsaufgaben","text":""},{"seite":"qp-nlp.html","titel":"11.8 Finde den Denkfehler","anker":"sec:qp-nlp-denkfehler","text":""},{"seite":"qp-nlp.html","titel":"11.9 Micro-Quiz","anker":"sec:qp-nlp-quiz","text":""},{"seite":"qp-nlp.html","titel":"11.10 Selbsttest","anker":"sec:qp-nlp-selbsttest","text":""},{"seite":"qp-nlp.html","titel":"11.11 Zusammenfassung","anker":"sec:qp-nlp-zusammenfassung","text":""},{"seite":"unsicherheit.html","titel":"Kapitel 12: Optimierung unter Unsicherheit — Monte-Carlo, Stochastik, Robustheit","anker":null,"text":""},{"seite":"unsicherheit.html","titel":"12.1 In 5 Minuten gelöst","anker":"sec:unsicherheit-schnellstart","text":""},{"seite":"unsicherheit.html","titel":"12.2 Lernziele","anker":"sec:unsicherheit-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"unsicherheit.html","titel":"12.3 Der Fluch des Durchschnitts","anker":"sec:unsicherheit-der-fluch-des-durchschnitts","text":"Der naheliegende Umgang mit Unsicherheit lautet: „Wir setzen den Erwartungswert ein und rechnen deterministisch.“ Das ist fast immer falsch — und zwar nicht ungenau, sondern system"},{"seite":"unsicherheit.html","titel":"Die drei Ansätze im Überblick","anker":"die-drei-ansätze-im-überblick","text":""},{"seite":"unsicherheit.html","titel":"12.4 Monte-Carlo-Simulation","anker":"sec:unsicherheit-monte-carlo-simulation","text":"Monte-Carlo heißt schlicht: Ziehe sehr viele Zufallsszenarien, werte für jedes den Plan aus, und betrachte die Verteilung der Ergebnisse statt eines einzigen Werts."},{"seite":"unsicherheit.html","titel":"12.5 Zweistufige stochastische Programmierung","anker":"sec:unsicherheit-zweistufige-stochastische-programmierung","text":"Das zweistufige Modell mit Recourse bildet ab, dass Entscheidungen zeitlich gestaffelt sind:"},{"seite":"unsicherheit.html","titel":"12.6 Robuste Optimierung: gegen den Worst Case absichern","anker":"sec:unsicherheit-robuste-optimierung-gegen-den-worst-case","text":"Wenn Wahrscheinlichkeiten unbekannt oder instabil sind — Marktcrashs, Lieferkettenabrisse, Pandemien —, hilft die robuste Optimierung. Sie fragt nicht nach dem Mittel, sondern nach"},{"seite":"unsicherheit.html","titel":"12.7 Übungsaufgaben","anker":"sec:unsicherheit-uebungsaufgaben","text":""},{"seite":"unsicherheit.html","titel":"12.8 Finde den Denkfehler","anker":"sec:unsicherheit-denkfehler","text":""},{"seite":"unsicherheit.html","titel":"12.9 Micro-Quiz","anker":"sec:unsicherheit-quiz","text":""},{"seite":"unsicherheit.html","titel":"12.10 Selbsttest","anker":"sec:unsicherheit-selbsttest","text":""},{"seite":"unsicherheit.html","titel":"12.11 Zusammenfassung","anker":"sec:unsicherheit-zusammenfassung","text":""},{"seite":"dynamische-programmierung.html","titel":"Kapitel 13: Dynamische Programmierung — Die Bellman-Gleichung und Order-Execution","anker":null,"text":""},{"seite":"dynamische-programmierung.html","titel":"13.1 In 5 Minuten gelöst","anker":"sec:dynamische-programmierung-schnellstart","text":""},{"seite":"dynamische-programmierung.html","titel":"13.2 Lernziele","anker":"sec:dynamische-programmierung-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"dynamische-programmierung.html","titel":"13.3 Das Bellmansche Optimalitätsprinzip","anker":"sec:dynamische-programmierung-das-bellmansche-optimalitaetsprinzip","text":"Richard Bellman formulierte 1957 das Grundprinzip der Dynamischen Programmierung (DP):"},{"seite":"dynamische-programmierung.html","titel":"Die vier Bausteine eines DP-Modells","anker":"die-vier-bausteine-eines-dp-modells","text":""},{"seite":"dynamische-programmierung.html","titel":"Die Bellman-Gleichung","anker":"die-bellman-gleichung","text":"V_t(S_t) = \\min_{a_t\\in\\mathcal{A}(S_t)}\\Big\\{\\,C(S_t,a_t) \\;+\\; \\gamma\\cdot\\mathbb{E}\\big[V_{t+1}(S_{t+1})\\mid S_t,a_t\\big]\\Big\\}"},{"seite":"dynamische-programmierung.html","titel":"13.4 Das Almgren-Chriss-Problem: optimale Orderausführung","anker":"sec:dynamische-programmierung-das-almgren-chriss-problem-optimale-orderausfuehrung","text":"Ein klassisches OR-Problem an Börsen: Ein institutioneller Händler muss X_0 = 100\\,000 Aktien innerhalb von T Handelsperioden verkaufen."},{"seite":"dynamische-programmierung.html","titel":"13.5 Der Fluch der Dimensionalität","anker":"sec:dynamische-programmierung-der-fluch-der-dimensionalitaet","text":"DP ist mächtig, aber es hat eine harte Grenze. Der Aufwand der Rückwärtsinduktion ist"},{"seite":"dynamische-programmierung.html","titel":"13.6 Übungsaufgaben","anker":"sec:dynamische-programmierung-uebungsaufgaben","text":""},{"seite":"dynamische-programmierung.html","titel":"13.7 Finde den Denkfehler","anker":"sec:dynamische-programmierung-denkfehler","text":"Bei dynamischer Programmierung entscheidet eine einzige Frage über Erfolg oder Misserfolg: Was gehört in den Zustand? Ist er zu klein, rechnet das Verfahren völlig korrekt — nur eb"},{"seite":"dynamische-programmierung.html","titel":"13.8 Micro-Quiz","anker":"sec:dynamische-programmierung-quiz","text":""},{"seite":"dynamische-programmierung.html","titel":"13.9 Selbsttest","anker":"sec:dynamische-programmierung-selbsttest","text":""},{"seite":"dynamische-programmierung.html","titel":"13.10 Zusammenfassung","anker":"sec:dynamische-programmierung-zusammenfassung","text":""},{"seite":"mehrziel.html","titel":"Kapitel 14: Mehrere Ziele — Pareto-Fronten statt Gewichte","anker":null,"text":""},{"seite":"mehrziel.html","titel":"14.1 In 5 Minuten gelöst","anker":"sec:mehrziel-schnellstart","text":""},{"seite":"mehrziel.html","titel":"14.2 Lernziele","anker":"sec:mehrziel-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"mehrziel.html","titel":"14.3 Wann ein Kompromiss gut ist","anker":"sec:mehrziel-dominanz","text":"Bei einem Ziel ist „besser” eindeutig. Bei zwei Zielen nicht mehr: Ein Plan kann billiger und schmutziger sein als ein anderer, und dann ist keiner von beiden besser."},{"seite":"mehrziel.html","titel":"14.4 Der Reflex: alles in ein Ziel rühren","anker":"sec:mehrziel-skalarisierung","text":"Der naheliegende Weg, zwei Ziele zu einem zu machen, ist die gewichtete Summe:"},{"seite":"mehrziel.html","titel":"14.5 Das Programm","anker":"sec:mehrziel-programm","text":"Zwölf Sendungen, drei Verkehrsträger, fünf freie Bahntrassen. Der Zielkonflikt entsteht nicht zwischen den Trägern — die Bahn ist immer billiger und sauberer als der LKW —, sondern"},{"seite":"mehrziel.html","titel":"14.6 Was die gewichtete Summe nicht sieht","anker":"sec:mehrziel-luecke","text":"Die Front hat zehn Punkte. Ein Raster aus 1 401 Gewichten findet sechs davon. Vier Kompromisse sind für kein Gewicht erreichbar — und das ist keine Frage der Rasterweite."},{"seite":"mehrziel.html","titel":"Das Gewicht ist keine Feineinstellung","anker":"das-gewicht-ist-keine-feineinstellung","text":"Die zweite Beobachtung aus derselben Tabelle: 1 401 Gewichte erzeugen sechs verschiedene Pläne. Weite Gewichtsbereiche liefern dasselbe Ergebnis, und dazwischen springt es."},{"seite":"mehrziel.html","titel":"14.7 Das ε-Constraint-Verfahren","anker":"sec:mehrziel-epsilon","text":"Der Ausweg ist unspektakulär: Man macht aus dem zweiten Ziel eine Nebenbedingung."},{"seite":"mehrziel.html","titel":"Wenn es eine klare Rangfolge gibt: lexikografisch","anker":"wenn-es-eine-klare-rangfolge-gibt-lexikografisch","text":"Manchmal ist ein Ziel wirklich vorrangig. Dann optimiert man lexikografisch: erst das erste Ziel, dann das zweite unter der Bedingung, dass das erste (fast) erhalten bleibt."},{"seite":"mehrziel.html","titel":"14.8 Wie man die Front vorlegt","anker":"sec:mehrziel-vorlegen","text":"Eine Front mit zehn Punkten ist kein Ergebnis, sondern eine Speisekarte. Damit sie entscheidbar wird, gehört eine Spalte dazu, die im Programm EUR je kg heißt:"},{"seite":"mehrziel.html","titel":"14.9 Übungsaufgaben","anker":"sec:mehrziel-uebungsaufgaben","text":""},{"seite":"mehrziel.html","titel":"14.10 Finde den Denkfehler","anker":"sec:mehrziel-denkfehler","text":""},{"seite":"mehrziel.html","titel":"14.11 Micro-Quiz","anker":"sec:mehrziel-quiz","text":""},{"seite":"mehrziel.html","titel":"14.12 Selbsttest","anker":"sec:mehrziel-selbsttest","text":""},{"seite":"mehrziel.html","titel":"14.13 Zusammenfassung","anker":"sec:mehrziel-zusammenfassung","text":""},{"seite":"prognose.html","titel":"Kapitel 15: Predict-then-Optimize — die bessere Prognose, die schlechtere Entscheidung","anker":null,"text":""},{"seite":"prognose.html","titel":"15.1 In 5 Minuten gelöst","anker":"sec:prognose-schnellstart","text":""},{"seite":"prognose.html","titel":"15.2 Lernziele","anker":"sec:prognose-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"prognose.html","titel":"15.3 Die Naht zwischen zwei Abteilungen","anker":"sec:prognose-naht","text":"In fast jedem Unternehmen sieht der Ablauf so aus:"},{"seite":"prognose.html","titel":"15.4 Das Programm","anker":"sec:prognose-programm","text":"Dieselbe Bäckerei, aber mit Merkmalen: Wochentag, Temperatur, Aktionstage. Die Nachfrage muss jetzt modelliert werden."},{"seite":"prognose.html","titel":"15.5 Der Befund","anker":"sec:prognose-befund","text":""},{"seite":"prognose.html","titel":"Warum ein pauschaler Zuschlag zu kurz greift","anker":"warum-ein-pauschaler-zuschlag-zu-kurz-greift","text":"Die zweite Zeile der Tabelle ist die verbreitete Praxis: Punktprognose plus Sicherheitsbestand. Sie hilft (−7,3 %), bleibt aber hinter der Quantilregression zurück. Das Programm re"},{"seite":"prognose.html","titel":"15.6 Eine Messfalle, in die der Autor selbst getappt ist","anker":"sec:prognose-messfalle","text":"Der erste Entwurf dieses Programms bewertete auf 230 Testtagen — ein realistischer Zeitraum, gut sieben Monate. Dort hatte die Quantilregression den besseren MSE, und die ganze Aus"},{"seite":"prognose.html","titel":"15.7 Wie weit das Verfahren reicht","anker":"sec:prognose-ausblick","text":"Die dritte Zeile der Ergebnistabelle — „Zuschlag auf Kosten trainiert” — ist die einfachste denkbare Form einer Idee, die derzeit erforscht wird: das Prognosemodell direkt auf die "},{"seite":"prognose.html","titel":"15.8 Übungsaufgaben","anker":"sec:prognose-uebungsaufgaben","text":""},{"seite":"prognose.html","titel":"15.9 Finde den Denkfehler","anker":"sec:prognose-denkfehler","text":""},{"seite":"prognose.html","titel":"15.10 Micro-Quiz","anker":"sec:prognose-quiz","text":""},{"seite":"prognose.html","titel":"15.11 Selbsttest","anker":"sec:prognose-selbsttest","text":""},{"seite":"prognose.html","titel":"15.12 Zusammenfassung","anker":"sec:prognose-zusammenfassung","text":""},{"seite":"bruecke.html","titel":"Kapitel 16: Die Strukturbrücke — dieselbe Mathematik, zwei Welten","anker":null,"text":""},{"seite":"bruecke.html","titel":"16.1 In 5 Minuten gelöst","anker":"sec:bruecke-schnellstart","text":""},{"seite":"bruecke.html","titel":"16.2 Lernziele","anker":"sec:bruecke-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"bruecke.html","titel":"16.3 Das Programm","anker":"sec:bruecke-programm","text":""},{"seite":"bruecke.html","titel":"16.4 Die zwei Brücken im Einzelnen","anker":"sec:bruecke-einzelnen","text":""},{"seite":"bruecke.html","titel":"Erste Brücke: Allokation","anker":"erste-brücke-allokation","text":""},{"seite":"bruecke.html","titel":"Zweite Brücke: Absicherung gegen den schlechtesten Fall","anker":"zweite-brücke-absicherung-gegen-den-schlechtesten-fall","text":"Hier ist die Übereinstimmung noch enger, weil sie nicht nur die Struktur betrifft, sondern die Formel. Die Rockafellar-Uryasev-Formulierung aus Kapitel 20"},{"seite":"bruecke.html","titel":"Wo die Lösungen sich unterscheiden","anker":"wo-die-lösungen-sich-unterscheiden","text":"Der Vergleich fördert nebenbei einen Unterschied zutage, den man nicht erwartet: Die Streuungsgrenze von 40 % bindet nur im Einkauf, nicht im Depot."},{"seite":"bruecke.html","titel":"16.5 Wo die Brücke endet","anker":"sec:bruecke-grenzen","text":"Eine Analogie ist nur so nützlich wie die Grenzen, die man ihr zieht. Drei Dinge wandern nicht mit:"},{"seite":"bruecke.html","titel":"16.6 Übungsaufgaben","anker":"sec:bruecke-uebungsaufgaben","text":""},{"seite":"bruecke.html","titel":"16.7 Finde den Denkfehler","anker":"sec:bruecke-denkfehler","text":""},{"seite":"bruecke.html","titel":"16.8 Micro-Quiz","anker":"sec:bruecke-quiz","text":""},{"seite":"bruecke.html","titel":"16.9 Selbsttest","anker":"sec:bruecke-selbsttest","text":""},{"seite":"bruecke.html","titel":"16.10 Zusammenfassung","anker":"sec:bruecke-zusammenfassung","text":""},{"seite":"supplychain.html","titel":"Kapitel 17: Supply-Chain und Energieeinsatz unter Unsicherheit","anker":null,"text":""},{"seite":"supplychain.html","titel":"17.1 In 5 Minuten gelöst","anker":"sec:supplychain-schnellstart","text":""},{"seite":"supplychain.html","titel":"17.2 Lernziele","anker":"sec:supplychain-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"supplychain.html","titel":"17.3 Die Aufgabe","anker":"sec:supplychain-aufgabe","text":"Fünf Blöcke, 24 Stunden, eine Last zwischen 270 und 804 MW. Dazu Windeinspeisung, die niemand am Vorabend kennt."},{"seite":"supplychain.html","titel":"Die Zeitstruktur der Entscheidung","anker":"die-zeitstruktur-der-entscheidung","text":"Hier geht das Kapitel über Abschnitt 12.5 hinaus:"},{"seite":"supplychain.html","titel":"17.4 Das Programm","anker":"sec:supplychain-programm","text":""},{"seite":"supplychain.html","titel":"17.5 Der Befund","anker":"sec:supplychain-befund","text":""},{"seite":"supplychain.html","titel":"17.6 Was Versorgungssicherheit kostet","anker":"sec:supplychain-sicherheit","text":"Der dritte Plan legt eine CVaR-Schranke auf die nicht gedeckte Energie: Im Mittel der schlechtesten 10 % der Szenarien darf nichts fehlen. Dieselbe Rockafellar-Uryasev-Konstruktion"},{"seite":"supplychain.html","titel":"17.7 Übungsaufgaben","anker":"sec:supplychain-uebungsaufgaben","text":""},{"seite":"supplychain.html","titel":"17.8 Finde den Denkfehler","anker":"sec:supplychain-denkfehler","text":""},{"seite":"supplychain.html","titel":"17.9 Micro-Quiz","anker":"sec:supplychain-quiz","text":""},{"seite":"supplychain.html","titel":"17.10 Selbsttest","anker":"sec:supplychain-selbsttest","text":""},{"seite":"supplychain.html","titel":"17.11 Zusammenfassung","anker":"sec:supplychain-zusammenfassung","text":""},{"seite":"finanzdaten.html","titel":"Kapitel 18: Finanzdaten-Modellierung — Renditen, Kovarianz und Shrinkage","anker":null,"text":""},{"seite":"finanzdaten.html","titel":"18.1 In 5 Minuten gelöst","anker":"sec:finanzdaten-schnellstart","text":""},{"seite":"finanzdaten.html","titel":"18.2 Lernziele","anker":"sec:finanzdaten-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"finanzdaten.html","titel":"18.3 Diskrete und logarithmische Renditen","anker":"sec:finanzdaten-diskrete-und-logarithmische-renditen","text":"Sei P_{i,t} der bereinigte Schlusskurs (adjusted close) von Titel i zum Zeitpunkt t. „Bereinigt“ heißt: um Dividenden und Aktiensplits korrigiert — sonst erscheint jede Dividendenz"},{"seite":"finanzdaten.html","titel":"Diskrete Rendite","anker":"diskrete-rendite","text":"R_{i,t} = \\frac{P_{i,t} - P_{i,t-1}}{P_{i,t-1}} = \\frac{P_{i,t}}{P_{i,t-1}} - 1"},{"seite":"finanzdaten.html","titel":"Logarithmische Rendite","anker":"logarithmische-rendite","text":"r_{i,t} = \\ln\\!\\left(\\frac{P_{i,t}}{P_{i,t-1}}\\right) = \\ln P_{i,t} - \\ln P_{i,t-1}"},{"seite":"finanzdaten.html","titel":"18.4 Das Schätzfehler-Problem","anker":"sec:finanzdaten-das-schaetzfehler-problem","text":"Sei \\mathbf{X}\\in\\mathbb{R}^{T\\times N} die Matrix zentrierter Renditen von N Titeln über T Handelstage. Die Stichproben-Kovarianzmatrix lautet:"},{"seite":"finanzdaten.html","titel":"18.5 Ledoit-Wolf-Shrinkage","anker":"sec:finanzdaten-ledoit-wolf-shrinkage","text":"Olivier Ledoit und Michael Wolf lösten das Dilemma durch lineare Schrumpfung:"},{"seite":"finanzdaten.html","titel":"Welches Ziel \\mathbf{F}?","anker":"welches-ziel-mathbff","text":"Ein häufiges Missverständnis: Es gibt nämlich mehrere Ledoit-Wolf-Varianten, und Text und Code müssen sich konsistent auf dieselbe beziehen:"},{"seite":"finanzdaten.html","titel":"18.6 Praxis: Datenpipeline mit korrekter Spaltenreihenfolge","anker":"sec:finanzdaten-praxis-datenpipeline-mit-korrekter-spaltenreihenfolge","text":""},{"seite":"finanzdaten.html","titel":"18.7 Übungsaufgaben","anker":"sec:finanzdaten-uebungsaufgaben","text":""},{"seite":"finanzdaten.html","titel":"18.8 Finde den Denkfehler","anker":"sec:finanzdaten-denkfehler","text":""},{"seite":"finanzdaten.html","titel":"18.9 Micro-Quiz","anker":"sec:finanzdaten-quiz","text":""},{"seite":"finanzdaten.html","titel":"18.10 Selbsttest","anker":"sec:finanzdaten-selbsttest","text":""},{"seite":"finanzdaten.html","titel":"18.11 Zusammenfassung","anker":"sec:finanzdaten-zusammenfassung","text":""},{"seite":"markowitz.html","titel":"Kapitel 19: Die moderne Portfoliotheorie nach Markowitz","anker":null,"text":""},{"seite":"markowitz.html","titel":"19.1 In 5 Minuten gelöst","anker":"sec:markowitz-schnellstart","text":""},{"seite":"markowitz.html","titel":"19.2 Lernziele","anker":"sec:markowitz-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"markowitz.html","titel":"19.3 Warum Diversifikation funktioniert","anker":"sec:markowitz-warum-diversifikation-funktioniert","text":"Harry Markowitz’ zentrale Einsicht von 1952: Das Risiko eines Portfolios ist nicht die Summe der Einzelrisiken. Es hängt davon ab, wie die Titel zusammenspielen."},{"seite":"markowitz.html","titel":"19.4 Das Mean-Variance-Modell","anker":"sec:markowitz-das-mean-variance-modell","text":"Sei \\mathbf{w} = (w_1,\\dots,w_n)^\\top der Vektor der Portfoliogewichte:"},{"seite":"markowitz.html","titel":"Die Effizienzgrenze und das Tangentialportfolio","anker":"die-effizienzgrenze-und-das-tangentialportfolio","text":"Die Effizienzgrenze (efficient frontier) ist die Kurve aller Portfolios, die zu jedem Risikoniveau die höchstmögliche Rendite liefern."},{"seite":"markowitz.html","titel":"Die Korn-Transformation","anker":"die-korn-transformation","text":"Die Sharpe Ratio ist ein Bruch — und damit nicht konvex. CVXPY würde sie ablehnen. Der Ausweg ist ein eleganter Trick, der die Homogenität des Problems ausnutzt:"},{"seite":"markowitz.html","titel":"19.5 Institutionelle Nebenbedingungen","anker":"sec:markowitz-institutionelle-nebenbedingungen","text":"In der Praxis gelten regulatorische und interne Regeln:"},{"seite":"markowitz.html","titel":"Was diese Regeln kosten","anker":"was-diese-regeln-kosten","text":"Jede Regel schneidet etwas vom Erreichbaren ab. Wie viel, lässt sich ausrechnen — man legt die Effizienzlinie ohne und mit Beschränkungen übereinander:"},{"seite":"markowitz.html","titel":"19.6 Vollimplementierung mit CVXPY","anker":"sec:markowitz-vollimplementierung-mit-cvxpy","text":""},{"seite":"markowitz.html","titel":"19.7 Übungsaufgaben","anker":"sec:markowitz-uebungsaufgaben","text":""},{"seite":"markowitz.html","titel":"19.8 Finde den Denkfehler","anker":"sec:markowitz-denkfehler","text":"Kapitel 18 hat gezeigt, was eine schlecht geschätzte Kovarianzmatrix anrichtet. Bei den erwarteten Renditen ist es schlimmer — und weil sie in der Zielfunktion stehen statt in der "},{"seite":"markowitz.html","titel":"19.9 Micro-Quiz","anker":"sec:markowitz-quiz","text":""},{"seite":"markowitz.html","titel":"19.10 Selbsttest","anker":"sec:markowitz-selbsttest","text":""},{"seite":"markowitz.html","titel":"19.11 Zusammenfassung","anker":"sec:markowitz-zusammenfassung","text":""},{"seite":"cvar.html","titel":"Kapitel 20: Tail-Risiko, CVaR und Transaktionskosten","anker":null,"text":""},{"seite":"cvar.html","titel":"20.1 In 5 Minuten gelöst","anker":"sec:cvar-schnellstart","text":""},{"seite":"cvar.html","titel":"20.2 Lernziele","anker":"sec:cvar-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"cvar.html","titel":"20.3 Die zwei Schwächen des Markowitz-Modells","anker":"sec:cvar-die-zwei-schwaechen-des-markowitz-modells","text":"Erstens: die Normalverteilungs-Illusion. Die Varianz behandelt Aufwärts- und Abwärtsschwankungen gleich und unterstellt implizit symmetrische, dünn auslaufende Verteilungen. Reale "},{"seite":"cvar.html","titel":"20.4 Value at Risk und Conditional Value at Risk","anker":"sec:cvar-value-at-risk-und-conditional-value","text":""},{"seite":"cvar.html","titel":"Das Rockafellar-Uryasev-Theorem (2000)","anker":"das-rockafellar-uryasev-theorem-2000","text":"Rockafellar und Uryasev zeigten, dass sich der CVaR über S Szenarien exakt als lineares Programm formulieren lässt:"},{"seite":"cvar.html","titel":"20.5 Transaktionskosten über die L_1-Norm","anker":"sec:cvar-transaktionskosten-ueber-die-l-1-norm","text":"Sei \\mathbf{w}_{\\text{alt}} das bestehende Portfolio und \\mathbf{w} das neue Ziel. Der Umschlag (turnover) ist:"},{"seite":"cvar.html","titel":"20.6 Implementierung: CVaR-Portfolio mit Reibung","anker":"sec:cvar-implementierung-cvar-portfolio-mit-reibung","text":""},{"seite":"cvar.html","titel":"20.7 Übungsaufgaben","anker":"sec:cvar-uebungsaufgaben","text":""},{"seite":"cvar.html","titel":"20.8 Finde den Denkfehler","anker":"sec:cvar-denkfehler","text":""},{"seite":"cvar.html","titel":"20.9 Micro-Quiz","anker":"sec:cvar-quiz","text":""},{"seite":"cvar.html","titel":"20.10 Selbsttest","anker":"sec:cvar-selbsttest","text":""},{"seite":"cvar.html","titel":"20.11 Zusammenfassung","anker":"sec:cvar-zusammenfassung","text":""},{"seite":"handelsmaschine.html","titel":"Kapitel 21: Die vollständige quantitative Handelsmaschine","anker":null,"text":""},{"seite":"handelsmaschine.html","titel":"21.1 In 5 Minuten gelöst","anker":"sec:handelsmaschine-schnellstart","text":""},{"seite":"handelsmaschine.html","titel":"21.2 Lernziele","anker":"sec:handelsmaschine-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"handelsmaschine.html","titel":"21.3 Die Architektur","anker":"sec:handelsmaschine-die-architektur","text":""},{"seite":"handelsmaschine.html","titel":"21.4 Rebalancing-Termine richtig bestimmen","anker":"sec:handelsmaschine-rebalancing-termine-richtig-bestimmen","text":""},{"seite":"handelsmaschine.html","titel":"21.5 Die Engine","anker":"sec:handelsmaschine-die-engine","text":""},{"seite":"handelsmaschine.html","titel":"21.6 Die fünf Selbsttäuschungen des Backtestens","anker":"sec:handelsmaschine-die-fuenf-selbsttaeuschungen-des-backtestens","text":""},{"seite":"handelsmaschine.html","titel":"21.7 Vom Backtest zum Betrieb","anker":"sec:handelsmaschine-vom-backtest-zum-betrieb","text":"Ein guter Backtest ist eine notwendige, keine hinreichende Bedingung. Was zwischen Backtest und echtem Einsatz liegt:"},{"seite":"handelsmaschine.html","titel":"21.8 Übungsaufgaben","anker":"sec:handelsmaschine-uebungsaufgaben","text":""},{"seite":"handelsmaschine.html","titel":"21.9 Finde den Denkfehler","anker":"sec:handelsmaschine-denkfehler","text":"Backtest_Fallen.py zeigt drei Fehler, die im Backtest stecken: Lookahead, Survivorship, vergessene Kosten. Alle drei kann man einem Programm ansehen, wenn man es liest."},{"seite":"handelsmaschine.html","titel":"21.10 Micro-Quiz","anker":"sec:handelsmaschine-quiz","text":""},{"seite":"handelsmaschine.html","titel":"21.11 Selbsttest","anker":"sec:handelsmaschine-selbsttest","text":""},{"seite":"handelsmaschine.html","titel":"21.12 Zusammenfassung","anker":"sec:handelsmaschine-zusammenfassung","text":""},{"seite":"praxisfallen.html","titel":"Kapitel 22: Praxisfallen und der Weg zum produktiven Einsatz","anker":null,"text":""},{"seite":"praxisfallen.html","titel":"22.1 In 5 Minuten gelöst","anker":"sec:praxisfallen-schnellstart","text":"Die vorangegangenen vierzehn Kapitel enden fast alle mit derselben Empfehlung: Prüfen Sie das Ergebnis gegen die Wirklichkeit. Dieses Kapitel beginnt damit, diese Prüfung als wiede"},{"seite":"praxisfallen.html","titel":"22.2 Lernziele","anker":"sec:praxisfallen-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"praxisfallen.html","titel":"22.3 Die fünf typischen Praxisfallen","anker":"sec:praxisfallen-die-fuenf-typischen-praxisfallen","text":""},{"seite":"praxisfallen.html","titel":"Falle 1 — Infeasibility","anker":"falle-1-infeasibility","text":"Wenn Parameter unglücklich zusammentreffen — drei Mitarbeitende gleichzeitig krank, kein qualifizierter Ersatz —, meldet der Solver schlicht INFEASIBLE."},{"seite":"praxisfallen.html","titel":"Falle 2 — Der Black-Box-Effekt","anker":"falle-2-der-black-box-effekt","text":"Ein Planer oder Trader lehnt ein System ab, wenn er die Zuweisungen nicht nachvollziehen kann. Akzeptanz ist keine Nebensache, sondern Voraussetzung für den Betrieb."},{"seite":"praxisfallen.html","titel":"Falle 3 — Regimewechsel und Schätzfehler","anker":"falle-3-regimewechsel-und-schätzfehler","text":"Ein Modell, das auf einer mehrjährigen Aufwärtsphase kalibriert wurde, kennt keine Liquiditätskrise und keine Zinswende."},{"seite":"praxisfallen.html","titel":"Falle 4 — Lookahead- und Survivorship-Bias","anker":"falle-4-lookahead--und-survivorship-bias","text":"Ausführlich in Kapitel 21 behandelt. Die Kurzfassung: Beide Fehler machen Backtests systematisch zu gut, ohne eine Fehlermeldung zu erzeugen. Der Lookahead-Selbsttest aus Abschnitt"},{"seite":"praxisfallen.html","titel":"Falle 5 — Laufzeitexplosion","anker":"falle-5-laufzeitexplosion","text":"Exakte Optimalität kann bei MILP oder CP-SAT Minuten bis Stunden dauern — und die Laufzeit wächst nicht linear mit der Problemgröße."},{"seite":"praxisfallen.html","titel":"22.4 Architektur einer produktionsreifen OR-Plattform","anker":"sec:praxisfallen-architektur-einer-produktionsreifen-or-plattform","text":""},{"seite":"praxisfallen.html","titel":"22.5 Der gemeinsame Unterbau: or_kern.py","anker":"sec:praxisfallen-or-kern","text":"Die fünf Prinzipien oben beschreiben eine Plattform. Der Weg dorthin beginnt aber viel kleiner — mit der Frage, welche Teile eines Optimierungsprogramms immer dieselben sind."},{"seite":"praxisfallen.html","titel":"Die vier Schichten","anker":"die-vier-schichten","text":"\\underbrace{\\text{Rohdaten}}_{\\text{Excel, CSV, ERP}}\n\\;\\longrightarrow\\;\n\\underbrace{\\text{Domänenmodell}}_{\\text{geprüft, solverfrei}}\n\\;\\longrightarrow\\;\n\\underbrace{\\text{Model"},{"seite":"praxisfallen.html","titel":"Ein Enum für fünf Bibliotheken","anker":"ein-enum-für-fünf-bibliotheken","text":"Der unscheinbarste, aber wirksamste Teil ist die Statusübersetzung. Dieselbe Aussage heißt in den fünf im Buch verwendeten Bibliotheken:"},{"seite":"praxisfallen.html","titel":"22.6 Der Solverwechsel in der Praxis","anker":"sec:praxisfallen-solverwechsel","text":"Der vorige Abschnitt endet mit einer Behauptung: Ein Solverwechsel betreffe genau einen Baustein. Das ist die Art Satz, die in jedem Architekturvortrag vorkommt und die man deshalb"},{"seite":"praxisfallen.html","titel":"22.7 Eine Checkliste vor dem Produktivgang","anker":"sec:praxisfallen-eine-checkliste-vor-dem-produktivgang","text":"Arbeiten Sie diese Liste ab, bevor ein OR-System in Betrieb geht:"},{"seite":"praxisfallen.html","titel":"22.8 Weiterführende Literatur und Roadmap","anker":"sec:praxisfallen-weiterfuehrende-literatur-und-roadmap","text":"Grundlagen und lineare/ganzzahlige Optimierung * Bertsimas & Tsitsiklis: Introduction to Linear Optimization — Standardwerk zu Simplex, Dualität, Polyedertheorie. * Wolsey: Int"},{"seite":"praxisfallen.html","titel":"22.9 Übungsaufgaben","anker":"sec:praxisfallen-uebungsaufgaben","text":""},{"seite":"praxisfallen.html","titel":"22.10 Finde den Denkfehler","anker":"sec:praxisfallen-denkfehler","text":"Falle 5 — die Laufzeitexplosion — zeigt sich im Betrieb selten als Absturz. Sie zeigt sich als gar nichts."},{"seite":"praxisfallen.html","titel":"22.11 Micro-Quiz","anker":"sec:praxisfallen-quiz","text":""},{"seite":"praxisfallen.html","titel":"22.12 Selbsttest","anker":"sec:praxisfallen-selbsttest","text":""},{"seite":"praxisfallen.html","titel":"22.13 Zusammenfassung","anker":"sec:praxisfallen-zusammenfassung","text":""},{"seite":"testing.html","titel":"Kapitel 23: Testen, Messen, Ausliefern","anker":null,"text":""},{"seite":"testing.html","titel":"23.1 In 5 Minuten gelöst","anker":"sec:testing-schnellstart","text":""},{"seite":"testing.html","titel":"23.2 Lernziele","anker":"sec:testing-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"testing.html","titel":"23.3 Warum Optimierungsmodelle schwer zu testen sind","anker":"sec:testing-warum-schwer","text":"Bei einer gewöhnlichen Funktion schreibt man den erwarteten Wert hin:"},{"seite":"testing.html","titel":"23.4 Die Testsuite","anker":"sec:testing-suite","text":""},{"seite":"testing.html","titel":"23.5 Wer testet die Tests?","anker":"sec:testing-mutation","text":"33 grüne Tests. Das ist noch kein Ergebnis."},{"seite":"testing.html","titel":"Der erste Lauf fand zwei Lücken","anker":"der-erste-lauf-fand-zwei-lücken","text":"Die Suite hatte zunächst 31 Tests. Der Mutationstest sagte dazu:"},{"seite":"testing.html","titel":"23.6 Ein Vergleich, dem man glauben kann","anker":"sec:testing-benchmark","text":"Solververgleiche stehen in jedem Blog, und die meisten sind wertlos. Nicht, weil falsch gemessen wurde, sondern weil zu wenig dazugesagt wird."},{"seite":"testing.html","titel":"Die interessanteste Spalte heißt „Anteil”","anker":"die-interessanteste-spalte-heißt-anteil","text":"Sie sagt, wie viel der Zeit in den Aufbau des Modells geht statt ins Lösen — und die Zahlen sind unbequem:"},{"seite":"testing.html","titel":"23.7 Das Modell als Dienst","anker":"sec:testing-dienst","text":"Ein Modell hinter eine HTTP-Schnittstelle zu hängen sieht nach einer Fingerübung aus. Es gibt aber einen Unterschied, der die ganze Bauform bestimmt:"},{"seite":"testing.html","titel":"Threads oder Prozesse? Eine Messung, keine Meinung","anker":"threads-oder-prozesse-eine-messung-keine-meinung","text":"Punkt 6 des Selbsttests misst, ob vier Solverläufe in Threads sich überlappen. Das Ergebnis ist ein Faktor von rund 3,5 bei vier Threads: OR-Tools rechnet in C++ und gibt den GIL w"},{"seite":"testing.html","titel":"Was im echten Betrieb dazukommt","anker":"was-im-echten-betrieb-dazukommt","text":"Das Beispiel hält das Auftragsbuch in einem dict und rechnet in Threads desselben Prozesses. Für einen einzelnen Rechner reicht das; darüber hinaus nicht:"},{"seite":"testing.html","titel":"23.8 Übungsaufgaben","anker":"sec:testing-uebungsaufgaben","text":""},{"seite":"testing.html","titel":"23.9 Finde den Denkfehler","anker":"sec:testing-denkfehler","text":""},{"seite":"testing.html","titel":"23.10 Micro-Quiz","anker":"sec:testing-quiz","text":""},{"seite":"testing.html","titel":"23.11 Selbsttest","anker":"sec:testing-selbsttest","text":""},{"seite":"testing.html","titel":"23.12 Zusammenfassung","anker":"sec:testing-zusammenfassung","text":""},{"seite":"projektwerkstatt.html","titel":"Projektwerkstatt","anker":null,"text":""},{"seite":"projektwerkstatt.html","titel":"Wie Sie ein Projekt bearbeiten","anker":"wie-sie-ein-projekt-bearbeiten","text":"Halten Sie sich an diese Reihenfolge — sie ist aus vielen gescheiterten und einigen gelungenen Projekten destilliert:"},{"seite":"projektwerkstatt.html","titel":"P1 — Vertretungsplaner für eine Schule","anker":"p1-vertretungsplaner-für-eine-schule","text":""},{"seite":"projektwerkstatt.html","titel":"P2 — Schichtplanung für ein Pflegeteam","anker":"p2-schichtplanung-für-ein-pflegeteam","text":""},{"seite":"projektwerkstatt.html","titel":"P3 — Tourenplanung für einen Lieferdienst","anker":"p3-tourenplanung-für-einen-lieferdienst","text":""},{"seite":"projektwerkstatt.html","titel":"P4 — Standort- und Lagernetzplanung","anker":"p4-standort--und-lagernetzplanung","text":""},{"seite":"projektwerkstatt.html","titel":"P5 — Produktionsplanung mit Rüstkosten","anker":"p5-produktionsplanung-mit-rüstkosten","text":""},{"seite":"projektwerkstatt.html","titel":"P6 — Portfolio-Rebalancer für ein Privatdepot","anker":"p6-portfolio-rebalancer-für-ein-privatdepot","text":""},{"seite":"projektwerkstatt.html","titel":"P7 — Risikoreport mit CVaR und Stresstests","anker":"p7-risikoreport-mit-cvar-und-stresstests","text":""},{"seite":"projektwerkstatt.html","titel":"P9 — Wenn der Solver aussteigt: Tourenplanung in Echtgröße","anker":"p9-wenn-der-solver-aussteigt-tourenplanung-in-echtgröße","text":""},{"seite":"projektwerkstatt.html","titel":"P10 — Zwei Ziele, eine Entscheidung: Kosten gegen CO₂","anker":"p10-zwei-ziele-eine-entscheidung-kosten-gegen-co₂","text":""},{"seite":"projektwerkstatt.html","titel":"P11 — Vom Skript zum Dienst: das Modell übergeben","anker":"p11-vom-skript-zum-dienst-das-modell-übergeben","text":""},{"seite":"projektwerkstatt.html","titel":"P8 — Freies Projekt aus Ihrem eigenen Umfeld","anker":"p8-freies-projekt-aus-ihrem-eigenen-umfeld","text":""},{"seite":"projektwerkstatt.html","titel":"Eine Bitte zum Schluss","anker":"eine-bitte-zum-schluss","text":"Wenn Sie ein Projekt fertig haben, machen Sie zwei Dinge:"},{"seite":"anhang-loesungen.html","titel":"Anhang A: Lösungen zu allen Übungsaufgaben","anker":null,"text":""},{"seite":"anhang-loesungen.html","titel":"A.1 Lösungen zu Kapitel „Einführung in Operations Research — Vom Ursprung zur mathematischen Entscheidungsfindung“","anker":"sec:loesungen-einfuehrung","text":"1.1 — Analytik-Stufen. (a) deskriptiv (Vergangenheit beschreiben). (b) prädiktiv (Prognose). (c) präskriptiv (Handlungsanweisung unter Restriktionen). (d) prädiktiv (Klassifikation"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 1.1 — Die Schreinerei verdoppelt ihren Gewinn","anker":"finde-den-denkfehler-1.1-die-schreinerei-verdoppelt-ihren-gewinn","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-einfuehrung","text":"1 — (c) präskriptiv. Die Frage lautet „was tun“, und es gibt eine Restriktion (das Restbudget). Dass man für die Bewertung eine Prognose braucht, macht die Frage nicht prädiktiv — "},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-einfuehrung","text":""},{"seite":"anhang-loesungen.html","titel":"A.2 Lösungen zu Kapitel „Das mathematische Fundament — Vektoren, Matrizen, Konvexität“","anker":"sec:loesungen-fundament","text":"2.1 — Matrixform lesen. \\max 4x_1 + x_2 + 6x_3 u. d. N. x_1 + 2x_2 \\le 10, x_2 + 3x_3 \\le 12, x \\ge 0. 3 Variablen, 2 Nebenbedingungen (plus Nichtnegativität)."},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 2.1 — Die unauffällige Transposition","anker":"finde-den-denkfehler-2.1-die-unauffällige-transposition","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-fundament","text":"1 — (b). Der Fundamentalsatz sagt nur, dass ein Optimum in einer Ecke angenommen wird. (a) ist falsch, weil es mehrere optimale Ecken geben kann (wenn die Zielfunktion parallel zu "},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-fundament","text":""},{"seite":"anhang-loesungen.html","titel":"A.3 Lösungen zu Kapitel „Das Python-Ökosystem für OR — Solver, Bindings und Modellierungsschichten“","anker":"sec:loesungen-oekosystem","text":"3.1 — Solverwahl. (a) CP-SAT (diskrete Zuordnung mit Zeitfenstern). (b) scipy.optimize.linprog (klassisches Mischungs-LP, klein). (c) CVXPY (konvexes QP). (d) MIQP — CVXPY mit Binä"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 3.1 — Der Solver, der angeblich dreimal schneller ist","anker":"finde-den-denkfehler-3.1-der-solver-der-angeblich-dreimal-schneller-ist","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-oekosystem","text":"1 — (b) CP-SAT. Es geht um diskrete Zuweisung mit logischen Regeln („nicht gleichzeitig“) — CP-SATs Kerngebiet, und Regeln dieser Art formuliert man dort direkt statt über Big-M-Um"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-oekosystem","text":""},{"seite":"anhang-loesungen.html","titel":"A.4 Lösungen zu Kapitel „Vom Management-Wunsch zum Modell“","anker":"sec:loesungen-modellierung","text":"Die fehlende Lesart. Eine dritte Lesart: „Der Umsatzanteil der Stammkunden darf nicht unter 30 % fallen.” Sie zielt weder auf einzelne Aufträge noch auf einzelne Kunden, sondern au"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Die Engpassmaschine soll zu mindestens 92 % ausgelastet sein“","anker":"denkfehler-loesung-modellierung","text":"Fehler 1: Auslastung ist ein Ergebnis, keine Anforderung."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-modellierung","text":"1. b) Die Zielfunktion ist eine vollständige Aussage darüber, was zählt. Was nicht darin steht, ist für das Modell nicht „weniger wichtig”, sondern wertlos. (a) und (c) sind frei e"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-modellierung","text":""},{"seite":"anhang-loesungen.html","titel":"A.5 Lösungen zu Kapitel „Lineare Programmierung — Simplex, Dualität und Schattenpreise“","anker":"sec:loesungen-lp","text":"4.1 — Schlupf deuten. (a) Ressourcen 1 und 3 (Schlupf 0). (b) Ja: Überall ist s_i \\cdot y_i = 0. (c) In Ressource 3 — der höchste Schattenpreis (9,8) bedeutet den größten Grenznutz"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 4.1 — Die 380 000-Euro-Maschine","anker":"finde-den-denkfehler-4.1-die-380-000-euro-maschine","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-lp","text":"1 — (b). Genau der Satz vom komplementären Schlupf: s_i \\cdot y_i = 0. Ist Schlupf vorhanden, muss der Dualwert null sein. (a) hat die Aussage in ihr Gegenteil verkehrt; (c) verwec"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-lp","text":""},{"seite":"anhang-loesungen.html","titel":"A.6 Lösungen zu Kapitel „Gemischt-ganzzahlige Optimierung — Diskrete Entscheidungen und Branch-and-Bound“","anker":"sec:loesungen-milp","text":"5.1 — Runden widerlegen. Beispiel: \\max x_1 + x_2 u. d. N. 10x_1 + 10x_2 \\le 15, ganzzahlig. LP-Optimum Z = 1{,}5; Abrunden ergibt (0,0) mit Z = 0 — 100 % Verlust. Das ganzzahlige "},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 5.1 — Elf Lager, die keine Fixkosten kosten","anker":"finde-den-denkfehler-5.1-elf-lager-die-keine-fixkosten-kosten","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-milp","text":"1 — (b). Der Gap (48\\,200 - 47\\,100)/48\\,200 = 2{,}3\\,\\% ist eine Garantie, keine Schätzung: Besser als 47 100 € kann es nachweislich nicht werden, also ist der vorliegende Plan hö"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-milp","text":""},{"seite":"anhang-loesungen.html","titel":"A.7 Lösungen zu Kapitel „Constraint Programming mit CP-SAT — Logik, Scheduling und Zuweisung“","anker":"sec:loesungen-cpsat","text":"6.1 — Propagation. Aus x_1 + x_2 = 8 und x_1 < x_2 folgt x_1 < 4, also x_1 \\in \\{2,3\\} (denn x_2 = 8-x_1 \\le 6 verlangt x_1 \\ge 2) und entsprechend x_2 \\in \\{5,6\\}. Aus 36 Ko"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 6.1 — Die Betriebsvereinbarung, die niemanden interessiert","anker":"finde-den-denkfehler-6.1-die-betriebsvereinbarung-die-niemanden-interessiert","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-cpsat","text":"1 — (b) wirkungslos. INFEASIBLE ist kein Abbruch, sondern ein Beweis: CP-SAT hat gezeigt, dass keine zulässige Lösung existiert. Mehr Zeit (a) oder mehr Arbeiter (c) ändern daran n"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-cpsat","text":""},{"seite":"anhang-loesungen.html","titel":"A.8 Lösungen zu Kapitel „Graphen, Flüsse und Touren — Min-Cost-Flow, Matching und VRP“","anker":"sec:loesungen-graphen","text":"7.1 — Flusserhaltung. b_i = (15+3) - (12+8) = -2 → Senke (Nettobedarf 2)."},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 7.1 — Die vergessene Dimension","anker":"finde-den-denkfehler-7.1-die-vergessene-dimension","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-graphen","text":"1 — (b) totale Unimodularität. Jede quadratische Teilmatrix hat Determinante 0, +1 oder -1; bei ganzzahliger rechter Seite sind deshalb alle Ecken des zulässigen Bereichs ganzzahli"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-graphen","text":""},{"seite":"anhang-loesungen.html","titel":"A.9 Lösungen zu Kapitel „Metaheuristiken — wenn der exakte Solver aussteigt“","anker":"sec:loesungen-metaheuristiken","text":"Die Kurzsichtigkeit von Hand. Ab Schwarz wählt die Faustregel: Schwarz → Dunkelrot (26) → Rot (5) → … und muss dann in die helle Gruppe: Rot → Weiß (50) → Elfenbein (2) → Beige (2)"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Unsere Heuristik ist 6 % besser“","anker":"denkfehler-loesung-metaheuristiken","text":"Es fehlt die Schranke."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-metaheuristiken","text":"1. b) Eine lokale Suche lebt von der Zahl geprüfter Züge. Die volle Summe kostet O(n) statt O(1) — bei 500 Aufträgen zwei Größenordnungen weniger Züge im selben Zeitbudget. Numeris"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-metaheuristiken","text":""},{"seite":"anhang-loesungen.html","titel":"A.10 Lösungen zu Kapitel „Spaltengenerierung“","anker":"sec:loesungen-dekomposition","text":"Das Abbruchkriterium. Die Eins ist der Zielfunktionskoeffizient eines Musters: Jedes geschnittene Muster verbraucht genau eine Mutterrolle, und die Zielfunktion lautet \\min \\sum_p "},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Die LP-Lösung sagt 72,92 — also runden wir auf“","anker":"denkfehler-loesung-dekomposition","text":"Das Aufrunden kostet sechs Rollen — acht Prozent."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-dekomposition","text":"1. b) Das Mustermodell kennt keine einzelnen Rollen und damit keine austauschbaren Objekte — die Symmetrie verschwindet. (a) trifft zufällig auch zu, ist aber nicht der Grund; (c) "},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-dekomposition","text":""},{"seite":"anhang-loesungen.html","titel":"A.11 Lösungen zu Kapitel „Quadratische und nichtlineare Optimierung — KKT, Lagrange, Konvexität“","anker":"sec:loesungen-qp-nlp","text":"8.1 — Konvexität einordnen. PSD (alle \\ge 0) → konvex, aber nicht streng konvex; die Lösung ist nicht notwendig eindeutig. Der Eigenwert 0 bedeutet eine flache Richtung: Entlang de"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 8.1 — Die Kovarianzmatrix aus dem Controlling","anker":"finde-den-denkfehler-8.1-die-kovarianzmatrix-aus-dem-controlling","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-qp-nlp","text":"1 — (b). Der DCPError ist eine inhaltliche Aussage: Für diese Formulierung kann es keine Optimalitätsgarantie geben. Auf scipy auszuweichen (a) beseitigt die Meldung, nicht die Urs"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-qp-nlp","text":""},{"seite":"anhang-loesungen.html","titel":"A.12 Lösungen zu Kapitel „Optimierung unter Unsicherheit — Monte-Carlo, Stochastik, Robustheit“","anker":"sec:loesungen-unsicherheit","text":"9.1 — Fluch des Durchschnitts. Weil sich Verzögerungen fortpflanzen und die Verteilung rechtsschief ist: Ein Gewerk, das früher fertig wird, beschleunigt selten den Gesamtablauf (d"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 9.1 — Warum jedes Projekt zu spät fertig wird","anker":"finde-den-denkfehler-9.1-warum-jedes-projekt-zu-spät-fertig-wird","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-unsicherheit","text":"1 — (b). Das kritische Verhältnis 1400/(1400+120) = 0{,}921 gibt an, wie weit man sich auf die günstigere Fehlerseite stellen soll: Bestellt wird das 92,1-%-Quantil des Bedarfs, hi"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-unsicherheit","text":""},{"seite":"anhang-loesungen.html","titel":"A.13 Lösungen zu Kapitel „Dynamische Programmierung — Die Bellman-Gleichung und Order-Execution“","anker":"sec:loesungen-dynamische-programmierung","text":"10.1 — Bausteine. Stufe = Tag 1–5; Zustand = verbleibende Distanz (ggf. plus Erschöpfungsgrad); Aktion = heutige Tagesetappe; Wertfunktion = minimale Restanstrengung. Nicht in den "},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 10.1 — Der Zustand, der zu wenig weiß","anker":"finde-den-denkfehler-10.1-der-zustand-der-zu-wenig-weiß","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-dynamische-programmierung","text":"1 — (b). V_t setzt V_{t+1} voraus: Eine Entscheidung lässt sich erst bewerten, wenn feststeht, was sie für die Zukunft bedeutet. Am Ende ist dieser Wert bekannt (V_T = g), deshalb "},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-dynamische-programmierung","text":""},{"seite":"anhang-loesungen.html","titel":"A.14 Lösungen zu Kapitel „Mehrere Ziele — Pareto-Fronten statt Gewichte“","anker":"sec:loesungen-mehrziel","text":"Dominanz prüfen. P (14 000 €, 6 000 kg) und Q (13 800 €, 6 100 kg): keiner dominiert den anderen. Q ist billiger, P ist sauberer — beide sind pareto-optimal, die Wahl ist eine Präf"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Wir haben die Gewichte sauber kalibriert“","anker":"denkfehler-loesung-mehrziel","text":"Der Lösungsraum wurde nicht vollständig abgetastet — und zwar aus prinzipiellen Gründen."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-mehrziel","text":"1. b) Geometrisch heißt „gewichtete Summe minimieren“: eine Gerade von links unten an die Punktwolke schieben. Sie berührt immer einen Eckpunkt der unteren konvexen Hülle; Punkte d"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-mehrziel","text":""},{"seite":"anhang-loesungen.html","titel":"A.15 Lösungen zu Kapitel „Predict-then-Optimize“","anker":"sec:loesungen-prognose","text":"Andere Preise. Der Überhang kostet nur noch 1,50 €, die Fehlmenge weiterhin 6 €. Das kritische Verhältnis steigt von 6/9 = 0{,}667 auf 6/7{,}5 = 0{,}80. Die Bestellmenge verschiebt"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Wir haben die Prognose um 18 % verbessert“","anker":"denkfehler-loesung-prognose","text":"Der Sicherheitszuschlag stammt aus den Residuen des alten Modells."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-prognose","text":"1. b) Die Kostenasymmetrie verschiebt das Optimum vom Erwartungswert zum kritischen Quantil. (a) und (c) sind reale Probleme, aber nicht dieses: Selbst bei perfekt bekannter, norma"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-prognose","text":""},{"seite":"anhang-loesungen.html","titel":"A.16 Lösungen zu Kapitel „Die Strukturbrücke — dieselbe Mathematik, zwei Welten“","anker":"sec:loesungen-bruecke","text":"Die dritte Ressource. „Liquidität” — wie schnell sich eine Position ohne Kursabschlag verkaufen lässt — entspricht in der Werkstatt der Umrüstbarkeit oder der Vorlaufzeit: Wie schn"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Das ist doch dasselbe Problem“","anker":"denkfehler-loesung-bruecke","text":"Fehler 1: Varianz ist hier das falsche Risikomaß — genau der Fall, für den es CVaR gibt."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-bruecke","text":"1. b) Beide Probleme haben dieselbe Struktur: knappe Größen auf konkurrierende Verwendungen verteilen. (a) ist falsch — Produktionsproblem ist streng typisiert und validiert beim E"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-bruecke","text":""},{"seite":"anhang-loesungen.html","titel":"A.17 Lösungen zu Kapitel „Supply-Chain und Energieeinsatz unter Unsicherheit“","anker":"sec:loesungen-supplychain","text":"Der Umschlagpunkt. Bei 60 MW statt 120 MW halbiert sich die Grenzkostendifferenz je Stunde (83 €/MWh × 60 MW = 4 980 €/h). Der Umschlagpunkt verdoppelt sich auf 7,7 Stunden."},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Wir rechnen mit dem P50-Szenario“","anker":"denkfehler-loesung-supplychain","text":"Fehler 1: Der Median ist keine Absicherung, sondern eine Wette auf die Hälfte der Fälle."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-supplychain","text":"1. b) Anfahrkosten und Mindestlaufzeiten koppeln die Stunden. Die Merit-Order ist eine Sortierung und kennt keine Kopplung über die Zeit; sie ist für einen einzelnen Zeitpunkt rich"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-supplychain","text":""},{"seite":"anhang-loesungen.html","titel":"A.18 Lösungen zu Kapitel „Finanzdaten-Modellierung — Renditen, Kovarianz und Shrinkage“","anker":"sec:loesungen-finanzdaten","text":"11.1 — Renditeart. (a) diskret. (b) logarithmisch. (c) logarithmisch (bzw. diskret kumuliert über Produkt). (d) logarithmisch (statistische Eigenschaften)."},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 11.1 — Das risikofreie Portfolio","anker":"finde-den-denkfehler-11.1-das-risikofreie-portfolio","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-finanzdaten","text":"1 — (b) bei 75 %. 1{,}0 \\cdot 0{,}5 \\cdot 1{,}5 = 0{,}75. Prozentuale Änderungen verknüpfen sich multiplikativ; ein Verlust wiegt schwerer als ein gleich großer Gewinn, weil er von"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-finanzdaten","text":""},{"seite":"anhang-loesungen.html","titel":"A.19 Lösungen zu Kapitel „Die moderne Portfoliotheorie nach Markowitz“","anker":"sec:loesungen-markowitz","text":"12.1 — Diversifikationseffekt."},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 12.1 — Zwölf gleiche Anlagen, ein sehr ungleiches Portfolio","anker":"finde-den-denkfehler-12.1-zwölf-gleiche-anlagen-ein-sehr-ungleiches-portfolio","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-markowitz","text":"1 — (b). Das renditestärkste Portfolio auf der Effizienzlinie hat notwendig das schlechteste Rendite-Risiko-Verhältnis (im Kapitelbeispiel 0,44 gegenüber 0,75 beim Optimum) und bes"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-markowitz","text":""},{"seite":"anhang-loesungen.html","titel":"A.20 Lösungen zu Kapitel „Tail-Risiko, CVaR und Transaktionskosten“","anker":"sec:loesungen-cvar","text":"13.1 — VaR und CVaR von Hand. Sortierte Verluste: -2{,}0; -1{,}2; -0{,}8; -0{,}4; 0{,}3; 0{,}6; 1{,}1; 2{,}1; 5{,}4; 8{,}9. Das 80 %-Quantil ist der 8. Wert: \\text{VaR}_{80\\%} = 2{"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 13.1 — Das Risikobudget, das durch Diversifikation stieg","anker":"finde-den-denkfehler-13.1-das-risikobudget-das-durch-diversifikation-stieg","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-cvar","text":"1 — (b) nichts. Der VaR ist ein Quantil: Er markiert die Schwelle und sagt nichts über den Bereich dahinter. Genau das zeigt der Schnellstart dieses Kapitels — zwei Anlagen mit ide"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-cvar","text":""},{"seite":"anhang-loesungen.html","titel":"A.21 Lösungen zu Kapitel „Die vollständige quantitative Handelsmaschine“","anker":"sec:loesungen-handelsmaschine","text":"14.1 — Lookahead erkennen. (a) sauber (:heute). (b) Lookahead — gesamter Zeitraum. (c) Lookahead — Volatilität über den ganzen Zeitraum enthält Zukunft. (d) sauber — Gewichte aus D"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 14.1 — Die Strategie mit dem hochsignifikanten Ergebnis","anker":"finde-den-denkfehler-14.1-die-strategie-mit-dem-hochsignifikanten-ergebnis","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-handelsmaschine","text":"1 — (b) nichts. Beide Kennzahlen sind Mittelwerte über die Zeit und blind für die Reihenfolge. Der Schnellstart dieses Kapitels zeigt es an denselben Tagesrenditen, nur anders ange"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-handelsmaschine","text":""},{"seite":"anhang-loesungen.html","titel":"A.22 Lösungen zu Kapitel „Praxisfallen und der Weg zum produktiven Einsatz“","anker":"sec:loesungen-praxisfallen","text":"15.1 — Hart oder weich, revisited. (a) hart: gesetzliche Ruhezeit, Qualifikationspflicht. (b) weich: individuelle Wunschtage, Vermeidung von Freistunden. (c) diskutabel: Höchstzahl"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 15.1 — Das Modell, das seit einem Jahr nicht mehr optimiert","anker":"finde-den-denkfehler-15.1-das-modell-das-seit-einem-jahr-nicht-mehr-optimiert","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-praxisfallen","text":"1 — (b) das Zeitlimit greift. Eine Laufzeit, die exakt dem Limit entspricht und über Monate konstant bleibt, ist kein Stabilitätsbeleg, sondern zeigt, dass der Solver jedes Mal abg"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-praxisfallen","text":""},{"seite":"anhang-loesungen.html","titel":"A.23 Lösungen zu Kapitel „Testen, Messen, Ausliefern“","anker":"sec:loesungen-testing","text":"Den Schnellstart reparieren. Zwei Wege: (a) Abrunden statt runden — np.floor statt np.round. Das ist immer zulässig, weil weniger produzieren nie eine Kapazität sprengt, kostet abe"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Die Suite ist grün, das Modell stimmt”","anker":"denkfehler-loesung-testing","text":"Alle Prüfungen benutzen dieselbe falsche Zahl."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-testing","text":"1. b) Es gibt keine unabhängige Quelle für die richtige Antwort — sonst bräuchte man den Solver nicht. (a) ist bei festgelegtem Seed und Zeitlimit meist beherrschbar; (c) löst man "},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-testing","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"Anhang B: Katalog der Modellierungsmuster","anker":null,"text":""},{"seite":"anhang-modellierungsmuster.html","titel":"Übersicht","anker":"übersicht","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"Logische Schalter","anker":"logische-schalter","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B1 — Aktivierungsschalter (Fixkosten)","anker":"b1-aktivierungsschalter-fixkosten","text":"Regel. „Wenn überhaupt etwas produziert wird, fallen Rüstkosten F an.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B2 — Semikontinuierliche Variable","anker":"b2-semikontinuierliche-variable","text":"Regel. „Entweder gar nicht oder mindestens L (und höchstens U).“"},{"seite":"anhang-modellierungsmuster.html","titel":"B3 — Implikation","anker":"b3-implikation","text":"Regel. „Wenn A gewählt wird, muss auch B gewählt werden.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B4 — Entweder-Oder (disjunktive Bedingung)","anker":"b4-entweder-oder-disjunktive-bedingung","text":"Regel. „Es muss f(\\mathbf{x}) \\le b_1 oder g(\\mathbf{x}) \\le b_2 gelten.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B5 — Exklusiv-Oder","anker":"b5-exklusiv-oder","text":"Regel. „Genau eine der Optionen wird gewählt.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B6 — Kardinalität","anker":"b6-kardinalität","text":"Regel. „Höchstens (mindestens, genau) K von N.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B7 — Bedingte Kopplung (Konjunktion)","anker":"b7-bedingte-kopplung-konjunktion","text":"Regel. „Wenn A und B, dann auch C.“"},{"seite":"anhang-modellierungsmuster.html","titel":"Mengen und Grenzen","anker":"mengen-und-grenzen","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B8 — Weiche Grenze mit Strafkosten","anker":"b8-weiche-grenze-mit-strafkosten","text":"Regel. „Möglichst nicht über b — wenn doch, kostet es.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B9 — Gestaffelte Preise (stückweise linear)","anker":"b9-gestaffelte-preise-stückweise-linear","text":"Regel. „Die ersten 100 Stück kosten 5 €, danach 8 €.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B10 — Absolutbetrag / Abweichung","anker":"b10-absolutbetrag-abweichung","text":"Regel. „Die Abweichung vom Zielwert soll klein sein, egal in welche Richtung.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B11 — Min/Max in der Zielfunktion","anker":"b11-minmax-in-der-zielfunktion","text":"Regel. „Der am schlechtesten gestellte Beteiligte soll möglichst gut dastehen“ (Maximin/Fairness)."},{"seite":"anhang-modellierungsmuster.html","titel":"B12 — Verhältnis-Bedingung","anker":"b12-verhältnis-bedingung","text":"Regel. „Der Anteil von Gruppe G soll mindestens 30 % betragen.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B25 — Mindestabnahmemenge über einen Zeitraum","anker":"b25-mindestabnahmemenge-über-einen-zeitraum","text":"Regel. „Entweder wir arbeiten mit diesem Lieferanten gar nicht, oder wir nehmen ihm im Jahr mindestens 500 Einheiten ab.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B27 — Budgetlimit","anker":"b27-budgetlimit","text":"Regel. „Alle Maßnahmen zusammen dürfen 2 Mio. € nicht überschreiten.“"},{"seite":"anhang-modellierungsmuster.html","titel":"Zeit und Reihenfolge","anker":"zeit-und-reihenfolge","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B13 — Vorrangbeziehung","anker":"b13-vorrangbeziehung","text":"Regel. „Arbeitsgang B darf erst beginnen, wenn A fertig ist.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B14 — Nichtüberlappung","anker":"b14-nichtüberlappung","text":"Regel. „Eine Maschine bearbeitet nur einen Job gleichzeitig.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B15 — Kumulative Ressource","anker":"b15-kumulative-ressource","text":"Regel. „Zu keinem Zeitpunkt dürfen mehr als 3 Arbeiten gleichzeitig laufen“ bzw. „die Stromlast darf 500 kW nie übersteigen“."},{"seite":"anhang-modellierungsmuster.html","titel":"B16 — Gleitendes Fenster","anker":"b16-gleitendes-fenster","text":"Regel. „Höchstens 5 Arbeitstage in Folge“ / „mindestens 2 freie Tage je Woche“."},{"seite":"anhang-modellierungsmuster.html","titel":"B17 — Umrüst- bzw. Wechselkosten","anker":"b17-umrüst--bzw.-wechselkosten","text":"Regel. „Ein Produktwechsel kostet Rüstzeit.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B26 — Rüstzeit als Kapazitätsverbrauch","anker":"b26-rüstzeit-als-kapazitätsverbrauch","text":"Regel. „Das Umrüsten kostet nicht nur Geld, es kostet Maschinenstunden — und die fehlen dann für die Produktion.“"},{"seite":"anhang-modellierungsmuster.html","titel":"Robustheit und Diagnose","anker":"robustheit-und-diagnose","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B18 — Schlupfvariablen gegen Unlösbarkeit","anker":"b18-schlupfvariablen-gegen-unlösbarkeit","text":"Das wichtigste Muster für den Produktivbetrieb."},{"seite":"anhang-modellierungsmuster.html","titel":"B19 — Hierarchische Ziele (lexikografisch)","anker":"b19-hierarchische-ziele-lexikografisch","text":"Regel. „Erst die Besetzung sicherstellen, dann die Fairness optimieren.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B20 — Symmetriebrechung","anker":"b20-symmetriebrechung","text":"Regel. „Drei identische Maschinen — der Solver soll nicht alle Vertauschungen durchprobieren.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B21 — Worst-Case-Abzug (robuste Formulierung)","anker":"b21-worst-case-abzug-robuste-formulierung","text":"Regel. „Rechne nicht mit dem geschätzten Wert, sondern mit dem ungünstigsten plausiblen.“"},{"seite":"anhang-modellierungsmuster.html","titel":"Netzwerke","anker":"netzwerke","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B22 — Flusserhaltung","anker":"b22-flusserhaltung","text":"\\sum_{j:(i,j)\\in E} x_{ij} - \\sum_{k:(k,i)\\in E} x_{ki} = b_i \\qquad \\forall i"},{"seite":"anhang-modellierungsmuster.html","titel":"B23 — Zuordnung 1:1","anker":"b23-zuordnung-11","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B24 — Subtour-Eliminierung","anker":"b24-subtour-eliminierung","text":"MTZ (einfach, aber schwach): u_i - u_j + C x_{ij} \\le C - d_j \\qquad \\forall i\\ne j"},{"seite":"anhang-modellierungsmuster.html","titel":"Ein Wort zur Auswahl","anker":"ein-wort-zur-auswahl","text":"Wenn mehrere Muster passen, entscheiden Sie nach dieser Reihenfolge:"},{"seite":"anhang-fehlerdiagnose.html","titel":"Anhang C: Fehlerdiagnose-Handbuch","anker":null,"text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"Symptom-Schnellübersicht","anker":"symptom-schnellübersicht","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"C1 — INFEASIBLE","anker":"c1-infeasible","text":"Bedeutung. Es gibt keinen einzigen Punkt, der alle Bedingungen gleichzeitig erfüllt. Das ist eine Aussage über Ihr Modell, nicht über den Solver."},{"seite":"anhang-fehlerdiagnose.html","titel":"Diagnose in fünf Schritten","anker":"diagnose-in-fünf-schritten","text":"Schritt 1 — Trivialprüfungen von Hand. Rechnen Sie die offensichtlichen Bilanzen nach, bevor Sie den Solver befragen:"},{"seite":"anhang-fehlerdiagnose.html","titel":"Wenn die fünf Schritte nicht reichen: den Konflikt einkreisen","anker":"wenn-die-fünf-schritte-nicht-reichen-den-konflikt-einkreisen","text":"Warum Schritt 2 so oft ins Leere läuft. Bedingungen einzeln abzuschalten funktioniert nur, solange es einen Schuldigen gibt. Überlagern sich zwei unabhängige Widersprüche, bleibt d"},{"seite":"anhang-fehlerdiagnose.html","titel":"C2 — UNBOUNDED","anker":"c2-unbounded","text":"Bedeutung. Die Zielfunktion lässt sich unbegrenzt verbessern."},{"seite":"anhang-fehlerdiagnose.html","titel":"C3 — Zu langsam","anker":"c3-zu-langsam","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"Diagnosereihenfolge","anker":"diagnosereihenfolge","text":"1. Ist es überhaupt das Lösen?"},{"seite":"anhang-fehlerdiagnose.html","titel":"C4 — Unsinniges Ergebnis","anker":"c4-unsinniges-ergebnis","text":"Der Solver hat immer recht — bezogen auf das Modell, das Sie ihm gegeben haben. Wenn das Ergebnis unsinnig ist, beschreibt Ihr Modell nicht das Problem, das Sie meinen."},{"seite":"anhang-fehlerdiagnose.html","titel":"Checkliste","anker":"checkliste","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"Die wirksamste Gegenmaßnahme","anker":"die-wirksamste-gegenmaßnahme","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"C5 — Instabile Lösung","anker":"c5-instabile-lösung","text":"Symptom. Ein zusätzlicher Handelstag, und das Portfolio sieht völlig anders aus."},{"seite":"anhang-fehlerdiagnose.html","titel":"Derselbe Fehler außerhalb der Finanzwelt","anker":"derselbe-fehler-außerhalb-der-finanzwelt","text":"Instabilität ist kein Portfolioproblem, sondern ein Skalierungsproblem — und es trifft jedes Modell, in dem Größen sehr verschiedener Ordnung nebeneinanderstehen. Euro-Beträge (10^"},{"seite":"anhang-fehlerdiagnose.html","titel":"C6 — Falsche Dualwerte","anker":"c6-falsche-dualwerte","text":"Symptom. Schattenpreise sind negativ, wo sie positiv sein sollten — oder null bei einer offensichtlich knappen Ressource."},{"seite":"anhang-fehlerdiagnose.html","titel":"Die drei Prüfungen","anker":"die-drei-prüfungen","text":"1. Vorzeichenkonvention. Haben Sie zur Maximierung negiert?"},{"seite":"anhang-fehlerdiagnose.html","titel":"C7 — Widersprüchliche Solver","anker":"c7-widersprüchliche-solver","text":"Symptom. linprog sagt 530, CVXPY sagt 529,8."},{"seite":"anhang-fehlerdiagnose.html","titel":"C8 — DCPError","anker":"c8-dcperror","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"C9 — Importfehler","anker":"c9-importfehler","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"C10 — Verdächtig guter Backtest","anker":"c10-verdächtig-guter-backtest","text":"Faustregel: Eine Sharpe Ratio über 2 bei einer einfachen Strategie ist fast immer ein Fehler, kein Fund."},{"seite":"anhang-fehlerdiagnose.html","titel":"Prüfreihenfolge","anker":"prüfreihenfolge","text":"1. Lookahead-Selbsttest."},{"seite":"anhang-fehlerdiagnose.html","titel":"C11 — Vertauschte Spalten","anker":"c11-vertauschte-spalten","text":"Symptom. Ergebnisse sind plausibel, aber falsch beschriftet — oder eine Sektorgrenze greift auf die falschen Titel."},{"seite":"anhang-fehlerdiagnose.html","titel":"Die goldene Diagnoseregel","anker":"die-goldene-diagnoseregel","text":""},{"seite":"anhang-spickzettel.html","titel":"Anhang D: Spickzettel der Solver","anker":null,"text":""},{"seite":"anhang-spickzettel.html","titel":"D1 — SciPy: linprog und milp","anker":"d1-scipy-linprog-und-milp","text":"Wofür. Die Einstiegsschicht: keine zusätzliche Installation, HiGHS als Unterbau, ideal für lineare und gemischt-ganzzahlige Probleme in Matrixform. Wofür nicht: alles, was sich nic"},{"seite":"anhang-spickzettel.html","titel":"Lineares Programm","anker":"lineares-programm","text":""},{"seite":"anhang-spickzettel.html","titel":"Ganzzahlig: milp","anker":"ganzzahlig-milp","text":""},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine","text":""},{"seite":"anhang-spickzettel.html","titel":"D2 — HiGHS über highspy","anker":"d2-highs-über-highspy","text":"Wofür. Derselbe Solver wie unter SciPy, aber direkt gesteuert: Optionen, Warm-Starts, inkrementelles Ändern eines bestehenden Modells. Wofür nicht: schnelles Hinschreiben — die CSR"},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine-1","text":""},{"seite":"anhang-spickzettel.html","titel":"D3 — OR-Tools: pywraplp","anker":"d3-or-tools-pywraplp","text":"Wofür. Bequeme algebraische Schreibweise für LP und MILP mit umschaltbarem Backend (GLOP, SCIP, CBC, SAT). Wofür nicht: Scheduling und kombinatorische Bedingungen — dafür ist CP-SA"},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine-2","text":""},{"seite":"anhang-spickzettel.html","titel":"D4 — CP-SAT: cp_model","anker":"d4-cp-sat-cp_model","text":"Wofür. Scheduling, Zuordnung, Reihenfolgen, alles Kombinatorische mit globalen Bedingungen. Wofür nicht: stetige Größen — CP-SAT rechnet ausschließlich ganzzahlig. Wer Nachkommaste"},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine-3","text":""},{"seite":"anhang-spickzettel.html","titel":"D5 — CVXPY","anker":"d5-cvxpy","text":"Wofür. Konvexe Probleme: quadratische Ziele, Normen, CVaR, alles mit Regularisierungstermen. Wofür nicht: große kombinatorische Modelle — der Aufbau der Ausdrücke wird dann selbst "},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine-4","text":""},{"seite":"anhang-spickzettel.html","titel":"D6 — Modellierungssprachen: Pyomo und Linopy","anker":"d6-modellierungssprachen-pyomo-und-linopy","text":"Wofür. Beide trennen Modell von Solver: dasselbe Modell läuft ohne Änderung unter HiGHS, CBC, Gurobi. Pyomo denkt in Mengen und Indizes wie eine mathematische Formulierung; Linopy "},{"seite":"anhang-spickzettel.html","titel":"Pyomo","anker":"pyomo","text":""},{"seite":"anhang-spickzettel.html","titel":"Linopy","anker":"linopy","text":""},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine-5","text":""},{"seite":"anhang-spickzettel.html","titel":"Die gemeinsame Regel","anker":"die-gemeinsame-regel","text":"Alle sechs Seiten haben denselben längsten Abschnitt: die Statusauswertung. Das ist kein Zufall. Ein Solveraufruf hat nie zwei Ausgänge, sondern mindestens fünf — optimal, zulässig"},{"seite":"anhang-glossar-literatur.html","titel":"Anhang E: Glossar und Literatur","anker":null,"text":""},{"seite":"anhang-glossar-literatur.html","titel":"Mathematische Optimierung — Grundlagen","anker":"mathematische-optimierung-grundlagen","text":""},{"seite":"anhang-glossar-literatur.html","titel":"Konvexe, nichtlineare und robuste Optimierung","anker":"konvexe-nichtlineare-und-robuste-optimierung","text":""},{"seite":"anhang-glossar-literatur.html","titel":"Constraint Programming und Scheduling","anker":"constraint-programming-und-scheduling","text":""},{"seite":"anhang-glossar-literatur.html","titel":"Quantitative Finanzmathematik","anker":"quantitative-finanzmathematik","text":""},{"seite":"anhang-glossar-literatur.html","titel":"Software und Dokumentation","anker":"software-und-dokumentation","text":""},{"seite":"anhang-glossar-literatur.html","titel":"Verbände und Normen","anker":"verbände-und-normen","text":""}];
+window.OR_SEARCH_INDEX = [{"seite":"vorwort.html","titel":"Vorwort & Lesehilfe","anker":null,"text":""},{"seite":"notation.html","titel":"Notation & Abkürzungen","anker":null,"text":""},{"seite":"notation.html","titel":"1. Die Symbole der Optimierung","anker":"die-symbole-der-optimierung","text":""},{"seite":"notation.html","titel":"1.1 Grundgrößen jedes Modells","anker":"grundgrößen-jedes-modells","text":""},{"seite":"notation.html","titel":"1.2 Mengen und Zahlbereiche","anker":"mengen-und-zahlbereiche","text":""},{"seite":"notation.html","titel":"1.3 Operatoren und Schreibweisen","anker":"operatoren-und-schreibweisen","text":""},{"seite":"notation.html","titel":"1.4 Symbole der Finanzkapitel (Teil IV)","anker":"symbole-der-finanzkapitel-teil-iv","text":""},{"seite":"notation.html","titel":"1.5 Symbole der Verfahrenskapitel (Teil II und III)","anker":"symbole-der-verfahrenskapitel-teil-ii-und-iii","text":"Diese Zeichen kommen erst in den Kapiteln vor, die über das klassische LP hinausgehen."},{"seite":"notation.html","titel":"2. Abkürzungen — vollständig ausgeschrieben","anker":"abkürzungen-vollständig-ausgeschrieben","text":""},{"seite":"notation.html","titel":"2.1 Problemklassen und Verfahren","anker":"problemklassen-und-verfahren","text":""},{"seite":"notation.html","titel":"2.2 Probleme mit Eigennamen","anker":"probleme-mit-eigennamen","text":""},{"seite":"notation.html","titel":"2.3 Finanz- und Risikokennzahlen","anker":"finanz--und-risikokennzahlen","text":""},{"seite":"notation.html","titel":"2.4 Software, Solver und Schnittstellen","anker":"software-solver-und-schnittstellen","text":""},{"seite":"notation.html","titel":"2.5 Begriffe aus dem Solver-Alltag","anker":"begriffe-aus-dem-solver-alltag","text":""},{"seite":"notation.html","titel":"3. Konventionen in diesem Buch","anker":"konventionen-in-diesem-buch","text":"Vektoren sind grundsätzlich Spaltenvektoren und werden fett gesetzt: \\mathbf{x}. Ein transponierter Vektor \\mathbf{x}^\\top ist eine Zeile. Skalare bleiben mager: n, Z."},{"seite":"einfuehrung.html","titel":"Kapitel 1: Einführung in Operations Research — Vom Ursprung zur mathematischen Entscheidungsfindung","anker":null,"text":""},{"seite":"einfuehrung.html","titel":"1.1 In 5 Minuten gelöst","anker":"sec:einfuehrung-schnellstart","text":"Bevor wir über Theorie sprechen, lösen Sie ein echtes Problem. Kopieren Sie den folgenden Block, führen Sie ihn aus, sehen Sie das Ergebnis — die Erklärung kommt danach."},{"seite":"einfuehrung.html","titel":"1.2 Lernziele","anker":"sec:einfuehrung-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"einfuehrung.html","titel":"1.3 Was ist Operations Research wirklich?","anker":"sec:einfuehrung-was-ist-operations-research-wirklich","text":"Im Alltag lösen wir Probleme intuitiv: durch Ausprobieren, Daumenregeln, Erfahrung. Das funktioniert erstaunlich gut — bis eine bestimmte Schwelle überschritten wird. Dann bricht d"},{"seite":"einfuehrung.html","titel":"Die drei Stufen der Analytik","anker":"die-drei-stufen-der-analytik","text":"OR beantwortet nicht die beschreibende Frage „Was ist passiert?“ und nicht die vorhersagende Frage „Was wird passieren?“, sondern die vorschreibende (präskriptive) Kernfrage:"},{"seite":"einfuehrung.html","titel":"1.4 Warum Ausprobieren scheitert — mit eigener Rechnung","anker":"sec:einfuehrung-warum-ausprobieren-scheitert-mit-eigener-rechnung","text":"Die Behauptung „vollständiges Durchprobieren ist unmöglich“ wird oft aufgestellt und selten belegt. Rechnen wir sie einmal selbst nach — das Ergebnis prägt sich besser ein als jede"},{"seite":"einfuehrung.html","titel":"1.5 Historischer Kontext und Evolution","anker":"sec:einfuehrung-historischer-kontext-und-evolution","text":"Der Begriff entstand im Vorfeld des Zweiten Weltkriegs in Großbritannien. Militärische Führungsstäbe standen vor neuartigen logistischen und strategischen Fragen: Wie platziert man"},{"seite":"einfuehrung.html","titel":"1.6 Die vier universellen Bausteine jedes OR-Problems","anker":"sec:einfuehrung-die-vier-universellen-bausteine-jedes-or","text":"Dies ist der wichtigste Abschnitt des Kapitels. Jedes Optimierungsproblem der Welt lässt sich — unabhängig von der Branche — auf vier Elemente reduzieren. Wenn Sie später vor einem"},{"seite":"einfuehrung.html","titel":"Baustein 1 — Entscheidungsvariablen (x)","anker":"baustein-1-entscheidungsvariablen-x","text":"Die Stellschrauben des Modells: Werte, die der Algorithmus aktiv festlegen darf."},{"seite":"einfuehrung.html","titel":"Baustein 2 — Parameter / Eingabedaten (c, A, b)","anker":"baustein-2-parameter-eingabedaten-c-a-b","text":"Die unveränderlichen Fakten der Realität: historische Renditen, Maschinenstundensätze, Lagerkapazitäten, Steuerquoten, Entfernungen, Qualifikationen."},{"seite":"einfuehrung.html","titel":"Baustein 3 — Zielfunktion (f(x))","anker":"baustein-3-zielfunktion-fx","text":"Ein mathematischer Ausdruck, der die Güte einer Lösung zu einer einzigen Zahl verdichtet:"},{"seite":"einfuehrung.html","titel":"Baustein 4 — Nebenbedingungen (Constraints)","anker":"baustein-4-nebenbedingungen-constraints","text":"Regeln, die den Raum der erlaubten Lösungen einschränken:"},{"seite":"einfuehrung.html","titel":"Die vier Bausteine als eine Formel","anker":"die-vier-bausteine-als-eine-formel","text":"Setzt man die vier Bausteine zusammen, entsteht die Standardform, in der Ihnen jedes Optimierungsmodell dieses Buches begegnen wird. Sie sieht auf den ersten Blick sperrig aus — de"},{"seite":"einfuehrung.html","titel":"Die Bausteine in der Praxis: eine Vorlage","anker":"die-bausteine-in-der-praxis-eine-vorlage","text":"Damit die Zerlegung zur Gewohnheit wird, hier eine Vorlage, die Sie bei jedem neuen Problem ausfüllen können — vor der ersten Codezeile."},{"seite":"einfuehrung.html","titel":"1.7 Erstes Python-Vollbeispiel: Ressourcenallokation im Rechenzentrum","anker":"sec:einfuehrung-erstes-python-vollbeispiel-ressourcenallokation-im-rechenzentrum","text":""},{"seite":"einfuehrung.html","titel":"Szenario","anker":"szenario","text":"Ein quantitatives Handelshaus betreibt Serverkapazitäten für zwei Algorithmentypen:"},{"seite":"einfuehrung.html","titel":"Mathematische Formulierung","anker":"mathematische-formulierung","text":"Entscheidungsvariablen:"},{"seite":"einfuehrung.html","titel":"Umsetzung mit Google OR-Tools","anker":"umsetzung-mit-google-or-tools","text":""},{"seite":"einfuehrung.html","titel":"1.8 Von Excel zu Python: Ihre Daten liegen schon da","anker":"sec:einfuehrung-excel-bruecke","text":"In den allermeisten Betrieben liegen die Zahlen, die ein Optimierungsmodell braucht, nicht in einer Datenbank, sondern in einer Tabellenkalkulation. Und viele Leserinnen und Leser "},{"seite":"einfuehrung.html","titel":"1.9 Übungsaufgaben","anker":"sec:einfuehrung-uebungsaufgaben","text":""},{"seite":"einfuehrung.html","titel":"1.10 Finde den Denkfehler","anker":"sec:einfuehrung-denkfehler","text":"Ein Programm, das abstürzt, ist harmlos — Sie sehen sofort, dass etwas nicht stimmt. Gefährlich sind Programme, die fehlerfrei durchlaufen, einen Status OPTIMAL melden und eine Zah"},{"seite":"einfuehrung.html","titel":"1.11 Micro-Quiz","anker":"sec:einfuehrung-quiz","text":""},{"seite":"einfuehrung.html","titel":"1.12 Selbsttest","anker":"sec:einfuehrung-selbsttest","text":""},{"seite":"einfuehrung.html","titel":"1.13 Zusammenfassung","anker":"sec:einfuehrung-zusammenfassung","text":""},{"seite":"fundament.html","titel":"Kapitel 2: Das mathematische Fundament — Vektoren, Matrizen, Konvexität","anker":null,"text":""},{"seite":"fundament.html","titel":"2.1 In 5 Minuten gelöst","anker":"sec:fundament-schnellstart","text":"Dieses Kapitel handelt von Geometrie. Bevor wir Vektoren und Matrizen einführen, sehen Sie die zentrale Einsicht in Aktion — ganz ohne Solver, mit acht Zeilen NumPy."},{"seite":"fundament.html","titel":"2.2 Lernziele","anker":"sec:fundament-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"fundament.html","titel":"2.3 Warum überhaupt Vektoren und Matrizen?","anker":"sec:fundament-warum-ueberhaupt-vektoren-und-matrizen","text":"In Kapitel 1 hatten wir zwei Variablen und drei Nebenbedingungen. Das ließ sich bequem ausschreiben. Reale Modelle haben hunderte bis hunderttausende Variablen — dort wäre Ausschre"},{"seite":"fundament.html","titel":"Die Bausteine","anker":"die-bausteine","text":"Sei n die Anzahl der Entscheidungsvariablen und m die Anzahl der Nebenbedingungen."},{"seite":"fundament.html","titel":"Die kanonische Standardform","anker":"die-kanonische-standardform","text":"Damit lässt sich jedes lineare Programm in drei Zeilen schreiben:"},{"seite":"fundament.html","titel":"Dasselbe in Python","anker":"dasselbe-in-python","text":""},{"seite":"fundament.html","titel":"2.4 Der zulässige Lösungsraum und das Polyeder","anker":"sec:fundament-der-zulaessige-loesungsraum-und-das-polyeder","text":"Jede lineare Ungleichung a_{i1}x_1 + a_{i2}x_2 \\le b_i definiert geometrisch eine Halbebene (im \\mathbb{R}^2) bzw. einen Halbraum (im \\mathbb{R}^n): Die zugehörige Gleichung ist ei"},{"seite":"fundament.html","titel":"Der Fundamentalsatz der linearen Optimierung","anker":"der-fundamentalsatz-der-linearen-optimierung","text":""},{"seite":"fundament.html","titel":"2.5 Konvexität: die Grenze zwischen leicht und schwer","anker":"sec:fundament-konvexitaet-die-grenze-zwischen-leicht-und","text":"Hier kommt die vielleicht wichtigste Einsicht des ganzen Buches. In der Optimierungstheorie verläuft die Trennlinie zwischen „zuverlässig lösbar“ und „im Allgemeinen hoffnungslos“ "},{"seite":"fundament.html","titel":"Konvexe Menge","anker":"konvexe-menge","text":"Eine Menge \\mathcal{C} \\subseteq \\mathbb{R}^n heißt konvex, wenn für alle Punkte \\mathbf{x}, \\mathbf{y} \\in \\mathcal{C} und jedes \\theta \\in [0,1] gilt:"},{"seite":"fundament.html","titel":"Konvexe Funktion","anker":"konvexe-funktion","text":"Eine Funktion f: \\mathcal{C} \\to \\mathbb{R} heißt konvex, wenn für alle \\mathbf{x}, \\mathbf{y} \\in \\mathcal{C} und \\theta \\in [0,1] gilt:"},{"seite":"fundament.html","titel":"Der zentrale Satz","anker":"der-zentrale-satz","text":""},{"seite":"fundament.html","titel":"Was ist konvex, was nicht?","anker":"was-ist-konvex-was-nicht","text":""},{"seite":"fundament.html","titel":"Konvexität sichtbar machen","anker":"konvexität-sichtbar-machen","text":""},{"seite":"fundament.html","titel":"2.6 Geometrische Visualisierung des Lösungsraums","anker":"sec:fundament-geometrische-visualisierung-des-loesungsraums","text":"Wir zeichnen nun den Lösungsraum aus Kapitel 1 und lesen das Optimum ab. Zweidimensionale Bilder sind der schnellste Weg, Intuition für höhere Dimensionen aufzubauen — auch wenn ma"},{"seite":"fundament.html","titel":"2.7 Wenn die Zahlen nicht zusammenpassen: Kondition und Skalierung","anker":"sec:fundament-kondition","text":"Bis hierher war die lineare Algebra exakt. Ein Rechner rechnet aber nicht exakt, sondern mit rund 16 signifikanten Stellen — und Ihre Betriebsdaten sind ohnehin nur auf wenige Stel"},{"seite":"fundament.html","titel":"Die geometrische Vorstellung: zwei fast parallele Linien","anker":"die-geometrische-vorstellung-zwei-fast-parallele-linien","text":"Zwei Nebenbedingungen legen im Zweidimensionalen einen Schnittpunkt fest. Stehen die zugehörigen Geraden kreuzweise aufeinander, ist der Schnittpunkt robust: Verschiebt man eine Ge"},{"seite":"fundament.html","titel":"Woher schlechte Kondition im Alltag kommt","anker":"woher-schlechte-kondition-im-alltag-kommt","text":"Fast nie aus exotischer Mathematik — fast immer aus Einheiten:"},{"seite":"fundament.html","titel":"Ruiz-Equilibrierung: das Modell gesundrechnen","anker":"ruiz-equilibrierung-das-modell-gesundrechnen","text":"Die Gegenmaßnahme ist erstaunlich schlicht. Man multipliziert jede Zeile und jede Spalte der Matrix mit einem Faktor, sodass die Beträge überall in derselben Größenordnung landen:"},{"seite":"fundament.html","titel":"Das Experiment","anker":"das-experiment","text":"Das folgende Programm führt beides vor — die Fehlerverstärkung und ihre Behebung — und schließt mit der praktischen Konsequenz, die Sie in Kapitel 6 wieder brauchen werden: warum e"},{"seite":"fundament.html","titel":"2.8 Übungsaufgaben","anker":"sec:fundament-uebungsaufgaben","text":""},{"seite":"fundament.html","titel":"2.9 Finde den Denkfehler","anker":"sec:fundament-denkfehler","text":""},{"seite":"fundament.html","titel":"2.10 Micro-Quiz","anker":"sec:fundament-quiz","text":""},{"seite":"fundament.html","titel":"2.11 Selbsttest","anker":"sec:fundament-selbsttest","text":""},{"seite":"fundament.html","titel":"2.12 Zusammenfassung","anker":"sec:fundament-zusammenfassung","text":""},{"seite":"oekosystem.html","titel":"Kapitel 3: Das Python-Ökosystem für OR — Solver, Bindings und Modellierungsschichten","anker":null,"text":""},{"seite":"oekosystem.html","titel":"3.1 In 5 Minuten gelöst","anker":"sec:oekosystem-schnellstart","text":""},{"seite":"oekosystem.html","titel":"3.2 Lernziele","anker":"sec:oekosystem-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"oekosystem.html","titel":"3.3 Die Zwei-Schichten-Architektur","anker":"sec:oekosystem-die-zwei-schichten-architektur","text":"Im modernen Operations Research schreibt man Optimierungsalgorithmen nicht selbst. Man nutzt eine zweischichtige Architektur:"},{"seite":"oekosystem.html","titel":"3.4 Die Werkzeuge im Vergleich","anker":"sec:oekosystem-die-werkzeuge-im-vergleich","text":""},{"seite":"oekosystem.html","titel":"Die Entscheidung in drei Fragen","anker":"die-entscheidung-in-drei-fragen","text":"Statt die Tabelle auswendig zu lernen, beantworten Sie drei Fragen:"},{"seite":"oekosystem.html","titel":"3.5 Ein System — vier Programmieransätze","anker":"sec:oekosystem-ein-system-vier-programmieransaetze","text":"Nichts macht die Unterschiede so deutlich wie dasselbe Problem, viermal gelöst. Wir nehmen:"},{"seite":"oekosystem.html","titel":"Was das CSR-Format bedeutet","anker":"was-das-csr-format-bedeutet","text":"highspy erwartet die Nebenbedingungsmatrix im CSR-Format (Compressed Sparse Row, komprimierte Zeilendarstellung). Statt der vollen Matrix speichert man nur die Einträge ungleich nu"},{"seite":"oekosystem.html","titel":"3.6 Wann lohnt sich welche Ebene?","anker":"sec:oekosystem-wann-lohnt-sich-welche-ebene","text":""},{"seite":"oekosystem.html","titel":"3.7 Modellierungsschichten für große Modelle: Pyomo und Linopy","anker":"sec:oekosystem-pyomo-linopy","text":"Die vier Bibliotheken aus dem Vierfach-Vergleich decken den Alltag weitgehend ab. Sobald Modelle industrielle Größe erreichen — zehntausende Variablen, Daten aus mehreren Systemen,"},{"seite":"oekosystem.html","titel":"Pyomo: die algebraische Denkweise","anker":"pyomo-die-algebraische-denkweise","text":"Pyomo ist im deutschsprachigen Raum der De-facto-Standard für große LP- und MILP-Modelle in Energiewirtschaft, Chemie und Logistik. Sein Kennzeichen: Es denkt in Mengen und Indizes"},{"seite":"oekosystem.html","titel":"Linopy: eine Zeile, zehntausend Nebenbedingungen","anker":"linopy-eine-zeile-zehntausend-nebenbedingungen","text":"Linopy verfolgt einen anderen Ansatz: Variablen sind beschriftete Arrays (xarray), keine indizierten Einzelobjekte. Damit wird aus"},{"seite":"oekosystem.html","titel":"Die Schichten im Überblick","anker":"die-schichten-im-überblick","text":""},{"seite":"oekosystem.html","titel":"3.8 Wo die Zeit wirklich hingeht: vektorisierte Modellgenerierung","anker":"sec:oekosystem-vektorisierung","text":"Eine der hartnäckigsten Fehlannahmen in Optimierungsprojekten lautet: „Wenn es zu langsam ist, brauchen wir einen besseren Solver.“ Messen Sie erst — oft stimmt das nicht."},{"seite":"oekosystem.html","titel":"Vier Stufen an einem Transportproblem","anker":"vier-stufen-an-einem-transportproblem","text":"Wir bauen dasselbe Transportproblem (m Werke, n Kunden, m \\cdot n Variablen) auf vier Arten auf:"},{"seite":"oekosystem.html","titel":"3.9 Übungsaufgaben","anker":"sec:oekosystem-uebungsaufgaben","text":""},{"seite":"oekosystem.html","titel":"3.10 Finde den Denkfehler","anker":"sec:oekosystem-denkfehler","text":""},{"seite":"oekosystem.html","titel":"3.11 Micro-Quiz","anker":"sec:oekosystem-quiz","text":""},{"seite":"oekosystem.html","titel":"3.12 Selbsttest","anker":"sec:oekosystem-selbsttest","text":""},{"seite":"oekosystem.html","titel":"3.13 Zusammenfassung","anker":"sec:oekosystem-zusammenfassung","text":""},{"seite":"modellierung.html","titel":"Kapitel 4: Vom Management-Wunsch zum Modell","anker":null,"text":""},{"seite":"modellierung.html","titel":"4.1 In 5 Minuten gelöst","anker":"sec:modellierung-schnellstart","text":""},{"seite":"modellierung.html","titel":"4.2 Lernziele","anker":"sec:modellierung-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"modellierung.html","titel":"4.3 Ein Satz ist noch kein Modell","anker":"sec:modellierung-vier-fragen","text":"Was aus einer Besprechung mitkommt, klingt meist so:"},{"seite":"modellierung.html","titel":"4.4 Derselbe Datensatz, fünf Modelle","anker":"sec:modellierung-fuenf-modelle","text":"Das Programm dieses Kapitels arbeitet die vier Fragen an einer Auftragsannahme ab: 14 Anfragen verlangen 800 Maschinenstunden, verfügbar sind 300."},{"seite":"modellierung.html","titel":"Teil 1: Drei Ziele, drei Pläne","anker":"teil-1-drei-ziele-drei-pläne","text":""},{"seite":"modellierung.html","titel":"Teil 2: Ein Satz, drei Lesarten","anker":"teil-2-ein-satz-drei-lesarten","text":"„Stammkunden dürfen wir nicht verlieren.” Jeder im Raum nickt. Niemand merkt, dass drei verschiedene Modelle gemeint sein können:"},{"seite":"modellierung.html","titel":"Teil 3: Hart oder weich","anker":"teil-3-hart-oder-weich","text":"Dieselbe Regel als Strafkosten je abgelehntem Stammkundenauftrag:"},{"seite":"modellierung.html","titel":"Teil 4: Ein Quartal später","anker":"teil-4-ein-quartal-später","text":"Die harte Lesart A wurde eingebaut, alles lief drei Monate lang. Dann fragt Weber einen Auftrag über 320 Maschinenstunden an — mehr, als der ganze Zeitraum hergibt."},{"seite":"modellierung.html","titel":"4.5 Der Gesprächsleitfaden","anker":"sec:modellierung-leitfaden","text":"Was aus den vier Teilen als Handwerkszeug bleibt — Fragen, die man in der ersten Besprechung stellt, bevor eine Zeile Code entsteht:"},{"seite":"modellierung.html","titel":"4.6 Übungsaufgaben","anker":"sec:modellierung-uebungsaufgaben","text":""},{"seite":"modellierung.html","titel":"4.7 Finde den Denkfehler","anker":"sec:modellierung-denkfehler","text":""},{"seite":"modellierung.html","titel":"4.8 Micro-Quiz","anker":"sec:modellierung-quiz","text":""},{"seite":"modellierung.html","titel":"4.9 Selbsttest","anker":"sec:modellierung-selbsttest","text":""},{"seite":"modellierung.html","titel":"4.10 Zusammenfassung","anker":"sec:modellierung-zusammenfassung","text":""},{"seite":"lp.html","titel":"Kapitel 5: Lineare Programmierung — Simplex, Dualität und Schattenpreise","anker":null,"text":""},{"seite":"lp.html","titel":"5.1 In 5 Minuten gelöst","anker":"sec:lp-schnellstart","text":"Bisher hat ein Solver Ihnen gesagt, was Sie tun sollen. Jetzt sagt er Ihnen zusätzlich, was eine zusätzliche Stunde wert wäre — und das ist die Zahl, für die Sie im Betrieb gehört "},{"seite":"lp.html","titel":"5.2 Lernziele","anker":"sec:lp-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"lp.html","titel":"5.3 Die Standardform und Schlupfvariablen","anker":"sec:lp-die-standardform-und-schlupfvariablen","text":"Lineare Programme zeichnen sich dadurch aus, dass Zielfunktion und alle Nebenbedingungen strikt linear sind — keine Produkte von Variablen, keine Quadrate, keine Logarithmen."},{"seite":"lp.html","titel":"5.4 Der Simplex-Algorithmus Schritt für Schritt","anker":"sec:lp-der-simplex-algorithmus-schritt-fuer-schritt","text":"Der von George Dantzig 1947 formulierte Simplex-Algorithmus nutzt den Fundamentalsatz aus Kapitel 2: Das Optimum liegt in einer Ecke. Statt alle Ecken aufzuzählen, wandert er von E"},{"seite":"lp.html","titel":"Der Ablauf","anker":"der-ablauf","text":""},{"seite":"lp.html","titel":"5.5 Das Simplex-Tableau in Python","anker":"sec:lp-das-simplex-tableau-in-python","text":""},{"seite":"lp.html","titel":"5.6 Dualität und Schattenpreise","anker":"sec:lp-dualitaet-und-schattenpreise","text":"Zu jedem linearen Optimierungsproblem (dem primalen Problem) existiert ein spiegelbildliches Zwillingsproblem: das duale Problem."},{"seite":"lp.html","titel":"Der starke Dualitätssatz","anker":"der-starke-dualitätssatz","text":""},{"seite":"lp.html","titel":"Der Schattenpreis als Ableitung","anker":"der-schattenpreis-als-ableitung","text":"Der Dualwert y_i^* ist die Grenzproduktivität der Ressource i:"},{"seite":"lp.html","titel":"5.7 Die Vorzeichenfalle bei Schattenpreisen","anker":"sec:lp-die-vorzeichenfalle-bei-schattenpreisen","text":"Diese Falle sollten Sie sich merken, weil sie in jedem Projekt wieder auftaucht."},{"seite":"lp.html","titel":"5.8 Praxisfall: Sensitivitätsanalyse mit korrekten Schattenpreisen","anker":"sec:lp-praxisfall-sensitivitaetsanalyse-mit-korrekten-schattenpreisen","text":"Szenario. Ein Fertigungsbetrieb stellt drei Produkte her und will wissen:"},{"seite":"lp.html","titel":"5.9 Wann Schattenpreise lügen: Entartung und Toleranzen","anker":"sec:lp-entartung","text":"Der vorige Abschnitt hat gezeigt, wie wertvoll Schattenpreise sind. Dieser zeigt die zwei Fälle, in denen sie eine Entscheidung nicht tragen — und wie man beide erkennt, bevor jema"},{"seite":"lp.html","titel":"Fall 1: Entartung","anker":"fall-1-entartung","text":"Erinnern Sie sich an Abschnitt 2.4: In zwei Dimensionen legen zwei sich schneidende Geraden eine Ecke fest, in drei Dimensionen drei Ebenen, allgemein n Restriktionen bei n Variabl"},{"seite":"lp.html","titel":"Fall 2: Toleranzen","anker":"fall-2-toleranzen","text":"Der zweite Fall ist unscheinbarer und deshalb häufiger. Wie stellt man fest, ob eine Ressource ausgelastet ist? Naheliegend wäre if schlupf == 0. Genau das ist falsch."},{"seite":"lp.html","titel":"Die ehrliche Auskunft: Schattenpreis-Spannen","anker":"die-ehrliche-auskunft-schattenpreis-spannen","text":"Was tut man also, wenn das Modell entartet ist? Man meldet keinen Einzelwert, sondern die Spanne. Die Menge aller optimalen Dualvektoren ist selbst ein Polyeder:"},{"seite":"lp.html","titel":"5.10 Übungsaufgaben","anker":"sec:lp-uebungsaufgaben","text":""},{"seite":"lp.html","titel":"5.11 Finde den Denkfehler","anker":"sec:lp-denkfehler","text":""},{"seite":"lp.html","titel":"5.12 Micro-Quiz","anker":"sec:lp-quiz","text":""},{"seite":"lp.html","titel":"5.13 Selbsttest","anker":"sec:lp-selbsttest","text":""},{"seite":"lp.html","titel":"5.14 Zusammenfassung","anker":"sec:lp-zusammenfassung","text":""},{"seite":"milp.html","titel":"Kapitel 6: Gemischt-ganzzahlige Optimierung — Diskrete Entscheidungen und Branch-and-Bound","anker":null,"text":""},{"seite":"milp.html","titel":"6.1 In 5 Minuten gelöst","anker":"sec:milp-schnellstart","text":""},{"seite":"milp.html","titel":"6.2 Lernziele","anker":"sec:milp-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"milp.html","titel":"6.3 Warum Runden fundamental scheitert","anker":"sec:milp-warum-runden-fundamental-scheitert","text":"In der realen Welt sind viele Entscheidungen nicht teilbar. Man kann nicht 0,47 Flugzeuge kaufen, keine halbe Lagerhalle bauen, keinen Mitarbeiter zu 38 % einstellen. Noch wichtige"},{"seite":"milp.html","titel":"6.4 Branch-and-Bound","anker":"sec:milp-branch-and-bound","text":"MILP-Probleme sind NP-schwer. Der Standardansatz moderner Solver (HiGHS, SCIP, Gurobi) heißt Branch-and-Cut — Branch-and-Bound plus Schnittebenen."},{"seite":"milp.html","titel":"Die drei Phasen","anker":"die-drei-phasen","text":""},{"seite":"milp.html","titel":"6.5 Modellierungstricks: Big-M und logische Bedingungen","anker":"sec:milp-modellierungstricks-big-m-und-logische-bedingungen","text":"Hier kommt das Handwerkszeug, das MILP so mächtig macht: Man übersetzt Geschäftsregeln in lineare Ungleichungen — mithilfe binärer Hilfsvariablen y \\in \\{0,1\\} und einer hinreichen"},{"seite":"milp.html","titel":"Muster 1 — Fixkosten / Aktivierungsschalter","anker":"muster-1-fixkosten-aktivierungsschalter","text":"Regel: Wird Maschine j genutzt (x_j > 0), fallen Rüstkosten F_j an."},{"seite":"milp.html","titel":"Muster 2 — Entweder-Oder (disjunctive constraints)","anker":"muster-2-entweder-oder-disjunctive-constraints","text":"Regel: Es muss entweder f(\\mathbf{x}) \\le b_1 oder g(\\mathbf{x}) \\le b_2 gelten."},{"seite":"milp.html","titel":"Muster 3 — Wenn-Dann (Implikation)","anker":"muster-3-wenn-dann-implikation","text":"Regel: Wenn y_1 = 1, dann muss auch y_2 = 1 sein."},{"seite":"milp.html","titel":"Muster 4 — Kardinalität („höchstens K aus N“)","anker":"muster-4-kardinalität-höchstens-k-aus-n","text":"\\sum_{j=1}^N y_j \\le K"},{"seite":"milp.html","titel":"Muster 5 — Semikontinuierlich („entweder 0 oder mindestens L“)","anker":"muster-5-semikontinuierlich-entweder-0-oder-mindestens-l","text":"Regel: Eine Position ist entweder gar nicht besetzt oder mit mindestens L Euro."},{"seite":"milp.html","titel":"6.6 Beispiel: Das Rucksackproblem","anker":"sec:milp-beispiel-das-rucksackproblem","text":"Bevor wir zum Portfolio kommen, das klassische Einstiegsproblem — kurz, verständlich und überall wiederzuerkennen."},{"seite":"milp.html","titel":"6.7 Praxisfall: Portfolio mit Ordergebühren und Kardinalitätsgrenze","anker":"sec:milp-praxisfall-portfolio-mit-ordergebuehren-und-kardinalitaetsgrenze","text":"Szenario. Ein Investor verteilt 100\\,000\\,\\text{€} auf sechs Anlageklassen."},{"seite":"milp.html","titel":"6.8 Wenn der Solver nicht fertig wird: Gap, Zeitlimit und Warm-Start","anker":"sec:milp-gap","text":"Bei einem LP gibt es zwei Ausgänge: eine optimale Lösung oder eine klare Absage. Bei einem MILP gibt es einen dritten, und im Betrieb ist er der häufigste:"},{"seite":"milp.html","titel":"Die zwei Zahlen, die zählen","anker":"die-zwei-zahlen-die-zählen","text":"Branch-and-Bound führt zu jedem Zeitpunkt zwei Werte mit:"},{"seite":"milp.html","titel":"Alle Statusfälle behandeln","anker":"alle-statusfälle-behandeln","text":"Der zweite Teil der Professionalität ist unspektakulär, aber entscheidend: jeden Rückgabewert auswerten, nicht nur OPTIMAL."},{"seite":"milp.html","titel":"Warm-Starts: was sie können und was nicht","anker":"warm-starts-was-sie-können-und-was-nicht","text":"Ein Warm-Start gibt dem Solver eine bekannte Lösung als Startpunkt mit. Die Idee ist verlockend: Wer schon eine brauchbare Lösung hat, muss nicht bei null anfangen."},{"seite":"milp.html","titel":"6.9 Übungsaufgaben","anker":"sec:milp-uebungsaufgaben","text":""},{"seite":"milp.html","titel":"6.10 Finde den Denkfehler","anker":"sec:milp-denkfehler","text":"Die Warnung „M so klein wie möglich“ wird meist mit der Laufzeit begründet: schwache Relaxation, mehr Knoten. Das stimmt — und ist die harmlosere Hälfte der Wahrheit."},{"seite":"milp.html","titel":"6.11 Micro-Quiz","anker":"sec:milp-quiz","text":""},{"seite":"milp.html","titel":"6.12 Selbsttest","anker":"sec:milp-selbsttest","text":""},{"seite":"milp.html","titel":"6.13 Zusammenfassung","anker":"sec:milp-zusammenfassung","text":""},{"seite":"cpsat.html","titel":"Kapitel 7: Constraint Programming mit CP-SAT — Logik, Scheduling und Zuweisung","anker":null,"text":""},{"seite":"cpsat.html","titel":"7.1 In 5 Minuten gelöst","anker":"sec:cpsat-schnellstart","text":""},{"seite":"cpsat.html","titel":"7.2 Lernziele","anker":"sec:cpsat-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"cpsat.html","titel":"7.3 Ein anderes Denkmodell","anker":"sec:cpsat-ein-anderes-denkmodell","text":"Während LP und MILP auf algebraischen Gleichungen und Simplex-Matrizen beruhen, kommt Constraint Programming (CP) aus der künstlichen Intelligenz:"},{"seite":"cpsat.html","titel":"Was Propagation leistet","anker":"was-propagation-leistet","text":""},{"seite":"cpsat.html","titel":"7.4 Globale Constraints — die Bausteine von CP-SAT","anker":"sec:cpsat-globale-constraints-die-bausteine-von-cp","text":"CP-SAT stellt mächtige Makros bereit, die in MILP hunderte Big-M-Ungleichungen erfordern würden. Jedes davon hat einen spezialisierten, hocheffizienten Propagator."},{"seite":"cpsat.html","titel":"7.5 Praxisfall: Dynamisches Vertretungssystem","anker":"sec:cpsat-praxisfall-dynamisches-vertretungssystem","text":"Problemstellung. Eine Schule muss nach spontanen Ausfällen vier Unterrichtsstunden nachbesetzen."},{"seite":"cpsat.html","titel":"7.6 Intervallvariablen: Job-Shop-Scheduling","anker":"sec:cpsat-intervallvariablen-job-shop-scheduling","text":"Die Königsdisziplin von CP-SAT sind Zeitplanungsprobleme. Intervallvariablen sind dabei das zentrale Werkzeug."},{"seite":"cpsat.html","titel":"7.7 Die fünf Antworten von CP-SAT","anker":"sec:cpsat-statusfaelle","text":"Abschnitt 6.8 hat gezeigt, warum OPTIMAL bei ganzzahligen Problemen nicht der Normalfall ist. Bei CP-SAT kommt eine Besonderheit hinzu: Es gibt fünf verschiedene Antworten, und dre"},{"seite":"cpsat.html","titel":"7.8 Übungsaufgaben","anker":"sec:cpsat-uebungsaufgaben","text":""},{"seite":"cpsat.html","titel":"7.9 Finde den Denkfehler","anker":"sec:cpsat-denkfehler","text":""},{"seite":"cpsat.html","titel":"7.10 Micro-Quiz","anker":"sec:cpsat-quiz","text":""},{"seite":"cpsat.html","titel":"7.11 Selbsttest","anker":"sec:cpsat-selbsttest","text":""},{"seite":"cpsat.html","titel":"7.12 Zusammenfassung","anker":"sec:cpsat-zusammenfassung","text":""},{"seite":"graphen.html","titel":"Kapitel 8: Graphen, Flüsse und Touren — Min-Cost-Flow, Matching und VRP","anker":null,"text":""},{"seite":"graphen.html","titel":"8.1 In 5 Minuten gelöst","anker":"sec:graphen-schnellstart","text":""},{"seite":"graphen.html","titel":"8.2 Lernziele","anker":"sec:graphen-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"graphen.html","titel":"8.3 Graphen als Modellsprache","anker":"sec:graphen-graphen-als-modellsprache","text":"Viele reale Probleme in Logistik, Kommunikation und Finanzströmen sind keine flachen Ungleichungssysteme, sondern Graphen G = (V, E):"},{"seite":"graphen.html","titel":"Das Minimum-Cost-Flow-Problem (MCNFP)","anker":"das-minimum-cost-flow-problem-mcnfp","text":"Minimum-Cost Network Flow Problem — deutsch: kostenminimales Flussproblem. Es ist das mathematische Fundament für Transportketten, Liquiditätsrouting und Datenverteilung."},{"seite":"graphen.html","titel":"8.4 Bipartites Matching: das Zuordnungsproblem","anker":"sec:graphen-bipartites-matching-das-zuordnungsproblem","text":"Wenn N Aufgaben auf N Ressourcen eins zu eins verteilt werden — Orders auf Broker, Schichten auf Mitarbeitende, Aufträge auf Maschinen — spricht man von bipartitem Matching."},{"seite":"graphen.html","titel":"Der Satz von Birkhoff und von Neumann","anker":"der-satz-von-birkhoff-und-von-neumann","text":""},{"seite":"graphen.html","titel":"8.5 Das Vehicle Routing Problem mit Zeitfenstern","anker":"sec:graphen-das-vehicle-routing-problem-mit-zeitfenstern","text":"Das Traveling Salesperson Problem (TSP), deutsch Problem des Handlungsreisenden, fragt nach der kürzesten Rundreise durch N Städte. Das Capacitated Vehicle Routing Problem with Tim"},{"seite":"graphen.html","titel":"Kurzzyklen verhindern","anker":"kurzzyklen-verhindern","text":"Ein naives Modell erlaubt Subtouren: isolierte Kreise, die das Depot nie anfahren. Die klassische Gegenmaßnahme ist die MTZ-Formulierung nach Miller, Tucker und Zemlin. Man führt R"},{"seite":"graphen.html","titel":"Praxisbeispiel: Flotten-Routing","anker":"praxisbeispiel-flotten-routing","text":""},{"seite":"graphen.html","titel":"8.6 Übungsaufgaben","anker":"sec:graphen-uebungsaufgaben","text":""},{"seite":"graphen.html","titel":"8.7 Finde den Denkfehler","anker":"sec:graphen-denkfehler","text":""},{"seite":"graphen.html","titel":"8.8 Micro-Quiz","anker":"sec:graphen-quiz","text":""},{"seite":"graphen.html","titel":"8.9 Selbsttest","anker":"sec:graphen-selbsttest","text":""},{"seite":"graphen.html","titel":"8.10 Zusammenfassung","anker":"sec:graphen-zusammenfassung","text":""},{"seite":"metaheuristiken.html","titel":"Kapitel 9: Metaheuristiken — wenn der exakte Solver aussteigt","anker":null,"text":""},{"seite":"metaheuristiken.html","titel":"9.1 In 5 Minuten gelöst","anker":"sec:metaheuristiken-schnellstart","text":""},{"seite":"metaheuristiken.html","titel":"9.2 Lernziele","anker":"sec:metaheuristiken-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"metaheuristiken.html","titel":"9.3 Die Aufgabe: Rüstzeiten an der Lackieranlage","anker":"sec:metaheuristiken-aufgabe","text":"Der Schnellstart war ein Spielzeug. Die Aufgabe dieses Kapitels ist dieselbe, nur in Betriebsgröße: n Aufträge in eine Reihenfolge bringen, so dass die Summe der Rüstzeiten minimal"},{"seite":"metaheuristiken.html","titel":"Die Instanz","anker":"die-instanz","text":"Alle Programme dieses Kapitels benutzen dieselbe Aufgabe. Die Rüstzeit setzt sich aus zwei Teilen zusammen: einem unregelmäßigen Reinigungsaufwand zwischen den Produktfamilien und "},{"seite":"metaheuristiken.html","titel":"9.4 Lokale Suche: der Zug und seine Kosten","anker":"sec:metaheuristiken-lokale-suche","text":"Eine lokale Suche braucht drei Dinge: eine Startlösung, einen Zug und eine Regel, welche Züge übernommen werden."},{"seite":"metaheuristiken.html","titel":"9.5 Simulated Annealing","anker":"sec:metaheuristiken-annealing","text":"Reines Bergsteigen — nur Verbesserungen annehmen — bleibt im ersten lokalen Optimum stehen. Simulated Annealing nimmt Verschlechterungen mit einer Wahrscheinlichkeit an, die von de"},{"seite":"metaheuristiken.html","titel":"Was die Messung zeigt — und was sie nicht zeigt","anker":"was-die-messung-zeigt-und-was-sie-nicht-zeigt","text":"Erstens: Der Beitrag des Annealings ist bescheiden. Bergsteigen kommt auf 917 Minuten, Annealing auf 907 — das simple Verfahren holt 93 % des Ertrags. Das ist kein Argument gegen A"},{"seite":"metaheuristiken.html","titel":"9.6 Ab wann lohnt es sich?","anker":"sec:metaheuristiken-umschlagpunkt","text":"„Kleine Probleme exakt, große mit Metaheuristiken“ ist richtig und nutzlos, solange niemand sagt, wo groß anfängt. Diese Zahl lässt sich messen."},{"seite":"metaheuristiken.html","titel":"Die Spalte, die man nicht weglassen darf","anker":"die-spalte-die-man-nicht-weglassen-darf","text":"Bei 500 Aufträgen haben wir die CP-SAT-Lösung verworfen. Seine Schranke ist trotzdem das Wertvollste in der ganzen Tabelle."},{"seite":"metaheuristiken.html","titel":"9.7 Large Neighborhood Search","anker":"sec:metaheuristiken-lns","text":"Die Tabelle oben endet mit einem Widerspruch. Bei 500 Aufträgen scheitert der exakte Solver an der Größe; die Metaheuristik scheitert an der Kleinheit ihrer Züge — sie verschiebt j"},{"seite":"metaheuristiken.html","titel":"Das Fenster ist die eine Stellschraube","anker":"das-fenster-ist-die-eine-stellschraube","text":""},{"seite":"metaheuristiken.html","titel":"Das Gesamtbild","anker":"das-gesamtbild","text":""},{"seite":"metaheuristiken.html","titel":"9.8 Was dieses Kapitel nicht behandelt","anker":"sec:metaheuristiken-abgrenzung","text":"Damit die Landkarte vollständig ist — drei Verfahren, die in dieselbe Familie gehören und hier bewusst nur benannt werden:"},{"seite":"metaheuristiken.html","titel":"9.9 Übungsaufgaben","anker":"sec:metaheuristiken-uebungsaufgaben","text":""},{"seite":"metaheuristiken.html","titel":"9.10 Finde den Denkfehler","anker":"sec:metaheuristiken-denkfehler","text":""},{"seite":"metaheuristiken.html","titel":"9.11 Micro-Quiz","anker":"sec:metaheuristiken-quiz","text":""},{"seite":"metaheuristiken.html","titel":"9.12 Selbsttest","anker":"sec:metaheuristiken-selbsttest","text":""},{"seite":"metaheuristiken.html","titel":"9.13 Zusammenfassung","anker":"sec:metaheuristiken-zusammenfassung","text":""},{"seite":"dekomposition.html","titel":"Kapitel 10: Spaltengenerierung — das Modell umbauen statt die Lösung raten","anker":null,"text":""},{"seite":"dekomposition.html","titel":"10.1 In 5 Minuten gelöst","anker":"sec:dekomposition-schnellstart","text":""},{"seite":"dekomposition.html","titel":"10.2 Lernziele","anker":"sec:dekomposition-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"dekomposition.html","titel":"10.3 Warum das naheliegende Modell scheitert","anker":"sec:dekomposition-symmetrie","text":"Der erste Entwurf eines Zuschnittmodells sieht fast immer so aus: eine Binärvariable z_{jr} für „Stück j liegt auf Rolle r“, dazu y_r für „Rolle r wird benutzt”."},{"seite":"dekomposition.html","titel":"10.4 Rechnen mit Mustern, ohne sie aufzuschreiben","anker":"sec:dekomposition-schleife","text":"Die Idee ist alt (Gilmore und Gomory, 1961) und in einem Satz gesagt:"},{"seite":"dekomposition.html","titel":"10.5 Das Programm","anker":"sec:dekomposition-programm","text":""},{"seite":"dekomposition.html","titel":"10.6 Was die Zahlen zeigen","anker":"sec:dekomposition-befund","text":""},{"seite":"dekomposition.html","titel":"Die Musterzahl explodiert, der Bedarf nicht","anker":"die-musterzahl-explodiert-der-bedarf-nicht","text":""},{"seite":"dekomposition.html","titel":"Der unbequeme Teil: Es lohnt sich nicht immer","anker":"der-unbequeme-teil-es-lohnt-sich-nicht-immer","text":""},{"seite":"dekomposition.html","titel":"10.7 Übungsaufgaben","anker":"sec:dekomposition-uebungsaufgaben","text":""},{"seite":"dekomposition.html","titel":"10.8 Finde den Denkfehler","anker":"sec:dekomposition-denkfehler","text":""},{"seite":"dekomposition.html","titel":"10.9 Micro-Quiz","anker":"sec:dekomposition-quiz","text":""},{"seite":"dekomposition.html","titel":"10.10 Selbsttest","anker":"sec:dekomposition-selbsttest","text":""},{"seite":"dekomposition.html","titel":"10.11 Zusammenfassung","anker":"sec:dekomposition-zusammenfassung","text":""},{"seite":"qp-nlp.html","titel":"Kapitel 11: Quadratische und nichtlineare Optimierung — KKT, Lagrange, Konvexität","anker":null,"text":""},{"seite":"qp-nlp.html","titel":"11.1 In 5 Minuten gelöst","anker":"sec:qp-nlp-schnellstart","text":""},{"seite":"qp-nlp.html","titel":"11.2 Lernziele","anker":"sec:qp-nlp-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"qp-nlp.html","titel":"Auffrischung: Gradient in einer Minute","anker":"auffrischung-gradient-in-einer-minute","text":"Der Gradient \\nabla f(\\mathbf{x}) ist der Vektor aller partiellen Ableitungen:"},{"seite":"qp-nlp.html","titel":"11.3 Das quadratische Programm (QP)","anker":"sec:qp-nlp-das-quadratische-programm-qp","text":"Ein quadratisches Programm hat eine quadratische Zielfunktion und lineare Nebenbedingungen:"},{"seite":"qp-nlp.html","titel":"Konvexität: die präzise Aussage","anker":"konvexität-die-präzise-aussage","text":"Eine verbreitete, aber zu stark formulierte Aussage lautet: „Wenn \\mathbf{P} positiv semi-definit ist, ist das QP streng konvex; es existiert ein eindeutiges globales Minimum.“ Kor"},{"seite":"qp-nlp.html","titel":"11.4 Die Karush-Kuhn-Tucker-Bedingungen","anker":"sec:qp-nlp-die-karush-kuhn-tucker-bedingungen","text":"Die KKT-Bedingungen verallgemeinern den Lagrange-Ansatz auf Probleme mit Ungleichungen. Sie sind notwendig für ein Optimum — und bei konvexen Problemen auch hinreichend."},{"seite":"qp-nlp.html","titel":"Die vier KKT-Bedingungen","anker":"die-vier-kkt-bedingungen","text":"Am Optimum (\\mathbf{x}^*, \\boldsymbol{\\lambda}^*, \\boldsymbol{\\nu}^*) gilt:"},{"seite":"qp-nlp.html","titel":"11.5 Nichtlineare Optimierung mit scipy.optimize.minimize","anker":"sec:qp-nlp-nichtlineare-optimierung-mit-scipy-optimize-minimize","text":"Wenn Zielfunktion oder Nebenbedingungen weder linear noch quadratisch sind — Logarithmen, Wurzeln, Exponentialfunktionen — nutzt man gradientenbasierte Verfahren wie SLSQP (Sequent"},{"seite":"qp-nlp.html","titel":"Praxisfall: Entropie-maximierte Kapitalallokation","anker":"praxisfall-entropie-maximierte-kapitalallokation","text":"Wir optimieren ein Portfolio nicht nur nach Varianz, sondern maximieren zusätzlich die Shannon-Entropie der Gewichte, um Klumpenrisiken glatt zu bestrafen:"},{"seite":"qp-nlp.html","titel":"11.6 Jenseits der Konvexität: lokale Optima, Multistart und MINLP","anker":"sec:qp-nlp-nichtkonvex","text":"Alles bisher in diesem Kapitel stand unter einer Bedingung: Konvexität. Sie ist der Grund, warum CVXPY eine Optimalitätsgarantie geben kann und warum die KKT-Bedingungen nicht nur "},{"seite":"qp-nlp.html","titel":"Woher Nicht-Konvexität im Alltag kommt","anker":"woher-nicht-konvexität-im-alltag-kommt","text":"Nicht aus exotischer Mathematik, sondern aus ganz gewöhnlichen betriebswirtschaftlichen Effekten:"},{"seite":"qp-nlp.html","titel":"Was ein lokales Optimum praktisch bedeutet","anker":"was-ein-lokales-optimum-praktisch-bedeutet","text":"scipy.optimize.minimize verweigert nichts. Es rechnet, meldet success: True und liefert eine Zahl. Diese Meldung heißt aber nicht „das ist das Optimum“, sondern nur:"},{"seite":"qp-nlp.html","titel":"Die Zahlen im Klartext","anker":"die-zahlen-im-klartext","text":""},{"seite":"qp-nlp.html","titel":"Ausblick: MINLP und globale Solver","anker":"ausblick-minlp-und-globale-solver","text":"Wird zusätzlich noch ganzzahlig entschieden — „welche Anlage läuft überhaupt“ und „wie stark“ —, entsteht ein MINLP (Mixed-Integer Nonlinear Program), die schwierigste der in diese"},{"seite":"qp-nlp.html","titel":"11.7 Übungsaufgaben","anker":"sec:qp-nlp-uebungsaufgaben","text":""},{"seite":"qp-nlp.html","titel":"11.8 Finde den Denkfehler","anker":"sec:qp-nlp-denkfehler","text":""},{"seite":"qp-nlp.html","titel":"11.9 Micro-Quiz","anker":"sec:qp-nlp-quiz","text":""},{"seite":"qp-nlp.html","titel":"11.10 Selbsttest","anker":"sec:qp-nlp-selbsttest","text":""},{"seite":"qp-nlp.html","titel":"11.11 Zusammenfassung","anker":"sec:qp-nlp-zusammenfassung","text":""},{"seite":"unsicherheit.html","titel":"Kapitel 12: Optimierung unter Unsicherheit — Monte-Carlo, Stochastik, Robustheit","anker":null,"text":""},{"seite":"unsicherheit.html","titel":"12.1 In 5 Minuten gelöst","anker":"sec:unsicherheit-schnellstart","text":""},{"seite":"unsicherheit.html","titel":"12.2 Lernziele","anker":"sec:unsicherheit-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"unsicherheit.html","titel":"12.3 Der Fluch des Durchschnitts","anker":"sec:unsicherheit-der-fluch-des-durchschnitts","text":"Der naheliegende Umgang mit Unsicherheit lautet: „Wir setzen den Erwartungswert ein und rechnen deterministisch.“ Das ist fast immer falsch — und zwar nicht ungenau, sondern system"},{"seite":"unsicherheit.html","titel":"Die vier Ansätze im Überblick","anker":"die-vier-ansätze-im-überblick","text":""},{"seite":"unsicherheit.html","titel":"12.4 Monte-Carlo-Simulation","anker":"sec:unsicherheit-monte-carlo-simulation","text":"Monte-Carlo heißt schlicht: Ziehe sehr viele Zufallsszenarien, werte für jedes den Plan aus, und betrachte die Verteilung der Ergebnisse statt eines einzigen Werts."},{"seite":"unsicherheit.html","titel":"12.5 Zweistufige stochastische Programmierung","anker":"sec:unsicherheit-zweistufige-stochastische-programmierung","text":"Das zweistufige Modell mit Recourse bildet ab, dass Entscheidungen zeitlich gestaffelt sind:"},{"seite":"unsicherheit.html","titel":"12.6 Robuste Optimierung: gegen den Worst Case absichern","anker":"sec:unsicherheit-robuste-optimierung-gegen-den-worst-case","text":"Wenn Wahrscheinlichkeiten unbekannt oder instabil sind — Marktcrashs, Lieferkettenabrisse, Pandemien —, hilft die robuste Optimierung. Sie fragt nicht nach dem Mittel, sondern nach"},{"seite":"unsicherheit.html","titel":"12.7 Wahrscheinlichkeitsbeschränkungen: „mit 95 % Sicherheit“","anker":"sec:unsicherheit-chance-constraints","text":"Die drei bisherigen Ansätze beantworten Fragen, die im Sitzungssaal selten so gestellt werden. „Was ist im Mittel am besten?“ hört ein Vorstand als Ausflucht; „was hält auch im sch"},{"seite":"unsicherheit.html","titel":"Weg 1: Normalverteilung — die Bedingung wird ein Kegel","anker":"weg-1-normalverteilung-die-bedingung-wird-ein-kegel","text":"Ist \\mathbf{a} \\sim \\mathcal{N}(\\hat{\\mathbf{a}}, \\boldsymbol{\\Sigma}), dann ist \\mathbf{a}^\\top\\mathbf{x} für festes \\mathbf{x} selbst normalverteilt, mit Erwartungswert \\hat{\\mat"},{"seite":"unsicherheit.html","titel":"Weg 2: Szenarien — Big-M ohne Verteilungsannahme","anker":"weg-2-szenarien-big-m-ohne-verteilungsannahme","text":"Liegen statt einer Verteilung nur S beobachtete Szenarien vor, bekommt jedes eine Binärvariable z_s, die sagt, ob es verletzt werden darf:"},{"seite":"unsicherheit.html","titel":"Der Fall: ein Kraftwerkspark mit 500 MW Zusage","anker":"der-fall-ein-kraftwerkspark-mit-500-mw-zusage","text":""},{"seite":"unsicherheit.html","titel":"12.8 Übungsaufgaben","anker":"sec:unsicherheit-uebungsaufgaben","text":""},{"seite":"unsicherheit.html","titel":"12.9 Finde den Denkfehler","anker":"sec:unsicherheit-denkfehler","text":""},{"seite":"unsicherheit.html","titel":"12.10 Micro-Quiz","anker":"sec:unsicherheit-quiz","text":""},{"seite":"unsicherheit.html","titel":"12.11 Selbsttest","anker":"sec:unsicherheit-selbsttest","text":""},{"seite":"unsicherheit.html","titel":"12.12 Zusammenfassung","anker":"sec:unsicherheit-zusammenfassung","text":""},{"seite":"dynamische-programmierung.html","titel":"Kapitel 13: Dynamische Programmierung — Die Bellman-Gleichung und Order-Execution","anker":null,"text":""},{"seite":"dynamische-programmierung.html","titel":"13.1 In 5 Minuten gelöst","anker":"sec:dynamische-programmierung-schnellstart","text":""},{"seite":"dynamische-programmierung.html","titel":"13.2 Lernziele","anker":"sec:dynamische-programmierung-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"dynamische-programmierung.html","titel":"13.3 Das Bellmansche Optimalitätsprinzip","anker":"sec:dynamische-programmierung-das-bellmansche-optimalitaetsprinzip","text":"Richard Bellman formulierte 1957 das Grundprinzip der Dynamischen Programmierung (DP):"},{"seite":"dynamische-programmierung.html","titel":"Die vier Bausteine eines DP-Modells","anker":"die-vier-bausteine-eines-dp-modells","text":""},{"seite":"dynamische-programmierung.html","titel":"Die Bellman-Gleichung","anker":"die-bellman-gleichung","text":"V_t(S_t) = \\min_{a_t\\in\\mathcal{A}(S_t)}\\Big\\{\\,C(S_t,a_t) \\;+\\; \\gamma\\cdot\\mathbb{E}\\big[V_{t+1}(S_{t+1})\\mid S_t,a_t\\big]\\Big\\}"},{"seite":"dynamische-programmierung.html","titel":"13.4 Das Almgren-Chriss-Problem: optimale Orderausführung","anker":"sec:dynamische-programmierung-das-almgren-chriss-problem-optimale-orderausfuehrung","text":"Ein klassisches OR-Problem an Börsen: Ein institutioneller Händler muss X_0 = 100\\,000 Aktien innerhalb von T Handelsperioden verkaufen."},{"seite":"dynamische-programmierung.html","titel":"13.5 Der Fluch der Dimensionalität","anker":"sec:dynamische-programmierung-der-fluch-der-dimensionalitaet","text":"DP ist mächtig, aber es hat eine harte Grenze. Der Aufwand der Rückwärtsinduktion ist"},{"seite":"dynamische-programmierung.html","titel":"13.6 Übungsaufgaben","anker":"sec:dynamische-programmierung-uebungsaufgaben","text":""},{"seite":"dynamische-programmierung.html","titel":"13.7 Finde den Denkfehler","anker":"sec:dynamische-programmierung-denkfehler","text":"Bei dynamischer Programmierung entscheidet eine einzige Frage über Erfolg oder Misserfolg: Was gehört in den Zustand? Ist er zu klein, rechnet das Verfahren völlig korrekt — nur eb"},{"seite":"dynamische-programmierung.html","titel":"13.8 Micro-Quiz","anker":"sec:dynamische-programmierung-quiz","text":""},{"seite":"dynamische-programmierung.html","titel":"13.9 Selbsttest","anker":"sec:dynamische-programmierung-selbsttest","text":""},{"seite":"dynamische-programmierung.html","titel":"13.10 Zusammenfassung","anker":"sec:dynamische-programmierung-zusammenfassung","text":""},{"seite":"mehrziel.html","titel":"Kapitel 14: Mehrere Ziele — Pareto-Fronten statt Gewichte","anker":null,"text":""},{"seite":"mehrziel.html","titel":"14.1 In 5 Minuten gelöst","anker":"sec:mehrziel-schnellstart","text":""},{"seite":"mehrziel.html","titel":"14.2 Lernziele","anker":"sec:mehrziel-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"mehrziel.html","titel":"14.3 Wann ein Kompromiss gut ist","anker":"sec:mehrziel-dominanz","text":"Bei einem Ziel ist „besser” eindeutig. Bei zwei Zielen nicht mehr: Ein Plan kann billiger und schmutziger sein als ein anderer, und dann ist keiner von beiden besser."},{"seite":"mehrziel.html","titel":"14.4 Der Reflex: alles in ein Ziel rühren","anker":"sec:mehrziel-skalarisierung","text":"Der naheliegende Weg, zwei Ziele zu einem zu machen, ist die gewichtete Summe:"},{"seite":"mehrziel.html","titel":"14.5 Das Programm","anker":"sec:mehrziel-programm","text":"Zwölf Sendungen, drei Verkehrsträger, fünf freie Bahntrassen. Der Zielkonflikt entsteht nicht zwischen den Trägern — die Bahn ist immer billiger und sauberer als der LKW —, sondern"},{"seite":"mehrziel.html","titel":"14.6 Was die gewichtete Summe nicht sieht","anker":"sec:mehrziel-luecke","text":"Die Front hat zehn Punkte. Ein Raster aus 1 401 Gewichten findet sechs davon. Vier Kompromisse sind für kein Gewicht erreichbar — und das ist keine Frage der Rasterweite."},{"seite":"mehrziel.html","titel":"Das Gewicht ist keine Feineinstellung","anker":"das-gewicht-ist-keine-feineinstellung","text":"Die zweite Beobachtung aus derselben Tabelle: 1 401 Gewichte erzeugen sechs verschiedene Pläne. Weite Gewichtsbereiche liefern dasselbe Ergebnis, und dazwischen springt es."},{"seite":"mehrziel.html","titel":"14.7 Das ε-Constraint-Verfahren","anker":"sec:mehrziel-epsilon","text":"Der Ausweg ist unspektakulär: Man macht aus dem zweiten Ziel eine Nebenbedingung."},{"seite":"mehrziel.html","titel":"Wenn es eine klare Rangfolge gibt: lexikografisch","anker":"wenn-es-eine-klare-rangfolge-gibt-lexikografisch","text":"Manchmal ist ein Ziel wirklich vorrangig. Dann optimiert man lexikografisch: erst das erste Ziel, dann das zweite unter der Bedingung, dass das erste (fast) erhalten bleibt."},{"seite":"mehrziel.html","titel":"14.8 Wie man die Front vorlegt","anker":"sec:mehrziel-vorlegen","text":"Eine Front mit zehn Punkten ist kein Ergebnis, sondern eine Speisekarte. Damit sie entscheidbar wird, gehört eine Spalte dazu, die im Programm EUR je kg heißt:"},{"seite":"mehrziel.html","titel":"14.9 Übungsaufgaben","anker":"sec:mehrziel-uebungsaufgaben","text":""},{"seite":"mehrziel.html","titel":"14.10 Finde den Denkfehler","anker":"sec:mehrziel-denkfehler","text":""},{"seite":"mehrziel.html","titel":"14.11 Micro-Quiz","anker":"sec:mehrziel-quiz","text":""},{"seite":"mehrziel.html","titel":"14.12 Selbsttest","anker":"sec:mehrziel-selbsttest","text":""},{"seite":"mehrziel.html","titel":"14.13 Zusammenfassung","anker":"sec:mehrziel-zusammenfassung","text":""},{"seite":"prognose.html","titel":"Kapitel 15: Predict-then-Optimize — die bessere Prognose, die schlechtere Entscheidung","anker":null,"text":""},{"seite":"prognose.html","titel":"15.1 In 5 Minuten gelöst","anker":"sec:prognose-schnellstart","text":""},{"seite":"prognose.html","titel":"15.2 Lernziele","anker":"sec:prognose-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"prognose.html","titel":"15.3 Die Naht zwischen zwei Abteilungen","anker":"sec:prognose-naht","text":"In fast jedem Unternehmen sieht der Ablauf so aus:"},{"seite":"prognose.html","titel":"15.4 Das Programm","anker":"sec:prognose-programm","text":"Dieselbe Bäckerei, aber mit Merkmalen: Wochentag, Temperatur, Aktionstage. Die Nachfrage muss jetzt modelliert werden."},{"seite":"prognose.html","titel":"15.5 Der Befund","anker":"sec:prognose-befund","text":""},{"seite":"prognose.html","titel":"Warum ein pauschaler Zuschlag zu kurz greift","anker":"warum-ein-pauschaler-zuschlag-zu-kurz-greift","text":"Die zweite Zeile der Tabelle ist die verbreitete Praxis: Punktprognose plus Sicherheitsbestand. Sie hilft (−7,3 %), bleibt aber hinter der Quantilregression zurück. Das Programm re"},{"seite":"prognose.html","titel":"15.6 Eine Messfalle, in die der Autor selbst getappt ist","anker":"sec:prognose-messfalle","text":"Der erste Entwurf dieses Programms bewertete auf 230 Testtagen — ein realistischer Zeitraum, gut sieben Monate. Dort hatte die Quantilregression den besseren MSE, und die ganze Aus"},{"seite":"prognose.html","titel":"15.7 Wie weit das Verfahren reicht","anker":"sec:prognose-ausblick","text":"Die dritte Zeile der Ergebnistabelle — „Zuschlag auf Kosten trainiert” — ist die einfachste denkbare Form einer Idee, die derzeit erforscht wird: das Prognosemodell direkt auf die "},{"seite":"prognose.html","titel":"15.8 Übungsaufgaben","anker":"sec:prognose-uebungsaufgaben","text":""},{"seite":"prognose.html","titel":"15.9 Finde den Denkfehler","anker":"sec:prognose-denkfehler","text":""},{"seite":"prognose.html","titel":"15.10 Micro-Quiz","anker":"sec:prognose-quiz","text":""},{"seite":"prognose.html","titel":"15.11 Selbsttest","anker":"sec:prognose-selbsttest","text":""},{"seite":"prognose.html","titel":"15.12 Zusammenfassung","anker":"sec:prognose-zusammenfassung","text":""},{"seite":"bruecke.html","titel":"Kapitel 16: Die Strukturbrücke — dieselbe Mathematik, zwei Welten","anker":null,"text":""},{"seite":"bruecke.html","titel":"16.1 In 5 Minuten gelöst","anker":"sec:bruecke-schnellstart","text":""},{"seite":"bruecke.html","titel":"16.2 Lernziele","anker":"sec:bruecke-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"bruecke.html","titel":"16.3 Das Programm","anker":"sec:bruecke-programm","text":""},{"seite":"bruecke.html","titel":"16.4 Die zwei Brücken im Einzelnen","anker":"sec:bruecke-einzelnen","text":""},{"seite":"bruecke.html","titel":"Erste Brücke: Allokation","anker":"erste-brücke-allokation","text":""},{"seite":"bruecke.html","titel":"Zweite Brücke: Absicherung gegen den schlechtesten Fall","anker":"zweite-brücke-absicherung-gegen-den-schlechtesten-fall","text":"Hier ist die Übereinstimmung noch enger, weil sie nicht nur die Struktur betrifft, sondern die Formel. Die Rockafellar-Uryasev-Formulierung aus Kapitel 20"},{"seite":"bruecke.html","titel":"Wo die Lösungen sich unterscheiden","anker":"wo-die-lösungen-sich-unterscheiden","text":"Der Vergleich fördert nebenbei einen Unterschied zutage, den man nicht erwartet: Die Streuungsgrenze von 40 % bindet nur im Einkauf, nicht im Depot."},{"seite":"bruecke.html","titel":"16.5 Wo die Brücke endet","anker":"sec:bruecke-grenzen","text":"Eine Analogie ist nur so nützlich wie die Grenzen, die man ihr zieht. Drei Dinge wandern nicht mit:"},{"seite":"bruecke.html","titel":"16.6 Übungsaufgaben","anker":"sec:bruecke-uebungsaufgaben","text":""},{"seite":"bruecke.html","titel":"16.7 Finde den Denkfehler","anker":"sec:bruecke-denkfehler","text":""},{"seite":"bruecke.html","titel":"16.8 Micro-Quiz","anker":"sec:bruecke-quiz","text":""},{"seite":"bruecke.html","titel":"16.9 Selbsttest","anker":"sec:bruecke-selbsttest","text":""},{"seite":"bruecke.html","titel":"16.10 Zusammenfassung","anker":"sec:bruecke-zusammenfassung","text":""},{"seite":"supplychain.html","titel":"Kapitel 17: Supply-Chain und Energieeinsatz unter Unsicherheit","anker":null,"text":""},{"seite":"supplychain.html","titel":"17.1 In 5 Minuten gelöst","anker":"sec:supplychain-schnellstart","text":""},{"seite":"supplychain.html","titel":"17.2 Lernziele","anker":"sec:supplychain-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"supplychain.html","titel":"17.3 Die Aufgabe","anker":"sec:supplychain-aufgabe","text":"Fünf Blöcke, 24 Stunden, eine Last zwischen 270 und 804 MW. Dazu Windeinspeisung, die niemand am Vorabend kennt."},{"seite":"supplychain.html","titel":"Die Zeitstruktur der Entscheidung","anker":"die-zeitstruktur-der-entscheidung","text":"Hier geht das Kapitel über Abschnitt 12.5 hinaus:"},{"seite":"supplychain.html","titel":"17.4 Das Programm","anker":"sec:supplychain-programm","text":""},{"seite":"supplychain.html","titel":"17.5 Der Befund","anker":"sec:supplychain-befund","text":""},{"seite":"supplychain.html","titel":"17.6 Was Versorgungssicherheit kostet","anker":"sec:supplychain-sicherheit","text":"Der dritte Plan legt eine CVaR-Schranke auf die nicht gedeckte Energie: Im Mittel der schlechtesten 10 % der Szenarien darf nichts fehlen. Dieselbe Rockafellar-Uryasev-Konstruktion"},{"seite":"supplychain.html","titel":"17.7 Übungsaufgaben","anker":"sec:supplychain-uebungsaufgaben","text":""},{"seite":"supplychain.html","titel":"17.8 Finde den Denkfehler","anker":"sec:supplychain-denkfehler","text":""},{"seite":"supplychain.html","titel":"17.9 Micro-Quiz","anker":"sec:supplychain-quiz","text":""},{"seite":"supplychain.html","titel":"17.10 Selbsttest","anker":"sec:supplychain-selbsttest","text":""},{"seite":"supplychain.html","titel":"17.11 Zusammenfassung","anker":"sec:supplychain-zusammenfassung","text":""},{"seite":"finanzdaten.html","titel":"Kapitel 18: Finanzdaten-Modellierung — Renditen, Kovarianz und Shrinkage","anker":null,"text":""},{"seite":"finanzdaten.html","titel":"18.1 In 5 Minuten gelöst","anker":"sec:finanzdaten-schnellstart","text":""},{"seite":"finanzdaten.html","titel":"18.2 Lernziele","anker":"sec:finanzdaten-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"finanzdaten.html","titel":"18.3 Diskrete und logarithmische Renditen","anker":"sec:finanzdaten-diskrete-und-logarithmische-renditen","text":"Sei P_{i,t} der bereinigte Schlusskurs (adjusted close) von Titel i zum Zeitpunkt t. „Bereinigt“ heißt: um Dividenden und Aktiensplits korrigiert — sonst erscheint jede Dividendenz"},{"seite":"finanzdaten.html","titel":"Diskrete Rendite","anker":"diskrete-rendite","text":"R_{i,t} = \\frac{P_{i,t} - P_{i,t-1}}{P_{i,t-1}} = \\frac{P_{i,t}}{P_{i,t-1}} - 1"},{"seite":"finanzdaten.html","titel":"Logarithmische Rendite","anker":"logarithmische-rendite","text":"r_{i,t} = \\ln\\!\\left(\\frac{P_{i,t}}{P_{i,t-1}}\\right) = \\ln P_{i,t} - \\ln P_{i,t-1}"},{"seite":"finanzdaten.html","titel":"18.4 Das Schätzfehler-Problem","anker":"sec:finanzdaten-das-schaetzfehler-problem","text":"Sei \\mathbf{X}\\in\\mathbb{R}^{T\\times N} die Matrix zentrierter Renditen von N Titeln über T Handelstage. Die Stichproben-Kovarianzmatrix lautet:"},{"seite":"finanzdaten.html","titel":"18.5 Ledoit-Wolf-Shrinkage","anker":"sec:finanzdaten-ledoit-wolf-shrinkage","text":"Olivier Ledoit und Michael Wolf lösten das Dilemma durch lineare Schrumpfung:"},{"seite":"finanzdaten.html","titel":"Welches Ziel \\mathbf{F}?","anker":"welches-ziel-mathbff","text":"Ein häufiges Missverständnis: Es gibt nämlich mehrere Ledoit-Wolf-Varianten, und Text und Code müssen sich konsistent auf dieselbe beziehen:"},{"seite":"finanzdaten.html","titel":"18.6 Praxis: Datenpipeline mit korrekter Spaltenreihenfolge","anker":"sec:finanzdaten-praxis-datenpipeline-mit-korrekter-spaltenreihenfolge","text":""},{"seite":"finanzdaten.html","titel":"18.7 Übungsaufgaben","anker":"sec:finanzdaten-uebungsaufgaben","text":""},{"seite":"finanzdaten.html","titel":"18.8 Finde den Denkfehler","anker":"sec:finanzdaten-denkfehler","text":""},{"seite":"finanzdaten.html","titel":"18.9 Micro-Quiz","anker":"sec:finanzdaten-quiz","text":""},{"seite":"finanzdaten.html","titel":"18.10 Selbsttest","anker":"sec:finanzdaten-selbsttest","text":""},{"seite":"finanzdaten.html","titel":"18.11 Zusammenfassung","anker":"sec:finanzdaten-zusammenfassung","text":""},{"seite":"markowitz.html","titel":"Kapitel 19: Die moderne Portfoliotheorie nach Markowitz","anker":null,"text":""},{"seite":"markowitz.html","titel":"19.1 In 5 Minuten gelöst","anker":"sec:markowitz-schnellstart","text":""},{"seite":"markowitz.html","titel":"19.2 Lernziele","anker":"sec:markowitz-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"markowitz.html","titel":"19.3 Warum Diversifikation funktioniert","anker":"sec:markowitz-warum-diversifikation-funktioniert","text":"Harry Markowitz’ zentrale Einsicht von 1952: Das Risiko eines Portfolios ist nicht die Summe der Einzelrisiken. Es hängt davon ab, wie die Titel zusammenspielen."},{"seite":"markowitz.html","titel":"19.4 Das Mean-Variance-Modell","anker":"sec:markowitz-das-mean-variance-modell","text":"Sei \\mathbf{w} = (w_1,\\dots,w_n)^\\top der Vektor der Portfoliogewichte:"},{"seite":"markowitz.html","titel":"Die Effizienzgrenze und das Tangentialportfolio","anker":"die-effizienzgrenze-und-das-tangentialportfolio","text":"Die Effizienzgrenze (efficient frontier) ist die Kurve aller Portfolios, die zu jedem Risikoniveau die höchstmögliche Rendite liefern."},{"seite":"markowitz.html","titel":"Die Korn-Transformation","anker":"die-korn-transformation","text":"Die Sharpe Ratio ist ein Bruch — und damit nicht konvex. CVXPY würde sie ablehnen. Der Ausweg ist ein eleganter Trick, der die Homogenität des Problems ausnutzt:"},{"seite":"markowitz.html","titel":"19.5 Institutionelle Nebenbedingungen","anker":"sec:markowitz-institutionelle-nebenbedingungen","text":"In der Praxis gelten regulatorische und interne Regeln:"},{"seite":"markowitz.html","titel":"Was diese Regeln kosten","anker":"was-diese-regeln-kosten","text":"Jede Regel schneidet etwas vom Erreichbaren ab. Wie viel, lässt sich ausrechnen — man legt die Effizienzlinie ohne und mit Beschränkungen übereinander:"},{"seite":"markowitz.html","titel":"19.6 Vollimplementierung mit CVXPY","anker":"sec:markowitz-vollimplementierung-mit-cvxpy","text":""},{"seite":"markowitz.html","titel":"19.7 Übungsaufgaben","anker":"sec:markowitz-uebungsaufgaben","text":""},{"seite":"markowitz.html","titel":"19.8 Finde den Denkfehler","anker":"sec:markowitz-denkfehler","text":"Kapitel 18 hat gezeigt, was eine schlecht geschätzte Kovarianzmatrix anrichtet. Bei den erwarteten Renditen ist es schlimmer — und weil sie in der Zielfunktion stehen statt in der "},{"seite":"markowitz.html","titel":"19.9 Micro-Quiz","anker":"sec:markowitz-quiz","text":""},{"seite":"markowitz.html","titel":"19.10 Selbsttest","anker":"sec:markowitz-selbsttest","text":""},{"seite":"markowitz.html","titel":"19.11 Zusammenfassung","anker":"sec:markowitz-zusammenfassung","text":""},{"seite":"cvar.html","titel":"Kapitel 20: Tail-Risiko, CVaR und Transaktionskosten","anker":null,"text":""},{"seite":"cvar.html","titel":"20.1 In 5 Minuten gelöst","anker":"sec:cvar-schnellstart","text":""},{"seite":"cvar.html","titel":"20.2 Lernziele","anker":"sec:cvar-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"cvar.html","titel":"20.3 Die zwei Schwächen des Markowitz-Modells","anker":"sec:cvar-die-zwei-schwaechen-des-markowitz-modells","text":"Erstens: die Normalverteilungs-Illusion. Die Varianz behandelt Aufwärts- und Abwärtsschwankungen gleich und unterstellt implizit symmetrische, dünn auslaufende Verteilungen. Reale "},{"seite":"cvar.html","titel":"20.4 Value at Risk und Conditional Value at Risk","anker":"sec:cvar-value-at-risk-und-conditional-value","text":""},{"seite":"cvar.html","titel":"Das Rockafellar-Uryasev-Theorem (2000)","anker":"das-rockafellar-uryasev-theorem-2000","text":"Rockafellar und Uryasev zeigten, dass sich der CVaR über S Szenarien exakt als lineares Programm formulieren lässt:"},{"seite":"cvar.html","titel":"20.5 Transaktionskosten über die L_1-Norm","anker":"sec:cvar-transaktionskosten-ueber-die-l-1-norm","text":"Sei \\mathbf{w}_{\\text{alt}} das bestehende Portfolio und \\mathbf{w} das neue Ziel. Der Umschlag (turnover) ist:"},{"seite":"cvar.html","titel":"20.6 Implementierung: CVaR-Portfolio mit Reibung","anker":"sec:cvar-implementierung-cvar-portfolio-mit-reibung","text":""},{"seite":"cvar.html","titel":"20.7 Übungsaufgaben","anker":"sec:cvar-uebungsaufgaben","text":""},{"seite":"cvar.html","titel":"20.8 Finde den Denkfehler","anker":"sec:cvar-denkfehler","text":""},{"seite":"cvar.html","titel":"20.9 Micro-Quiz","anker":"sec:cvar-quiz","text":""},{"seite":"cvar.html","titel":"20.10 Selbsttest","anker":"sec:cvar-selbsttest","text":""},{"seite":"cvar.html","titel":"20.11 Zusammenfassung","anker":"sec:cvar-zusammenfassung","text":""},{"seite":"handelsmaschine.html","titel":"Kapitel 21: Die vollständige quantitative Handelsmaschine","anker":null,"text":""},{"seite":"handelsmaschine.html","titel":"21.1 In 5 Minuten gelöst","anker":"sec:handelsmaschine-schnellstart","text":""},{"seite":"handelsmaschine.html","titel":"21.2 Lernziele","anker":"sec:handelsmaschine-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"handelsmaschine.html","titel":"21.3 Die Architektur","anker":"sec:handelsmaschine-die-architektur","text":""},{"seite":"handelsmaschine.html","titel":"21.4 Rebalancing-Termine richtig bestimmen","anker":"sec:handelsmaschine-rebalancing-termine-richtig-bestimmen","text":""},{"seite":"handelsmaschine.html","titel":"21.5 Die Engine","anker":"sec:handelsmaschine-die-engine","text":""},{"seite":"handelsmaschine.html","titel":"21.6 Die fünf Selbsttäuschungen des Backtestens","anker":"sec:handelsmaschine-die-fuenf-selbsttaeuschungen-des-backtestens","text":""},{"seite":"handelsmaschine.html","titel":"21.7 Vom Backtest zum Betrieb","anker":"sec:handelsmaschine-vom-backtest-zum-betrieb","text":"Ein guter Backtest ist eine notwendige, keine hinreichende Bedingung. Was zwischen Backtest und echtem Einsatz liegt:"},{"seite":"handelsmaschine.html","titel":"21.8 Übungsaufgaben","anker":"sec:handelsmaschine-uebungsaufgaben","text":""},{"seite":"handelsmaschine.html","titel":"21.9 Finde den Denkfehler","anker":"sec:handelsmaschine-denkfehler","text":"Backtest_Fallen.py zeigt drei Fehler, die im Backtest stecken: Lookahead, Survivorship, vergessene Kosten. Alle drei kann man einem Programm ansehen, wenn man es liest."},{"seite":"handelsmaschine.html","titel":"21.10 Micro-Quiz","anker":"sec:handelsmaschine-quiz","text":""},{"seite":"handelsmaschine.html","titel":"21.11 Selbsttest","anker":"sec:handelsmaschine-selbsttest","text":""},{"seite":"handelsmaschine.html","titel":"21.12 Zusammenfassung","anker":"sec:handelsmaschine-zusammenfassung","text":""},{"seite":"praxisfallen.html","titel":"Kapitel 22: Praxisfallen und der Weg zum produktiven Einsatz","anker":null,"text":""},{"seite":"praxisfallen.html","titel":"22.1 In 5 Minuten gelöst","anker":"sec:praxisfallen-schnellstart","text":"Die vorangegangenen vierzehn Kapitel enden fast alle mit derselben Empfehlung: Prüfen Sie das Ergebnis gegen die Wirklichkeit. Dieses Kapitel beginnt damit, diese Prüfung als wiede"},{"seite":"praxisfallen.html","titel":"22.2 Lernziele","anker":"sec:praxisfallen-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"praxisfallen.html","titel":"22.3 Die fünf typischen Praxisfallen","anker":"sec:praxisfallen-die-fuenf-typischen-praxisfallen","text":""},{"seite":"praxisfallen.html","titel":"Falle 1 — Infeasibility","anker":"falle-1-infeasibility","text":"Wenn Parameter unglücklich zusammentreffen — drei Mitarbeitende gleichzeitig krank, kein qualifizierter Ersatz —, meldet der Solver schlicht INFEASIBLE."},{"seite":"praxisfallen.html","titel":"Falle 2 — Der Black-Box-Effekt","anker":"falle-2-der-black-box-effekt","text":"Ein Planer oder Trader lehnt ein System ab, wenn er die Zuweisungen nicht nachvollziehen kann. Akzeptanz ist keine Nebensache, sondern Voraussetzung für den Betrieb."},{"seite":"praxisfallen.html","titel":"Falle 3 — Regimewechsel und Schätzfehler","anker":"falle-3-regimewechsel-und-schätzfehler","text":"Ein Modell, das auf einer mehrjährigen Aufwärtsphase kalibriert wurde, kennt keine Liquiditätskrise und keine Zinswende."},{"seite":"praxisfallen.html","titel":"Falle 4 — Lookahead- und Survivorship-Bias","anker":"falle-4-lookahead--und-survivorship-bias","text":"Ausführlich in Kapitel 21 behandelt. Die Kurzfassung: Beide Fehler machen Backtests systematisch zu gut, ohne eine Fehlermeldung zu erzeugen. Der Lookahead-Selbsttest aus Abschnitt"},{"seite":"praxisfallen.html","titel":"Falle 5 — Laufzeitexplosion","anker":"falle-5-laufzeitexplosion","text":"Exakte Optimalität kann bei MILP oder CP-SAT Minuten bis Stunden dauern — und die Laufzeit wächst nicht linear mit der Problemgröße."},{"seite":"praxisfallen.html","titel":"22.4 Architektur einer produktionsreifen OR-Plattform","anker":"sec:praxisfallen-architektur-einer-produktionsreifen-or-plattform","text":""},{"seite":"praxisfallen.html","titel":"22.5 Der gemeinsame Unterbau: or_kern.py","anker":"sec:praxisfallen-or-kern","text":"Die fünf Prinzipien oben beschreiben eine Plattform. Der Weg dorthin beginnt aber viel kleiner — mit der Frage, welche Teile eines Optimierungsprogramms immer dieselben sind."},{"seite":"praxisfallen.html","titel":"Die vier Schichten","anker":"die-vier-schichten","text":"\\underbrace{\\text{Rohdaten}}_{\\text{Excel, CSV, ERP}}\n\\;\\longrightarrow\\;\n\\underbrace{\\text{Domänenmodell}}_{\\text{geprüft, solverfrei}}\n\\;\\longrightarrow\\;\n\\underbrace{\\text{Model"},{"seite":"praxisfallen.html","titel":"Ein Enum für fünf Bibliotheken","anker":"ein-enum-für-fünf-bibliotheken","text":"Der unscheinbarste, aber wirksamste Teil ist die Statusübersetzung. Dieselbe Aussage heißt in den fünf im Buch verwendeten Bibliotheken:"},{"seite":"praxisfallen.html","titel":"22.6 Der Solverwechsel in der Praxis","anker":"sec:praxisfallen-solverwechsel","text":"Der vorige Abschnitt endet mit einer Behauptung: Ein Solverwechsel betreffe genau einen Baustein. Das ist die Art Satz, die in jedem Architekturvortrag vorkommt und die man deshalb"},{"seite":"praxisfallen.html","titel":"22.7 Eine Checkliste vor dem Produktivgang","anker":"sec:praxisfallen-eine-checkliste-vor-dem-produktivgang","text":"Arbeiten Sie diese Liste ab, bevor ein OR-System in Betrieb geht:"},{"seite":"praxisfallen.html","titel":"22.8 Weiterführende Literatur und Roadmap","anker":"sec:praxisfallen-weiterfuehrende-literatur-und-roadmap","text":"Grundlagen und lineare/ganzzahlige Optimierung * Bertsimas & Tsitsiklis: Introduction to Linear Optimization — Standardwerk zu Simplex, Dualität, Polyedertheorie. * Wolsey: Int"},{"seite":"praxisfallen.html","titel":"22.9 Übungsaufgaben","anker":"sec:praxisfallen-uebungsaufgaben","text":""},{"seite":"praxisfallen.html","titel":"22.10 Finde den Denkfehler","anker":"sec:praxisfallen-denkfehler","text":"Falle 5 — die Laufzeitexplosion — zeigt sich im Betrieb selten als Absturz. Sie zeigt sich als gar nichts."},{"seite":"praxisfallen.html","titel":"22.11 Micro-Quiz","anker":"sec:praxisfallen-quiz","text":""},{"seite":"praxisfallen.html","titel":"22.12 Selbsttest","anker":"sec:praxisfallen-selbsttest","text":""},{"seite":"praxisfallen.html","titel":"22.13 Zusammenfassung","anker":"sec:praxisfallen-zusammenfassung","text":""},{"seite":"testing.html","titel":"Kapitel 23: Testen, Messen, Ausliefern","anker":null,"text":""},{"seite":"testing.html","titel":"23.1 In 5 Minuten gelöst","anker":"sec:testing-schnellstart","text":""},{"seite":"testing.html","titel":"23.2 Lernziele","anker":"sec:testing-lernziele","text":"Nach diesem Kapitel können Sie …"},{"seite":"testing.html","titel":"23.3 Warum Optimierungsmodelle schwer zu testen sind","anker":"sec:testing-warum-schwer","text":"Bei einer gewöhnlichen Funktion schreibt man den erwarteten Wert hin:"},{"seite":"testing.html","titel":"23.4 Die Testsuite","anker":"sec:testing-suite","text":""},{"seite":"testing.html","titel":"23.5 Wer testet die Tests?","anker":"sec:testing-mutation","text":"33 grüne Tests. Das ist noch kein Ergebnis."},{"seite":"testing.html","titel":"Der erste Lauf fand zwei Lücken","anker":"der-erste-lauf-fand-zwei-lücken","text":"Die Suite hatte zunächst 31 Tests. Der Mutationstest sagte dazu:"},{"seite":"testing.html","titel":"23.6 Ein Vergleich, dem man glauben kann","anker":"sec:testing-benchmark","text":"Solververgleiche stehen in jedem Blog, und die meisten sind wertlos. Nicht, weil falsch gemessen wurde, sondern weil zu wenig dazugesagt wird."},{"seite":"testing.html","titel":"Die interessanteste Spalte heißt „Anteil”","anker":"die-interessanteste-spalte-heißt-anteil","text":"Sie sagt, wie viel der Zeit in den Aufbau des Modells geht statt ins Lösen — und die Zahlen sind unbequem:"},{"seite":"testing.html","titel":"23.7 Das Modell als Dienst","anker":"sec:testing-dienst","text":"Ein Modell hinter eine HTTP-Schnittstelle zu hängen sieht nach einer Fingerübung aus. Es gibt aber einen Unterschied, der die ganze Bauform bestimmt:"},{"seite":"testing.html","titel":"Threads oder Prozesse? Eine Messung, keine Meinung","anker":"threads-oder-prozesse-eine-messung-keine-meinung","text":"Punkt 6 des Selbsttests misst, ob vier Solverläufe in Threads sich überlappen. Das Ergebnis ist ein Faktor von rund 3,5 bei vier Threads: OR-Tools rechnet in C++ und gibt den GIL w"},{"seite":"testing.html","titel":"Was im echten Betrieb dazukommt","anker":"was-im-echten-betrieb-dazukommt","text":"Das Beispiel hält das Auftragsbuch in einem dict und rechnet in Threads desselben Prozesses. Für einen einzelnen Rechner reicht das; darüber hinaus nicht:"},{"seite":"testing.html","titel":"23.8 Übungsaufgaben","anker":"sec:testing-uebungsaufgaben","text":""},{"seite":"testing.html","titel":"23.9 Finde den Denkfehler","anker":"sec:testing-denkfehler","text":""},{"seite":"testing.html","titel":"23.10 Micro-Quiz","anker":"sec:testing-quiz","text":""},{"seite":"testing.html","titel":"23.11 Selbsttest","anker":"sec:testing-selbsttest","text":""},{"seite":"testing.html","titel":"23.12 Zusammenfassung","anker":"sec:testing-zusammenfassung","text":""},{"seite":"projektwerkstatt.html","titel":"Projektwerkstatt","anker":null,"text":""},{"seite":"projektwerkstatt.html","titel":"Wie Sie ein Projekt bearbeiten","anker":"wie-sie-ein-projekt-bearbeiten","text":"Halten Sie sich an diese Reihenfolge — sie ist aus vielen gescheiterten und einigen gelungenen Projekten destilliert:"},{"seite":"projektwerkstatt.html","titel":"P1 — Vertretungsplaner für eine Schule","anker":"p1-vertretungsplaner-für-eine-schule","text":""},{"seite":"projektwerkstatt.html","titel":"P2 — Schichtplanung für ein Pflegeteam","anker":"p2-schichtplanung-für-ein-pflegeteam","text":""},{"seite":"projektwerkstatt.html","titel":"P3 — Tourenplanung für einen Lieferdienst","anker":"p3-tourenplanung-für-einen-lieferdienst","text":""},{"seite":"projektwerkstatt.html","titel":"P4 — Standort- und Lagernetzplanung","anker":"p4-standort--und-lagernetzplanung","text":""},{"seite":"projektwerkstatt.html","titel":"P5 — Produktionsplanung mit Rüstkosten","anker":"p5-produktionsplanung-mit-rüstkosten","text":""},{"seite":"projektwerkstatt.html","titel":"P6 — Portfolio-Rebalancer für ein Privatdepot","anker":"p6-portfolio-rebalancer-für-ein-privatdepot","text":""},{"seite":"projektwerkstatt.html","titel":"P7 — Risikoreport mit CVaR und Stresstests","anker":"p7-risikoreport-mit-cvar-und-stresstests","text":""},{"seite":"projektwerkstatt.html","titel":"P9 — Wenn der Solver aussteigt: Tourenplanung in Echtgröße","anker":"p9-wenn-der-solver-aussteigt-tourenplanung-in-echtgröße","text":""},{"seite":"projektwerkstatt.html","titel":"P10 — Zwei Ziele, eine Entscheidung: Kosten gegen CO₂","anker":"p10-zwei-ziele-eine-entscheidung-kosten-gegen-co₂","text":""},{"seite":"projektwerkstatt.html","titel":"P11 — Vom Skript zum Dienst: das Modell übergeben","anker":"p11-vom-skript-zum-dienst-das-modell-übergeben","text":""},{"seite":"projektwerkstatt.html","titel":"P8 — Freies Projekt aus Ihrem eigenen Umfeld","anker":"p8-freies-projekt-aus-ihrem-eigenen-umfeld","text":""},{"seite":"projektwerkstatt.html","titel":"Eine Bitte zum Schluss","anker":"eine-bitte-zum-schluss","text":"Wenn Sie ein Projekt fertig haben, machen Sie zwei Dinge:"},{"seite":"anhang-loesungen.html","titel":"Anhang A: Lösungen zu allen Übungsaufgaben","anker":null,"text":""},{"seite":"anhang-loesungen.html","titel":"A.1 Lösungen zu Kapitel „Einführung in Operations Research — Vom Ursprung zur mathematischen Entscheidungsfindung“","anker":"sec:loesungen-einfuehrung","text":"1.1 — Analytik-Stufen. (a) deskriptiv (Vergangenheit beschreiben). (b) prädiktiv (Prognose). (c) präskriptiv (Handlungsanweisung unter Restriktionen). (d) prädiktiv (Klassifikation"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 1.1 — Die Schreinerei verdoppelt ihren Gewinn","anker":"finde-den-denkfehler-1.1-die-schreinerei-verdoppelt-ihren-gewinn","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-einfuehrung","text":"1 — (c) präskriptiv. Die Frage lautet „was tun“, und es gibt eine Restriktion (das Restbudget). Dass man für die Bewertung eine Prognose braucht, macht die Frage nicht prädiktiv — "},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-einfuehrung","text":""},{"seite":"anhang-loesungen.html","titel":"A.2 Lösungen zu Kapitel „Das mathematische Fundament — Vektoren, Matrizen, Konvexität“","anker":"sec:loesungen-fundament","text":"2.1 — Matrixform lesen. \\max 4x_1 + x_2 + 6x_3 u. d. N. x_1 + 2x_2 \\le 10, x_2 + 3x_3 \\le 12, x \\ge 0. 3 Variablen, 2 Nebenbedingungen (plus Nichtnegativität)."},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 2.1 — Die unauffällige Transposition","anker":"finde-den-denkfehler-2.1-die-unauffällige-transposition","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-fundament","text":"1 — (b). Der Fundamentalsatz sagt nur, dass ein Optimum in einer Ecke angenommen wird. (a) ist falsch, weil es mehrere optimale Ecken geben kann (wenn die Zielfunktion parallel zu "},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-fundament","text":""},{"seite":"anhang-loesungen.html","titel":"A.3 Lösungen zu Kapitel „Das Python-Ökosystem für OR — Solver, Bindings und Modellierungsschichten“","anker":"sec:loesungen-oekosystem","text":"3.1 — Solverwahl. (a) CP-SAT (diskrete Zuordnung mit Zeitfenstern). (b) scipy.optimize.linprog (klassisches Mischungs-LP, klein). (c) CVXPY (konvexes QP). (d) MIQP — CVXPY mit Binä"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 3.1 — Der Solver, der angeblich dreimal schneller ist","anker":"finde-den-denkfehler-3.1-der-solver-der-angeblich-dreimal-schneller-ist","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-oekosystem","text":"1 — (b) CP-SAT. Es geht um diskrete Zuweisung mit logischen Regeln („nicht gleichzeitig“) — CP-SATs Kerngebiet, und Regeln dieser Art formuliert man dort direkt statt über Big-M-Um"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-oekosystem","text":""},{"seite":"anhang-loesungen.html","titel":"A.4 Lösungen zu Kapitel „Vom Management-Wunsch zum Modell“","anker":"sec:loesungen-modellierung","text":"Die fehlende Lesart. Eine dritte Lesart: „Der Umsatzanteil der Stammkunden darf nicht unter 30 % fallen.” Sie zielt weder auf einzelne Aufträge noch auf einzelne Kunden, sondern au"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Die Engpassmaschine soll zu mindestens 92 % ausgelastet sein“","anker":"denkfehler-loesung-modellierung","text":"Fehler 1: Auslastung ist ein Ergebnis, keine Anforderung."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-modellierung","text":"1. b) Die Zielfunktion ist eine vollständige Aussage darüber, was zählt. Was nicht darin steht, ist für das Modell nicht „weniger wichtig”, sondern wertlos. (a) und (c) sind frei e"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-modellierung","text":""},{"seite":"anhang-loesungen.html","titel":"A.5 Lösungen zu Kapitel „Lineare Programmierung — Simplex, Dualität und Schattenpreise“","anker":"sec:loesungen-lp","text":"4.1 — Schlupf deuten. (a) Ressourcen 1 und 3 (Schlupf 0). (b) Ja: Überall ist s_i \\cdot y_i = 0. (c) In Ressource 3 — der höchste Schattenpreis (9,8) bedeutet den größten Grenznutz"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 4.1 — Die 380 000-Euro-Maschine","anker":"finde-den-denkfehler-4.1-die-380-000-euro-maschine","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-lp","text":"1 — (b). Genau der Satz vom komplementären Schlupf: s_i \\cdot y_i = 0. Ist Schlupf vorhanden, muss der Dualwert null sein. (a) hat die Aussage in ihr Gegenteil verkehrt; (c) verwec"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-lp","text":""},{"seite":"anhang-loesungen.html","titel":"A.6 Lösungen zu Kapitel „Gemischt-ganzzahlige Optimierung — Diskrete Entscheidungen und Branch-and-Bound“","anker":"sec:loesungen-milp","text":"5.1 — Runden widerlegen. Beispiel: \\max x_1 + x_2 u. d. N. 10x_1 + 10x_2 \\le 15, ganzzahlig. LP-Optimum Z = 1{,}5; Abrunden ergibt (0,0) mit Z = 0 — 100 % Verlust. Das ganzzahlige "},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 5.1 — Elf Lager, die keine Fixkosten kosten","anker":"finde-den-denkfehler-5.1-elf-lager-die-keine-fixkosten-kosten","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-milp","text":"1 — (b). Der Gap (48\\,200 - 47\\,100)/48\\,200 = 2{,}3\\,\\% ist eine Garantie, keine Schätzung: Besser als 47 100 € kann es nachweislich nicht werden, also ist der vorliegende Plan hö"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-milp","text":""},{"seite":"anhang-loesungen.html","titel":"A.7 Lösungen zu Kapitel „Constraint Programming mit CP-SAT — Logik, Scheduling und Zuweisung“","anker":"sec:loesungen-cpsat","text":"6.1 — Propagation. Aus x_1 + x_2 = 8 und x_1 < x_2 folgt x_1 < 4, also x_1 \\in \\{2,3\\} (denn x_2 = 8-x_1 \\le 6 verlangt x_1 \\ge 2) und entsprechend x_2 \\in \\{5,6\\}. Aus 36 Ko"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 6.1 — Die Betriebsvereinbarung, die niemanden interessiert","anker":"finde-den-denkfehler-6.1-die-betriebsvereinbarung-die-niemanden-interessiert","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-cpsat","text":"1 — (b) wirkungslos. INFEASIBLE ist kein Abbruch, sondern ein Beweis: CP-SAT hat gezeigt, dass keine zulässige Lösung existiert. Mehr Zeit (a) oder mehr Arbeiter (c) ändern daran n"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-cpsat","text":""},{"seite":"anhang-loesungen.html","titel":"A.8 Lösungen zu Kapitel „Graphen, Flüsse und Touren — Min-Cost-Flow, Matching und VRP“","anker":"sec:loesungen-graphen","text":"7.1 — Flusserhaltung. b_i = (15+3) - (12+8) = -2 → Senke (Nettobedarf 2)."},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 7.1 — Die vergessene Dimension","anker":"finde-den-denkfehler-7.1-die-vergessene-dimension","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-graphen","text":"1 — (b) totale Unimodularität. Jede quadratische Teilmatrix hat Determinante 0, +1 oder -1; bei ganzzahliger rechter Seite sind deshalb alle Ecken des zulässigen Bereichs ganzzahli"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-graphen","text":""},{"seite":"anhang-loesungen.html","titel":"A.9 Lösungen zu Kapitel „Metaheuristiken — wenn der exakte Solver aussteigt“","anker":"sec:loesungen-metaheuristiken","text":"Die Kurzsichtigkeit von Hand. Ab Schwarz wählt die Faustregel: Schwarz → Dunkelrot (26) → Rot (5) → … und muss dann in die helle Gruppe: Rot → Weiß (50) → Elfenbein (2) → Beige (2)"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Unsere Heuristik ist 6 % besser“","anker":"denkfehler-loesung-metaheuristiken","text":"Es fehlt die Schranke."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-metaheuristiken","text":"1. b) Eine lokale Suche lebt von der Zahl geprüfter Züge. Die volle Summe kostet O(n) statt O(1) — bei 500 Aufträgen zwei Größenordnungen weniger Züge im selben Zeitbudget. Numeris"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-metaheuristiken","text":""},{"seite":"anhang-loesungen.html","titel":"A.10 Lösungen zu Kapitel „Spaltengenerierung“","anker":"sec:loesungen-dekomposition","text":"Das Abbruchkriterium. Die Eins ist der Zielfunktionskoeffizient eines Musters: Jedes geschnittene Muster verbraucht genau eine Mutterrolle, und die Zielfunktion lautet \\min \\sum_p "},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Die LP-Lösung sagt 72,92 — also runden wir auf“","anker":"denkfehler-loesung-dekomposition","text":"Das Aufrunden kostet sechs Rollen — acht Prozent."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-dekomposition","text":"1. b) Das Mustermodell kennt keine einzelnen Rollen und damit keine austauschbaren Objekte — die Symmetrie verschwindet. (a) trifft zufällig auch zu, ist aber nicht der Grund; (c) "},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-dekomposition","text":""},{"seite":"anhang-loesungen.html","titel":"A.11 Lösungen zu Kapitel „Quadratische und nichtlineare Optimierung — KKT, Lagrange, Konvexität“","anker":"sec:loesungen-qp-nlp","text":"8.1 — Konvexität einordnen. PSD (alle \\ge 0) → konvex, aber nicht streng konvex; die Lösung ist nicht notwendig eindeutig. Der Eigenwert 0 bedeutet eine flache Richtung: Entlang de"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 8.1 — Die Kovarianzmatrix aus dem Controlling","anker":"finde-den-denkfehler-8.1-die-kovarianzmatrix-aus-dem-controlling","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-qp-nlp","text":"1 — (b). Der DCPError ist eine inhaltliche Aussage: Für diese Formulierung kann es keine Optimalitätsgarantie geben. Auf scipy auszuweichen (a) beseitigt die Meldung, nicht die Urs"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-qp-nlp","text":""},{"seite":"anhang-loesungen.html","titel":"A.12 Lösungen zu Kapitel „Optimierung unter Unsicherheit — Monte-Carlo, Stochastik, Robustheit“","anker":"sec:loesungen-unsicherheit","text":"9.1 — Fluch des Durchschnitts. Weil sich Verzögerungen fortpflanzen und die Verteilung rechtsschief ist: Ein Gewerk, das früher fertig wird, beschleunigt selten den Gesamtablauf (d"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 9.1 — Warum jedes Projekt zu spät fertig wird","anker":"finde-den-denkfehler-9.1-warum-jedes-projekt-zu-spät-fertig-wird","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-unsicherheit","text":"1 — (b). Das kritische Verhältnis 1400/(1400+120) = 0{,}921 gibt an, wie weit man sich auf die günstigere Fehlerseite stellen soll: Bestellt wird das 92,1-%-Quantil des Bedarfs, hi"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-unsicherheit","text":""},{"seite":"anhang-loesungen.html","titel":"A.13 Lösungen zu Kapitel „Dynamische Programmierung — Die Bellman-Gleichung und Order-Execution“","anker":"sec:loesungen-dynamische-programmierung","text":"10.1 — Bausteine. Stufe = Tag 1–5; Zustand = verbleibende Distanz (ggf. plus Erschöpfungsgrad); Aktion = heutige Tagesetappe; Wertfunktion = minimale Restanstrengung. Nicht in den "},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 10.1 — Der Zustand, der zu wenig weiß","anker":"finde-den-denkfehler-10.1-der-zustand-der-zu-wenig-weiß","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-dynamische-programmierung","text":"1 — (b). V_t setzt V_{t+1} voraus: Eine Entscheidung lässt sich erst bewerten, wenn feststeht, was sie für die Zukunft bedeutet. Am Ende ist dieser Wert bekannt (V_T = g), deshalb "},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-dynamische-programmierung","text":""},{"seite":"anhang-loesungen.html","titel":"A.14 Lösungen zu Kapitel „Mehrere Ziele — Pareto-Fronten statt Gewichte“","anker":"sec:loesungen-mehrziel","text":"Dominanz prüfen. P (14 000 €, 6 000 kg) und Q (13 800 €, 6 100 kg): keiner dominiert den anderen. Q ist billiger, P ist sauberer — beide sind pareto-optimal, die Wahl ist eine Präf"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Wir haben die Gewichte sauber kalibriert“","anker":"denkfehler-loesung-mehrziel","text":"Der Lösungsraum wurde nicht vollständig abgetastet — und zwar aus prinzipiellen Gründen."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-mehrziel","text":"1. b) Geometrisch heißt „gewichtete Summe minimieren“: eine Gerade von links unten an die Punktwolke schieben. Sie berührt immer einen Eckpunkt der unteren konvexen Hülle; Punkte d"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-mehrziel","text":""},{"seite":"anhang-loesungen.html","titel":"A.15 Lösungen zu Kapitel „Predict-then-Optimize“","anker":"sec:loesungen-prognose","text":"Andere Preise. Der Überhang kostet nur noch 1,50 €, die Fehlmenge weiterhin 6 €. Das kritische Verhältnis steigt von 6/9 = 0{,}667 auf 6/7{,}5 = 0{,}80. Die Bestellmenge verschiebt"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Wir haben die Prognose um 18 % verbessert“","anker":"denkfehler-loesung-prognose","text":"Der Sicherheitszuschlag stammt aus den Residuen des alten Modells."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-prognose","text":"1. b) Die Kostenasymmetrie verschiebt das Optimum vom Erwartungswert zum kritischen Quantil. (a) und (c) sind reale Probleme, aber nicht dieses: Selbst bei perfekt bekannter, norma"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-prognose","text":""},{"seite":"anhang-loesungen.html","titel":"A.16 Lösungen zu Kapitel „Die Strukturbrücke — dieselbe Mathematik, zwei Welten“","anker":"sec:loesungen-bruecke","text":"Die dritte Ressource. „Liquidität” — wie schnell sich eine Position ohne Kursabschlag verkaufen lässt — entspricht in der Werkstatt der Umrüstbarkeit oder der Vorlaufzeit: Wie schn"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Das ist doch dasselbe Problem“","anker":"denkfehler-loesung-bruecke","text":"Fehler 1: Varianz ist hier das falsche Risikomaß — genau der Fall, für den es CVaR gibt."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-bruecke","text":"1. b) Beide Probleme haben dieselbe Struktur: knappe Größen auf konkurrierende Verwendungen verteilen. (a) ist falsch — Produktionsproblem ist streng typisiert und validiert beim E"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-bruecke","text":""},{"seite":"anhang-loesungen.html","titel":"A.17 Lösungen zu Kapitel „Supply-Chain und Energieeinsatz unter Unsicherheit“","anker":"sec:loesungen-supplychain","text":"Der Umschlagpunkt. Bei 60 MW statt 120 MW halbiert sich die Grenzkostendifferenz je Stunde (83 €/MWh × 60 MW = 4 980 €/h). Der Umschlagpunkt verdoppelt sich auf 7,7 Stunden."},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Wir rechnen mit dem P50-Szenario“","anker":"denkfehler-loesung-supplychain","text":"Fehler 1: Der Median ist keine Absicherung, sondern eine Wette auf die Hälfte der Fälle."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-supplychain","text":"1. b) Anfahrkosten und Mindestlaufzeiten koppeln die Stunden. Die Merit-Order ist eine Sortierung und kennt keine Kopplung über die Zeit; sie ist für einen einzelnen Zeitpunkt rich"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-supplychain","text":""},{"seite":"anhang-loesungen.html","titel":"A.18 Lösungen zu Kapitel „Finanzdaten-Modellierung — Renditen, Kovarianz und Shrinkage“","anker":"sec:loesungen-finanzdaten","text":"11.1 — Renditeart. (a) diskret. (b) logarithmisch. (c) logarithmisch (bzw. diskret kumuliert über Produkt). (d) logarithmisch (statistische Eigenschaften)."},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 11.1 — Das risikofreie Portfolio","anker":"finde-den-denkfehler-11.1-das-risikofreie-portfolio","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-finanzdaten","text":"1 — (b) bei 75 %. 1{,}0 \\cdot 0{,}5 \\cdot 1{,}5 = 0{,}75. Prozentuale Änderungen verknüpfen sich multiplikativ; ein Verlust wiegt schwerer als ein gleich großer Gewinn, weil er von"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-finanzdaten","text":""},{"seite":"anhang-loesungen.html","titel":"A.19 Lösungen zu Kapitel „Die moderne Portfoliotheorie nach Markowitz“","anker":"sec:loesungen-markowitz","text":"12.1 — Diversifikationseffekt."},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 12.1 — Zwölf gleiche Anlagen, ein sehr ungleiches Portfolio","anker":"finde-den-denkfehler-12.1-zwölf-gleiche-anlagen-ein-sehr-ungleiches-portfolio","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-markowitz","text":"1 — (b). Das renditestärkste Portfolio auf der Effizienzlinie hat notwendig das schlechteste Rendite-Risiko-Verhältnis (im Kapitelbeispiel 0,44 gegenüber 0,75 beim Optimum) und bes"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-markowitz","text":""},{"seite":"anhang-loesungen.html","titel":"A.20 Lösungen zu Kapitel „Tail-Risiko, CVaR und Transaktionskosten“","anker":"sec:loesungen-cvar","text":"13.1 — VaR und CVaR von Hand. Sortierte Verluste: -2{,}0; -1{,}2; -0{,}8; -0{,}4; 0{,}3; 0{,}6; 1{,}1; 2{,}1; 5{,}4; 8{,}9. Das 80 %-Quantil ist der 8. Wert: \\text{VaR}_{80\\%} = 2{"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 13.1 — Das Risikobudget, das durch Diversifikation stieg","anker":"finde-den-denkfehler-13.1-das-risikobudget-das-durch-diversifikation-stieg","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-cvar","text":"1 — (b) nichts. Der VaR ist ein Quantil: Er markiert die Schwelle und sagt nichts über den Bereich dahinter. Genau das zeigt der Schnellstart dieses Kapitels — zwei Anlagen mit ide"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-cvar","text":""},{"seite":"anhang-loesungen.html","titel":"A.21 Lösungen zu Kapitel „Die vollständige quantitative Handelsmaschine“","anker":"sec:loesungen-handelsmaschine","text":"14.1 — Lookahead erkennen. (a) sauber (:heute). (b) Lookahead — gesamter Zeitraum. (c) Lookahead — Volatilität über den ganzen Zeitraum enthält Zukunft. (d) sauber — Gewichte aus D"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 14.1 — Die Strategie mit dem hochsignifikanten Ergebnis","anker":"finde-den-denkfehler-14.1-die-strategie-mit-dem-hochsignifikanten-ergebnis","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-handelsmaschine","text":"1 — (b) nichts. Beide Kennzahlen sind Mittelwerte über die Zeit und blind für die Reihenfolge. Der Schnellstart dieses Kapitels zeigt es an denselben Tagesrenditen, nur anders ange"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-handelsmaschine","text":""},{"seite":"anhang-loesungen.html","titel":"A.22 Lösungen zu Kapitel „Praxisfallen und der Weg zum produktiven Einsatz“","anker":"sec:loesungen-praxisfallen","text":"15.1 — Hart oder weich, revisited. (a) hart: gesetzliche Ruhezeit, Qualifikationspflicht. (b) weich: individuelle Wunschtage, Vermeidung von Freistunden. (c) diskutabel: Höchstzahl"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler 15.1 — Das Modell, das seit einem Jahr nicht mehr optimiert","anker":"finde-den-denkfehler-15.1-das-modell-das-seit-einem-jahr-nicht-mehr-optimiert","text":""},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-praxisfallen","text":"1 — (b) das Zeitlimit greift. Eine Laufzeit, die exakt dem Limit entspricht und über Monate konstant bleibt, ist kein Stabilitätsbeleg, sondern zeigt, dass der Solver jedes Mal abg"},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-praxisfallen","text":""},{"seite":"anhang-loesungen.html","titel":"A.23 Lösungen zu Kapitel „Testen, Messen, Ausliefern“","anker":"sec:loesungen-testing","text":"Den Schnellstart reparieren. Zwei Wege: (a) Abrunden statt runden — np.floor statt np.round. Das ist immer zulässig, weil weniger produzieren nie eine Kapazität sprengt, kostet abe"},{"seite":"anhang-loesungen.html","titel":"Finde den Denkfehler — „Die Suite ist grün, das Modell stimmt”","anker":"denkfehler-loesung-testing","text":"Alle Prüfungen benutzen dieselbe falsche Zahl."},{"seite":"anhang-loesungen.html","titel":"Micro-Quiz","anker":"quiz-loesung-testing","text":"1. b) Es gibt keine unabhängige Quelle für die richtige Antwort — sonst bräuchte man den Solver nicht. (a) ist bei festgelegtem Seed und Zeitlimit meist beherrschbar; (c) löst man "},{"seite":"anhang-loesungen.html","titel":"Selbsttest","anker":"selbsttest-loesung-testing","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"Anhang B: Katalog der Modellierungsmuster","anker":null,"text":""},{"seite":"anhang-modellierungsmuster.html","titel":"Übersicht","anker":"übersicht","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"Logische Schalter","anker":"logische-schalter","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B1 — Aktivierungsschalter (Fixkosten)","anker":"b1-aktivierungsschalter-fixkosten","text":"Regel. „Wenn überhaupt etwas produziert wird, fallen Rüstkosten F an.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B2 — Semikontinuierliche Variable","anker":"b2-semikontinuierliche-variable","text":"Regel. „Entweder gar nicht oder mindestens L (und höchstens U).“"},{"seite":"anhang-modellierungsmuster.html","titel":"B3 — Implikation","anker":"b3-implikation","text":"Regel. „Wenn A gewählt wird, muss auch B gewählt werden.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B4 — Entweder-Oder (disjunktive Bedingung)","anker":"b4-entweder-oder-disjunktive-bedingung","text":"Regel. „Es muss f(\\mathbf{x}) \\le b_1 oder g(\\mathbf{x}) \\le b_2 gelten.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B5 — Exklusiv-Oder","anker":"b5-exklusiv-oder","text":"Regel. „Genau eine der Optionen wird gewählt.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B6 — Kardinalität","anker":"b6-kardinalität","text":"Regel. „Höchstens (mindestens, genau) K von N.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B7 — Bedingte Kopplung (Konjunktion)","anker":"b7-bedingte-kopplung-konjunktion","text":"Regel. „Wenn A und B, dann auch C.“"},{"seite":"anhang-modellierungsmuster.html","titel":"Mengen und Grenzen","anker":"mengen-und-grenzen","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B8 — Weiche Grenze mit Strafkosten","anker":"b8-weiche-grenze-mit-strafkosten","text":"Regel. „Möglichst nicht über b — wenn doch, kostet es.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B9 — Gestaffelte Preise (stückweise linear)","anker":"b9-gestaffelte-preise-stückweise-linear","text":"Regel. „Die ersten 100 Stück kosten 5 €, danach 8 €.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B10 — Absolutbetrag / Abweichung","anker":"b10-absolutbetrag-abweichung","text":"Regel. „Die Abweichung vom Zielwert soll klein sein, egal in welche Richtung.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B11 — Min/Max in der Zielfunktion","anker":"b11-minmax-in-der-zielfunktion","text":"Regel. „Der am schlechtesten gestellte Beteiligte soll möglichst gut dastehen“ (Maximin/Fairness)."},{"seite":"anhang-modellierungsmuster.html","titel":"B12 — Verhältnis-Bedingung","anker":"b12-verhältnis-bedingung","text":"Regel. „Der Anteil von Gruppe G soll mindestens 30 % betragen.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B25 — Mindestabnahmemenge über einen Zeitraum","anker":"b25-mindestabnahmemenge-über-einen-zeitraum","text":"Regel. „Entweder wir arbeiten mit diesem Lieferanten gar nicht, oder wir nehmen ihm im Jahr mindestens 500 Einheiten ab.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B27 — Budgetlimit","anker":"b27-budgetlimit","text":"Regel. „Alle Maßnahmen zusammen dürfen 2 Mio. € nicht überschreiten.“"},{"seite":"anhang-modellierungsmuster.html","titel":"Zeit und Reihenfolge","anker":"zeit-und-reihenfolge","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B13 — Vorrangbeziehung","anker":"b13-vorrangbeziehung","text":"Regel. „Arbeitsgang B darf erst beginnen, wenn A fertig ist.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B14 — Nichtüberlappung","anker":"b14-nichtüberlappung","text":"Regel. „Eine Maschine bearbeitet nur einen Job gleichzeitig.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B15 — Kumulative Ressource","anker":"b15-kumulative-ressource","text":"Regel. „Zu keinem Zeitpunkt dürfen mehr als 3 Arbeiten gleichzeitig laufen“ bzw. „die Stromlast darf 500 kW nie übersteigen“."},{"seite":"anhang-modellierungsmuster.html","titel":"B16 — Gleitendes Fenster","anker":"b16-gleitendes-fenster","text":"Regel. „Höchstens 5 Arbeitstage in Folge“ / „mindestens 2 freie Tage je Woche“."},{"seite":"anhang-modellierungsmuster.html","titel":"B17 — Umrüst- bzw. Wechselkosten","anker":"b17-umrüst--bzw.-wechselkosten","text":"Regel. „Ein Produktwechsel kostet Rüstzeit.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B26 — Rüstzeit als Kapazitätsverbrauch","anker":"b26-rüstzeit-als-kapazitätsverbrauch","text":"Regel. „Das Umrüsten kostet nicht nur Geld, es kostet Maschinenstunden — und die fehlen dann für die Produktion.“"},{"seite":"anhang-modellierungsmuster.html","titel":"Robustheit und Diagnose","anker":"robustheit-und-diagnose","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B18 — Schlupfvariablen gegen Unlösbarkeit","anker":"b18-schlupfvariablen-gegen-unlösbarkeit","text":"Das wichtigste Muster für den Produktivbetrieb."},{"seite":"anhang-modellierungsmuster.html","titel":"B19 — Hierarchische Ziele (lexikografisch)","anker":"b19-hierarchische-ziele-lexikografisch","text":"Regel. „Erst die Besetzung sicherstellen, dann die Fairness optimieren.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B20 — Symmetriebrechung","anker":"b20-symmetriebrechung","text":"Regel. „Drei identische Maschinen — der Solver soll nicht alle Vertauschungen durchprobieren.“"},{"seite":"anhang-modellierungsmuster.html","titel":"B21 — Worst-Case-Abzug (robuste Formulierung)","anker":"b21-worst-case-abzug-robuste-formulierung","text":"Regel. „Rechne nicht mit dem geschätzten Wert, sondern mit dem ungünstigsten plausiblen.“"},{"seite":"anhang-modellierungsmuster.html","titel":"Netzwerke","anker":"netzwerke","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B22 — Flusserhaltung","anker":"b22-flusserhaltung","text":"\\sum_{j:(i,j)\\in E} x_{ij} - \\sum_{k:(k,i)\\in E} x_{ki} = b_i \\qquad \\forall i"},{"seite":"anhang-modellierungsmuster.html","titel":"B23 — Zuordnung 1:1","anker":"b23-zuordnung-11","text":""},{"seite":"anhang-modellierungsmuster.html","titel":"B24 — Subtour-Eliminierung","anker":"b24-subtour-eliminierung","text":"MTZ (einfach, aber schwach): u_i - u_j + C x_{ij} \\le C - d_j \\qquad \\forall i\\ne j"},{"seite":"anhang-modellierungsmuster.html","titel":"Ein Wort zur Auswahl","anker":"ein-wort-zur-auswahl","text":"Wenn mehrere Muster passen, entscheiden Sie nach dieser Reihenfolge:"},{"seite":"anhang-fehlerdiagnose.html","titel":"Anhang C: Fehlerdiagnose-Handbuch","anker":null,"text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"Symptom-Schnellübersicht","anker":"symptom-schnellübersicht","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"C1 — INFEASIBLE","anker":"c1-infeasible","text":"Bedeutung. Es gibt keinen einzigen Punkt, der alle Bedingungen gleichzeitig erfüllt. Das ist eine Aussage über Ihr Modell, nicht über den Solver."},{"seite":"anhang-fehlerdiagnose.html","titel":"Diagnose in fünf Schritten","anker":"diagnose-in-fünf-schritten","text":"Schritt 1 — Trivialprüfungen von Hand. Rechnen Sie die offensichtlichen Bilanzen nach, bevor Sie den Solver befragen:"},{"seite":"anhang-fehlerdiagnose.html","titel":"Wenn die fünf Schritte nicht reichen: den Konflikt einkreisen","anker":"wenn-die-fünf-schritte-nicht-reichen-den-konflikt-einkreisen","text":"Warum Schritt 2 so oft ins Leere läuft. Bedingungen einzeln abzuschalten funktioniert nur, solange es einen Schuldigen gibt. Überlagern sich zwei unabhängige Widersprüche, bleibt d"},{"seite":"anhang-fehlerdiagnose.html","titel":"C2 — UNBOUNDED","anker":"c2-unbounded","text":"Bedeutung. Die Zielfunktion lässt sich unbegrenzt verbessern."},{"seite":"anhang-fehlerdiagnose.html","titel":"C3 — Zu langsam","anker":"c3-zu-langsam","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"Diagnosereihenfolge","anker":"diagnosereihenfolge","text":"1. Ist es überhaupt das Lösen?"},{"seite":"anhang-fehlerdiagnose.html","titel":"C4 — Unsinniges Ergebnis","anker":"c4-unsinniges-ergebnis","text":"Der Solver hat immer recht — bezogen auf das Modell, das Sie ihm gegeben haben. Wenn das Ergebnis unsinnig ist, beschreibt Ihr Modell nicht das Problem, das Sie meinen."},{"seite":"anhang-fehlerdiagnose.html","titel":"Checkliste","anker":"checkliste","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"Die wirksamste Gegenmaßnahme","anker":"die-wirksamste-gegenmaßnahme","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"C5 — Instabile Lösung","anker":"c5-instabile-lösung","text":"Symptom. Ein zusätzlicher Handelstag, und das Portfolio sieht völlig anders aus."},{"seite":"anhang-fehlerdiagnose.html","titel":"Derselbe Fehler außerhalb der Finanzwelt","anker":"derselbe-fehler-außerhalb-der-finanzwelt","text":"Instabilität ist kein Portfolioproblem, sondern ein Skalierungsproblem — und es trifft jedes Modell, in dem Größen sehr verschiedener Ordnung nebeneinanderstehen. Euro-Beträge (10^"},{"seite":"anhang-fehlerdiagnose.html","titel":"C6 — Falsche Dualwerte","anker":"c6-falsche-dualwerte","text":"Symptom. Schattenpreise sind negativ, wo sie positiv sein sollten — oder null bei einer offensichtlich knappen Ressource."},{"seite":"anhang-fehlerdiagnose.html","titel":"Die drei Prüfungen","anker":"die-drei-prüfungen","text":"1. Vorzeichenkonvention. Haben Sie zur Maximierung negiert?"},{"seite":"anhang-fehlerdiagnose.html","titel":"C7 — Widersprüchliche Solver","anker":"c7-widersprüchliche-solver","text":"Symptom. linprog sagt 530, CVXPY sagt 529,8."},{"seite":"anhang-fehlerdiagnose.html","titel":"C8 — DCPError","anker":"c8-dcperror","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"C9 — Importfehler","anker":"c9-importfehler","text":""},{"seite":"anhang-fehlerdiagnose.html","titel":"C10 — Verdächtig guter Backtest","anker":"c10-verdächtig-guter-backtest","text":"Faustregel: Eine Sharpe Ratio über 2 bei einer einfachen Strategie ist fast immer ein Fehler, kein Fund."},{"seite":"anhang-fehlerdiagnose.html","titel":"Prüfreihenfolge","anker":"prüfreihenfolge","text":"1. Lookahead-Selbsttest."},{"seite":"anhang-fehlerdiagnose.html","titel":"C11 — Vertauschte Spalten","anker":"c11-vertauschte-spalten","text":"Symptom. Ergebnisse sind plausibel, aber falsch beschriftet — oder eine Sektorgrenze greift auf die falschen Titel."},{"seite":"anhang-fehlerdiagnose.html","titel":"Die goldene Diagnoseregel","anker":"die-goldene-diagnoseregel","text":""},{"seite":"anhang-spickzettel.html","titel":"Anhang D: Spickzettel der Solver","anker":null,"text":""},{"seite":"anhang-spickzettel.html","titel":"D1 — SciPy: linprog und milp","anker":"d1-scipy-linprog-und-milp","text":"Wofür. Die Einstiegsschicht: keine zusätzliche Installation, HiGHS als Unterbau, ideal für lineare und gemischt-ganzzahlige Probleme in Matrixform. Wofür nicht: alles, was sich nic"},{"seite":"anhang-spickzettel.html","titel":"Lineares Programm","anker":"lineares-programm","text":""},{"seite":"anhang-spickzettel.html","titel":"Ganzzahlig: milp","anker":"ganzzahlig-milp","text":""},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine","text":""},{"seite":"anhang-spickzettel.html","titel":"D2 — HiGHS über highspy","anker":"d2-highs-über-highspy","text":"Wofür. Derselbe Solver wie unter SciPy, aber direkt gesteuert: Optionen, Warm-Starts, inkrementelles Ändern eines bestehenden Modells. Wofür nicht: schnelles Hinschreiben — die CSR"},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine-1","text":""},{"seite":"anhang-spickzettel.html","titel":"D3 — OR-Tools: pywraplp","anker":"d3-or-tools-pywraplp","text":"Wofür. Bequeme algebraische Schreibweise für LP und MILP mit umschaltbarem Backend (GLOP, SCIP, CBC, SAT). Wofür nicht: Scheduling und kombinatorische Bedingungen — dafür ist CP-SA"},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine-2","text":""},{"seite":"anhang-spickzettel.html","titel":"D4 — CP-SAT: cp_model","anker":"d4-cp-sat-cp_model","text":"Wofür. Scheduling, Zuordnung, Reihenfolgen, alles Kombinatorische mit globalen Bedingungen. Wofür nicht: stetige Größen — CP-SAT rechnet ausschließlich ganzzahlig. Wer Nachkommaste"},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine-3","text":""},{"seite":"anhang-spickzettel.html","titel":"D5 — CVXPY","anker":"d5-cvxpy","text":"Wofür. Konvexe Probleme: quadratische Ziele, Normen, CVaR, alles mit Regularisierungstermen. Wofür nicht: große kombinatorische Modelle — der Aufbau der Ausdrücke wird dann selbst "},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine-4","text":""},{"seite":"anhang-spickzettel.html","titel":"D6 — Modellierungssprachen: Pyomo und Linopy","anker":"d6-modellierungssprachen-pyomo-und-linopy","text":"Wofür. Beide trennen Modell von Solver: dasselbe Modell läuft ohne Änderung unter HiGHS, CBC, Gurobi. Pyomo denkt in Mengen und Indizes wie eine mathematische Formulierung; Linopy "},{"seite":"anhang-spickzettel.html","titel":"Pyomo","anker":"pyomo","text":""},{"seite":"anhang-spickzettel.html","titel":"Linopy","anker":"linopy","text":""},{"seite":"anhang-spickzettel.html","titel":"Die drei häufigsten Stolpersteine","anker":"die-drei-häufigsten-stolpersteine-5","text":""},{"seite":"anhang-spickzettel.html","titel":"Die gemeinsame Regel","anker":"die-gemeinsame-regel","text":"Alle sechs Seiten haben denselben längsten Abschnitt: die Statusauswertung. Das ist kein Zufall. Ein Solveraufruf hat nie zwei Ausgänge, sondern mindestens fünf — optimal, zulässig"},{"seite":"anhang-glossar-literatur.html","titel":"Anhang E: Glossar und Literatur","anker":null,"text":""},{"seite":"anhang-glossar-literatur.html","titel":"Mathematische Optimierung — Grundlagen","anker":"mathematische-optimierung-grundlagen","text":""},{"seite":"anhang-glossar-literatur.html","titel":"Konvexe, nichtlineare und robuste Optimierung","anker":"konvexe-nichtlineare-und-robuste-optimierung","text":""},{"seite":"anhang-glossar-literatur.html","titel":"Constraint Programming und Scheduling","anker":"constraint-programming-und-scheduling","text":""},{"seite":"anhang-glossar-literatur.html","titel":"Quantitative Finanzmathematik","anker":"quantitative-finanzmathematik","text":""},{"seite":"anhang-glossar-literatur.html","titel":"Software und Dokumentation","anker":"software-und-dokumentation","text":""},{"seite":"anhang-glossar-literatur.html","titel":"Verbände und Normen","anker":"verbände-und-normen","text":""}];
diff --git a/OR_HTML_04/gesamtdokument.html b/OR_HTML_04/gesamtdokument.html
index e68297e..dca8759 100644
--- a/OR_HTML_04/gesamtdokument.html
+++ b/OR_HTML_04/gesamtdokument.html
@@ -742,16 +742,22 @@
@@ -16379,15 +16390,16 @@ The objective is not DCP. Its following subexpressions are not: QuadForm(...)Kapitel 12: Optimierung unter Unsicherheit — Monte-Carlo, Stochastik, Robustheit
📌 Kapitel auf einen Blick
-
Worum geht es? Bisher waren alle Parameter bekannt. In der Realität sind Nachfrage, Rendite und Fahrzeit Zufallsgrößen. Dieses Kapitel zeigt drei Wege, damit umzugehen — und warum „einfach den Mittelwert einsetzen“ der schlechteste davon ist.
+
Worum geht es? Bisher waren alle Parameter bekannt. In der Realität sind Nachfrage, Rendite und Fahrzeit Zufallsgrößen. Dieses Kapitel zeigt vier Wege, damit umzugehen — und warum „einfach den Mittelwert einsetzen“ der schlechteste davon ist.
Voraussetzungen:Kapitel 5, Kapitel 11. Grundbegriffe der Statistik (Erwartungswert, Quantil).
-
Danach können Sie: Ein zweistufiges stochastisches Modell aufstellen, Monte-Carlo zur Risikomessung einsetzen, ein robustes Modell für den Worst Case formulieren — und erkennen, wann Rechnen mit Mittelwerten zulässig ist und wann es systematisch danebengeht.
-
Zeitbedarf: ca. 5,5 Stunden.
+
Danach können Sie: Ein zweistufiges stochastisches Modell aufstellen, Monte-Carlo zur Risikomessung einsetzen, ein robustes Modell für den Worst Case formulieren, eine Wahrscheinlichkeitszusage („mit 95 % Sicherheit“) als lösbare Nebenbedingung schreiben — und erkennen, wann Rechnen mit Mittelwerten zulässig ist und wann es systematisch danebengeht.
… ein zweistufiges stochastisches Programm mit Recourse formulieren.
… Monte-Carlo einsetzen, um Verteilungen statt Punktschätzungen zu bewerten.
… ein robustes Modell mit Unsicherheitsmenge aufstellen und seinen Preis beziffern.
-
… begründen, welcher der drei Ansätze für ein gegebenes Problem passt.
+
… eine Wahrscheinlichkeitszusage („mit 95 % Sicherheit“) als Chance Constraint schreiben — analytisch als Kegelbedingung oder szenariobasiert mit Big-M — und den Preis eines Prozentpunkts Sicherheit beziffern.
+
… begründen, welcher der vier Ansätze für ein gegebenes Problem passt.
… das kritische Verhältnis eines Newsvendor-Problems aufstellen und daraus die optimale Bestellmenge als Quantil ablesen.
… eine Terminzusage als Quantil formulieren statt als Mittelwert.
@@ -16612,17 +16625,18 @@ nicht beim Mittelwert 225 - typisch fuer asymmetrische Kostenfunktionen.<
🎯 Merksatz Der Durchschnittskunde kauft nie. Das Durchschnittsszenario tritt nie ein. Wer mit Mittelwerten plant, optimiert eine Welt, die es nicht gibt.
-
Die drei Ansätze im Überblick
+
Die vier Ansätze im Überblick
Abb. 12.1: Ansätze für Optimierung unter Unsicherheit
-
-
-
-
+
+
+
+
+
@@ -16630,6 +16644,7 @@ nicht beim Mittelwert 225 - typisch fuer asymmetrische Kostenfunktionen.<
Monte-Carlo
Stochastische Programmierung
Robuste Optimierung
+
Chance Constraints
@@ -16638,39 +16653,46 @@ nicht beim Mittelwert 225 - typisch fuer asymmetrische Kostenfunktionen.<
Der Unterschied zwischen den letzten beiden ist der, an dem die meisten Diskussionen hängenbleiben. Die robuste Optimierung sagt: „Es darf nie schiefgehen“ — und zahlt dafür. Die Chance Constraint sagt: „Es darf in höchstens 5 % der Fälle schiefgehen“ — und macht damit verhandelbar, was die Sicherheit kosten darf.
12.4 Monte-Carlo-Simulation
Monte-Carlo heißt schlicht: Ziehe sehr viele Zufallsszenarien, werte für jedes den Plan aus, und betrachte die Verteilung der Ergebnisse statt eines einzigen Werts.
@@ -17099,33 +17121,374 @@ deutlich teurer ist als Leerlauf (5 EUR).
⚠️ Der Preis der Robustheit Robuste Modelle sind konservativ. Wer gegen den absoluten Worst Case absichert, zahlt im Normalfall drauf — oft erheblich. Drei Gegenmittel: 1. Unsicherheitsmenge realistisch wählen. Nicht „alles kann passieren“, sondern „Abweichungen bis zu einer Standardabweichung“. 2. Budgeted Uncertainty (Bertsimas/Sim): Man nimmt an, dass höchstens \Gamma von n Parametern gleichzeitig ihren Worst Case annehmen. \Gamma steuert die Vorsicht stufenlos. 3. Nutzen und Preis immer beziffern — genau wie im Programm oben. Ohne diese zwei Zahlen ist die Diskussion „robust oder nicht“ Glaubenssache.
Die drei bisherigen Ansätze beantworten Fragen, die im Sitzungssaal selten so gestellt werden. „Was ist im Mittel am besten?“ hört ein Vorstand als Ausflucht; „was hält auch im schlimmsten Fall?“ als Einladung, zu viel Geld auszugeben. Gefragt wird fast immer nach einer Wahrscheinlichkeit: Mit welcher Sicherheit hält der Plan?
+
Die Wahrscheinlichkeitsbeschränkung schreibt genau das ins Modell:
📐 Formel-Lesehilfe * \mathbf{a} — der unsichere Vektor: Verfügbarkeiten, Erträge, Fahrzeiten. Er ist keine Zahl, sondern eine Zufallsgröße. * \mathbf{x} — Ihre Entscheidung. Sie fällt, bevor\mathbf{a} sich zeigt. * \alpha — die Ausfallwahrscheinlichkeit, die Sie akzeptieren. \alpha = 0{,}05 heißt „in höchstens 5 % der Fälle darf es schiefgehen“.
+
Ohne Formel gesagt: „Plane so, dass die Zusage in 95 von 100 Fällen hält — und sag mir, was das kostet.“
+
+
Das Problem daran. So aufgeschrieben ist die Bedingung nicht lösbar: Ein Solver kann nichts mit einem Wahrscheinlichkeitsoperator anfangen. Es braucht einen Weg, sie in etwas zu übersetzen, das ein Optimierer versteht. Es gibt zwei, und sie unterscheiden sich in dem, was sie über die Verteilung voraussetzen.
+
Weg 1: Normalverteilung — die Bedingung wird ein Kegel
+
Ist \mathbf{a} \sim \mathcal{N}(\hat{\mathbf{a}}, \boldsymbol{\Sigma}), dann ist \mathbf{a}^\top\mathbf{x} für festes \mathbf{x} selbst normalverteilt, mit Erwartungswert \hat{\mathbf{a}}^\top\mathbf{x} und Standardabweichung \sqrt{\mathbf{x}^\top\boldsymbol{\Sigma}\mathbf{x}}. Damit lässt sich die Wahrscheinlichkeit durch ein Quantil ersetzen:
Das ist eine Second-Order-Cone-Bedingung — dieselbe Bauform, die Kapitel 11 eingeführt hat, und CVXPY löst sie ohne Umstände.
+
+
🎯 Warum eine Norm und keine Summe Der Sicherheitszuschlag \lVert\mathbf{L}^\top\mathbf{x}\rVert_2 ist keine Summe von Einzelzuschlägen je Anlage. Genau darin steckt der Nutzen der Mischung: Zwei gegenläufige Quellen schwanken gemeinsam weniger als jede für sich, und die Norm rechnet das automatisch mit. Ein fester Aufschlag je Anlage könnte das nicht — er wäre linear und würde Diversifikation nicht belohnen.
+
+
Weg 2: Szenarien — Big-M ohne Verteilungsannahme
+
Liegen statt einer Verteilung nur S beobachtete Szenarien vor, bekommt jedes eine Binärvariable z_s, die sagt, ob es verletzt werden darf:
+
+\begin{aligned}
+\mathbf{a}_s^\top\mathbf{x} &\ge b - M z_s && \forall s\\
+\sum_{s} z_s &\le \alpha S\\
+z_s &\in \{0,1\}
+\end{aligned}
+
+
Aus dem Kegelproblem wird ein MILP mit S Binärvariablen. Für M gilt, was Kapitel 6 über die Big-M-Falle sagt: so klein wie möglich. Hier ist M = b die kleinste gültige Schranke, denn \mathbf{a}_s^\top\mathbf{x} \ge 0 — größer kann eine Verletzung gar nicht ausfallen.
+
Der Fall: ein Kraftwerkspark mit 500 MW Zusage
+
#!/usr/bin/env python3
+
+# Chance_Constraints.py
+"""
+Kapitel Unsicherheit: Wahrscheinlichkeitsbeschraenkungen.
+
+Das Management fragt selten "was ist im Mittel am besten?", sondern "mit
+welcher Sicherheit haelt der Plan?". Genau das formuliert eine Chance
+Constraint: P(Versorgung >= Bedarf) >= 1 - alpha.
+
+Gezeigt werden beide Wege dorthin, an derselben Instanz:
+ (a) analytisch - unter Normalverteilungsannahme wird daraus eine
+ Second-Order-Cone-Bedingung, loesbar mit CVXPY
+ (b) szenariobasiert - Big-M mit Binaervariablen, fuer beliebige
+ empirische Verteilungen
+
+Und die beiden Messungen, auf die es ankommt: Was kostet ein Prozentpunkt
+Versorgungssicherheit - und haelt die Zusage auch dann, wenn die Verteilung
+nicht normal ist?
+
+Solver: CLARABEL (Kegel) und SciPy/HiGHS (gemischt-ganzzahlig). Weder ortools
+noch ein direkter highspy-Import, damit alles in einem Prozess laeuft.
+"""
+
+import cvxpy as cp
+import numpy as np
+from scipy.stats import norm
+
+# --- Der Kraftwerkspark ----------------------------------------------------
+# Die Verfuegbarkeit ist der Anteil, den eine installierte MW im Mittel
+# wirklich liefert: bei Wind und Sonne klein und stark schwankend, bei Gas und
+# Biomasse gross und stabil. Die Ausbaugrenze ist der Standort - Flaeche,
+# Genehmigung, Brennstoffversorgung.
+TECHNIK = ["Gaskraftwerk", "Windpark", "Solarpark", "Biomasse"]
+KOSTEN = np.array([65_000.0, 24_000.0, 12_000.0, 60_000.0]) # EUR je MW und Jahr
+VERFUEGBAR = np.array([0.92, 0.35, 0.18, 0.85])
+STREUUNG = np.array([0.05, 0.16, 0.10, 0.04])
+GRENZE = np.array([400.0, 250.0, 600.0, 350.0]) # MW
+
+# Wind und Sonne sind leicht gegenlaeufig: Ein Tiefdruckgebiet bringt Wind und
+# Wolken zugleich. Genau diese Korrelation macht die Bedingung zu einem Kegel
+# und nicht zu einer Summe unabhaengiger Einzelzuschlaege.
+KORRELATION = np.array([
+ [1.00, 0.00, 0.00, 0.05],
+ [0.00, 1.00, -0.25, 0.00],
+ [0.00, -0.25, 1.00, 0.00],
+ [0.05, 0.00, 0.00, 1.00],
+])
+SIGMA = np.diag(STREUUNG) @ KORRELATION @ np.diag(STREUUNG)
+WURZEL = np.linalg.cholesky(SIGMA) # L mit L @ L.T == SIGMA
+
+BEDARF =500.0# MW, die gesichert bereitstehen muessen
+N =len(TECHNIK)
+
+# Die Kaeltewelle mit Dunkelflaute: selten, aber sie trifft alles zugleich.
+# Wind und Sonne brechen fast vollstaendig weg - und, das ist der Punkt, das
+# Gaskraftwerk liefert ebenfalls weniger, weil bei Frost der Netzdruck faellt.
+# Eine Kovarianzmatrix mit Korrelationen um null kann das nicht ausdruecken.
+P_KAELTEWELLE =0.08
+EINBRUCH = np.array([0.72, 0.10, 0.15, 0.88])
+
+
+def mittelwertplan():
+"""Plant mit den Erwartungswerten - ignoriert die Streuung vollstaendig."""
+ x = cp.Variable(N, nonneg=True)
+ problem = cp.Problem(cp.Minimize(KOSTEN @ x),
+ [VERFUEGBAR @ x >= BEDARF, x <= GRENZE])
+ problem.solve(solver=cp.CLARABEL)
+return problem.value, x.value
+
+
+def chance_constraint_analytisch(alpha):
+"""
+ P(a^T x >= BEDARF) >= 1 - alpha unter a ~ N(VERFUEGBAR, SIGMA).
+
+ Aequivalent zu: VERFUEGBAR^T x - z * ||L^T x||_2 >= BEDARF
+ mit z = Phi^-1(1 - alpha). Das ist eine Second-Order-Cone-Bedingung: Der
+ Sicherheitszuschlag ist eine Norm ueber x, kein fester Aufschlag je Anlage.
+ Deshalb belohnt sie Mischung - zwei gegenlaeufige Quellen schwanken
+ gemeinsam weniger als jede fuer sich.
+ """
+ x = cp.Variable(N, nonneg=True)
+ z = norm.ppf(1- alpha)
+ bedingung = VERFUEGBAR @ x - z * cp.norm(WURZEL.T @ x, 2) >= BEDARF
+ problem = cp.Problem(cp.Minimize(KOSTEN @ x), [bedingung, x <= GRENZE])
+ problem.solve(solver=cp.CLARABEL)
+return problem.value, x.value
+
+
+def chance_constraint_szenarien(a, alpha):
+"""
+ Dieselbe Zusage ohne Verteilungsannahme: Fuer jedes Szenario s sagt eine
+ Binaervariable z_s, ob es verletzt werden darf.
+
+ a_s^T x >= BEDARF - M * z_s fuer alle s
+ sum_s z_s <= alpha * S
+
+ M = BEDARF ist die kleinstmoegliche gueltige Schranke, denn a_s^T x >= 0:
+ Groesser kann eine Verletzung gar nicht ausfallen. Ein unnoetig grosses M
+ wuerde die LP-Relaxierung aufweichen und die Suche verlangsamen - die
+ Big-M-Falle aus dem Kapitel Gemischt-ganzzahlige Optimierung.
+ """
+ S =len(a)
+ x = cp.Variable(N, nonneg=True)
+ z = cp.Variable(S, boolean=True)
+ problem = cp.Problem(
+ cp.Minimize(KOSTEN @ x),
+ [a @ x >= BEDARF - BEDARF * z, cp.sum(z) <= alpha * S, x <= GRENZE])
+ problem.solve(solver=cp.SCIPY)
+return problem.value, x.value
+
+
+def ziehe_wetter(anzahl, seed):
+"""Mischverteilung: normales Wetter, mit P_KAELTEWELLE ein Einbruch."""
+ rng = np.random.default_rng(seed)
+ a = rng.multivariate_normal(VERFUEGBAR, SIGMA, size=anzahl)
+ getroffen = rng.random(anzahl) < P_KAELTEWELLE
+ a[getroffen] *= EINBRUCH
+return np.clip(a, 0.0, 1.0)
+
+
+def sicherheit(x, a):
+"""Anteil der Szenarien, in denen der Plan den Bedarf deckt."""
+returnfloat(np.mean(a @ x >= BEDARF))
+
+
+KOPF = (f"{'Plan':<20}{'Kosten (EUR)':>13}{'Gas':>4}{'Wind':>4} "
+f"{'Sol':>4}{'Bio':>4}{'Normalwelt':>11}{'echte Welt':>11}")
+
+
+def zeile(name, kosten, x, in_normal, in_echt):
+ mix =" ".join(f"{w:4.0f}"for w in x)
+return (f"{name:<20}{kosten:>13,.0f}{mix} "
+f"{in_normal *100:>9.2f} % {in_echt *100:>9.2f} %")
+
+
+if__name__=="__main__":
+print("="*78)
+print(" WAHRSCHEINLICHKEITSBESCHRAENKUNGEN IM KRAFTWERKSPARK")
+print("="*78)
+print(f"Gesichert bereitzustellen: {BEDARF:.0f} MW\n")
+print(f"{'Technologie':<14}{'EUR/MW':>8}{'verfuegbar':>11}{'Streuung':>9} "
+f"{'Grenze':>8}{'EUR je erw. MW':>15}")
+print("-"*78)
+for i, name inenumerate(TECHNIK):
+print(f"{name:<14}{KOSTEN[i]:>8,.0f}{VERFUEGBAR[i]:>10.2f} "
+f"{STREUUNG[i]:>8.2f}{GRENZE[i]:>7.0f} "
+f"{KOSTEN[i] / VERFUEGBAR[i]:>15,.0f}")
+print(f"\nVollausbau liefert im Mittel {VERFUEGBAR @ GRENZE:.0f} MW, "
+f"in der Kaeltewelle {(VERFUEGBAR * EINBRUCH) @ GRENZE:.0f} MW.")
+
+# Zwei grosse, unabhaengige Testmengen. An ihnen wird JEDER Plan gemessen -
+# einmal in der unterstellten Normalwelt, einmal in der echten Verteilung.
+ echt = ziehe_wetter(200_000, seed=771)
+ normalwelt = np.random.default_rng(4711).multivariate_normal(
+ VERFUEGBAR, SIGMA, size=200_000)
+
+print("\n"+"="*78)
+print(" (1) Mittelwertplan und Chance Constraints im Vergleich")
+print("="*78)
+print(KOPF)
+print("-"*78)
+
+ k_mittel, x_mittel = mittelwertplan()
+ stufen = [("Mittelwert", k_mittel, sicherheit(x_mittel, normalwelt))]
+print(zeile("Mittelwert", k_mittel, x_mittel,
+ sicherheit(x_mittel, normalwelt), sicherheit(x_mittel, echt)))
+
+ plaene = {}
+for alpha in (0.20, 0.10, 0.05, 0.01):
+ kosten, x = chance_constraint_analytisch(alpha)
+ plaene[alpha] = (kosten, x)
+ quote_normal = sicherheit(x, normalwelt)
+ stufen.append((f"Zusage {(1- alpha) *100:.0f} %", kosten, quote_normal))
+print(zeile(f"Zusage {(1- alpha) *100:.0f} %", kosten, x,
+ quote_normal, sicherheit(x, echt)))
+
+print("\n In der Normalwelt trifft jede Zusage ihren Wert - das Verfahren")
+print(" rechnet richtig. Die Spalte 'echte Welt' kommt in Teil (3).")
+
+print("\n"+"="*78)
+print(" (2) Was kostet ein Prozentpunkt Versorgungssicherheit?")
+print("="*78)
+print(f"{'von -> nach':<26}{'Prozentpunkte':>13}{'Mehrkosten':>13} "
+f"{'EUR je Punkt':>13}")
+print("-"*78)
+for (n1, k1, q1), (n2, k2, q2) inzip(stufen, stufen[1:]):
+ d_punkte = (q2 - q1) *100
+ d_kosten = k2 - k1
+print(f"{n1 +' -> '+ n2:<26}{d_punkte:>13.2f}{d_kosten:>13,.0f} "
+f"{d_kosten / d_punkte:>13,.0f}")
+ erst = (stufen[1][1] - stufen[0][1]) / ((stufen[1][2] - stufen[0][2]) *100)
+ letzt = (stufen[-1][1] - stufen[-2][1]) / ((stufen[-1][2] - stufen[-2][2]) *100)
+print(f"\n Der letzte Prozentpunkt kostet das {letzt / erst:.1f}-fache des ersten.")
+print(" Sicherheit ist konvex bepreist - genau deshalb muss jemand")
+print(" entscheiden, wie viel davon das Unternehmen kaufen will.")
+
+print("\n"+"="*78)
+print(" (3) Und wenn die Verteilung nicht normal ist?")
+print("="*78)
+ k_soc, x_soc = plaene[0.05]
+print(f"Die echte Welt kennt die Kaeltewelle mit Dunkelflaute: in "
+f"{P_KAELTEWELLE *100:.0f} % der Faelle liefern")
+print(f"Wind {EINBRUCH[1] *100:.0f} %, Sonne {EINBRUCH[2] *100:.0f} % "
+f"und - entscheidend - auch das Gaskraftwerk nur "
+f"{EINBRUCH[0] *100:.0f} %")
+print("des Ueblichen. Alles bricht gleichzeitig ein.\n")
+
+ a_bau = ziehe_wetter(400, seed=20260908)
+ k_sz, x_sz = chance_constraint_szenarien(a_bau, 0.05)
+
+print(KOPF)
+print("-"*78)
+print(zeile("SOC, Zusage 95 %", k_soc, x_soc,
+ sicherheit(x_soc, normalwelt), sicherheit(x_soc, echt)))
+print(zeile("Szenarien, 95 %", k_sz, x_sz,
+ sicherheit(x_sz, normalwelt), sicherheit(x_sz, echt)))
+
+print(f"\n Der SOC-Plan verspricht 95 % und haelt "
+f"{sicherheit(x_soc, echt) *100:.1f} %. Nicht das Verfahren ist")
+print(f" falsch, sondern die Annahme: In der Normalwelt liefert derselbe")
+print(f" Plan {sicherheit(x_soc, normalwelt) *100:.1f} %.")
+print(f"\n Der Szenarioplan ({len(a_bau)} Szenarien, {len(a_bau)} "
+f"Binaervariablen) kommt auf")
+print(f" {sicherheit(x_sz, echt) *100:.1f} % - und kostet dafuer "
+f"{(k_sz / k_soc -1) *100:+.1f} %. Er kauft keine Windkraft mehr:")
+print(" Was im Ernstfall ausfaellt, hilft der Zusage nicht.")
+print("="*78)
+
Ausgabe:
+
==============================================================================
+ WAHRSCHEINLICHKEITSBESCHRAENKUNGEN IM KRAFTWERKSPARK
+==============================================================================
+Gesichert bereitzustellen: 500 MW
+
+Technologie EUR/MW verfuegbar Streuung Grenze EUR je erw. MW
+------------------------------------------------------------------------------
+Gaskraftwerk 65,000 0.92 0.05 400 70,652
+Windpark 24,000 0.35 0.16 250 68,571
+Solarpark 12,000 0.18 0.10 600 66,667
+Biomasse 60,000 0.85 0.04 350 70,588
+
+Vollausbau liefert im Mittel 861 MW, in der Kaeltewelle 552 MW.
+
+==============================================================================
+ (1) Mittelwertplan und Chance Constraints im Vergleich
+==============================================================================
+Plan Kosten (EUR) Gas Wind Sol Bio Normalwelt echte Welt
+------------------------------------------------------------------------------
+Mittelwert 34,694,565 8 250 600 350 50.08 % 46.18 %
+Zusage 80 % 36,382,277 226 25 44 343 80.06 % 73.61 %
+Zusage 90 % 36,994,777 233 20 35 349 89.99 % 82.82 %
+Zusage 95 % 37,514,307 241 19 31 350 94.97 % 87.44 %
+Zusage 99 % 38,529,698 258 18 28 350 98.97 % 91.05 %
+
+ In der Normalwelt trifft jede Zusage ihren Wert - das Verfahren
+ rechnet richtig. Die Spalte 'echte Welt' kommt in Teil (3).
+
+==============================================================================
+ (2) Was kostet ein Prozentpunkt Versorgungssicherheit?
+==============================================================================
+von -> nach Prozentpunkte Mehrkosten EUR je Punkt
+------------------------------------------------------------------------------
+Mittelwert -> Zusage 80 % 29.98 1,687,712 56,289
+Zusage 80 % -> Zusage 90 % 9.93 612,500 61,685
+Zusage 90 % -> Zusage 95 % 4.98 519,530 104,365
+Zusage 95 % -> Zusage 99 % 4.00 1,015,390 253,848
+
+ Der letzte Prozentpunkt kostet das 4.5-fache des ersten.
+ Sicherheit ist konvex bepreist - genau deshalb muss jemand
+ entscheiden, wie viel davon das Unternehmen kaufen will.
+
+==============================================================================
+ (3) Und wenn die Verteilung nicht normal ist?
+==============================================================================
+Die echte Welt kennt die Kaeltewelle mit Dunkelflaute: in 8 % der Faelle liefern
+Wind 10 %, Sonne 15 % und - entscheidend - auch das Gaskraftwerk nur 72 %
+des Ueblichen. Alles bricht gleichzeitig ein.
+
+Plan Kosten (EUR) Gas Wind Sol Bio Normalwelt echte Welt
+------------------------------------------------------------------------------
+SOC, Zusage 95 % 37,514,307 241 19 31 350 94.97 % 87.44 %
+Szenarien, 95 % 43,760,709 350 0 0 350 100.00 % 94.84 %
+
+ Der SOC-Plan verspricht 95 % und haelt 87.4 %. Nicht das Verfahren ist
+ falsch, sondern die Annahme: In der Normalwelt liefert derselbe
+ Plan 95.0 %.
+
+ Der Szenarioplan (400 Szenarien, 400 Binaervariablen) kommt auf
+ 94.8 % - und kostet dafuer +16.7 %. Er kauft keine Windkraft mehr:
+ Was im Ernstfall ausfaellt, hilft der Zusage nicht.
+==============================================================================
+
Drei Befunde, die man dem Plan nicht ansieht.
+
Erstens: Der Mittelwertplan fällt in der Hälfte aller Fälle aus. Das ist kein Fehler, sondern die Definition des Erwartungswerts — und dieselbe Falle wie in Abschnitt 12.3, nur teurer. Er kauft die billigsten Quellen je erwarteter Megawattstunde und maximiert Wind und Sonne bis an die Ausbaugrenze. Sobald die Zusage im Modell steht, dreht sich das Bild: Gas und Biomasse tragen den Plan, Wind und Sonne schrumpfen auf einen Rest.
+
Zweitens: Sicherheit ist konvex bepreist. Die ersten dreißig Prozentpunkte kosten rund 56 000 € pro Punkt, die letzten vier rund 254 000 € — das 4,5-fache. Das ist die Zahl, die in die Vorlage gehört: Nicht „was kostet Versorgungssicherheit“, sondern „was kostet der nächste Prozentpunkt“. Wer 99,9 % fordert, ohne diese Kurve gesehen zu haben, fordert ins Blaue.
+
Drittens — und das ist der unangenehme Befund: Die Zusage gilt nur für die unterstellte Verteilung. Der Plan verspricht 95 % und hält 87,4 %. Nicht weil das Verfahren falsch rechnet: In einer reinen Normalwelt liefert derselbe Plan exakt seine 95,0 %. Sondern weil die Normalverteilung eine Kältewelle mit Dunkelflaute nicht kennt. In der bricht alles zugleich ein — auch das Gaskraftwerk, weil bei Frost der Netzdruck fällt. Eine Kovarianzmatrix mit Korrelationen um null kann so etwas nicht ausdrücken.
+
Der szenariobasierte Weg kennt diese Fälle, weil sie in den Daten stehen. Er kommt auf 94,8 %, kostet dafür 16,7 % mehr — und kauft keine Windkraft mehr. Die Logik dahinter ist hart und richtig: Was im Ernstfall ausfällt, hilft der Zusage nicht.
+
+
⚠️ Die Szenariomethode überanpasst — und mehr Szenarien helfen nicht verlässlich Bei S Szenarien und \alpha = 5\,\% darf das Modell genau 0{,}05 \cdot S davon ignorieren — und es sucht sich die teuersten aus. In der Stichprobe trifft es seine Quote dadurch immer; außerhalb streut sie.
+
Gemessen an zwölf Läufen dieses Modells (je vier Ziehungen bei S = 200, 400, 800, geprüft an 200 000 unabhängigen Szenarien): Die tatsächliche Quote lag zwischen 93,1 % und 96,7 %. Die Spanne wurde von S = 200 auf 400 enger (3,5 → 1,2 Prozentpunkte), bei S = 800 aber wieder breiter (3,1) — die Zahl der Szenarien allein schließt die Lücke also nicht.
+
Konsequenz für die Praxis: Eine mit Szenarien erkaufte Zusage immer an einer zurückgehaltenen Stichprobe nachmessen, so wie es das Programm oben tut. Wer nur die Trefferquote in den Baudaten berichtet, berichtet eine Tautologie — dieselbe Falle wie beim Backtest in Kapitel 21.
+
+
+
🎯 Merksatz Die robuste Optimierung fragt „was, wenn alles schiefgeht?“, die Chance Constraint „wie oft darf es schiefgehen?“. Nur die zweite Frage hat eine Antwort, über die man verhandeln kann — und nur sie zwingt dazu, den Preis eines Prozentpunkts Sicherheit zu beziffern.
Aufgabe 12.1 ⭐ — Fluch des Durchschnitts erkennen. Ein Bauunternehmer plant mit der durchschnittlichen Bauzeit von 8 Monaten. Warum ist die erwartete Fertigstellung trotzdem später als 8 Monate, wenn Verzögerungen wahrscheinlicher sind als Beschleunigungen? Nennen Sie zwei weitere Alltagsbeispiele.
-
Aufgabe 12.2 ⭐ — Ansatz wählen. Welcher der drei Ansätze passt? Begründen Sie: (a) Wie viele Notstromaggregate für ein Krankenhaus? (b) Wie viel Weizen einkaufen bei bekannter Preisverteilung? (c) Wie hoch ein Deich gebaut werden muss? (d) Wie viele Saisonkräfte einstellen bei bekannten Nachfrageszenarien?
+
Aufgabe 12.2 ⭐ — Ansatz wählen. Welcher der vier Ansätze passt? Begründen Sie: (a) Wie viele Notstromaggregate für ein Krankenhaus? (b) Wie viel Weizen einkaufen bei bekannter Preisverteilung? (c) Wie hoch ein Deich gebaut werden muss? (d) Wie viele Saisonkräfte einstellen bei bekannten Nachfrageszenarien? (e) Wie viel Speicherkapazität, damit ein Gasnetz an 99 % aller Wintertage hält?
Aufgabe 12.3 ⭐⭐ — Zweistufiges Modell rechnen. Wiederholen Sie die Handrechnung Warum der Mittelwert in die Irre führt mit geänderten Kosten: Spot 60 € statt 120 €. (a) Wie ändert sich die optimale Kapazität? (b) Erklären Sie die Richtung der Änderung. (c) Bei welchem Spot-Preis wäre die Mittelwert-Planung optimal?
Aufgabe 12.4 ⭐⭐ — EVPI interpretieren. Ihr Modell liefert EVPI = 8 400 € pro Jahr. Ein Anbieter verlangt 15 000 € jährlich für eine Prognoselösung, die „80 % Treffsicherheit“ verspricht. Wie argumentieren Sie?
Aufgabe 12.5 ⭐⭐⭐ — Monte-Carlo erweitern. Erweitern Sie Monte_Carlo.py: (a) Fügen Sie ein Servicelevel-Kriterium hinzu: „Die Unterdeckung darf höchstens in 5 % der Fälle auftreten.“ Welche Kapazität ist dafür nötig, und was kostet sie zusätzlich? (b) Berechnen Sie den CVaR der Kosten (Mittelwert der schlechtesten 5 %). (c) Zeichnen Sie ein Histogramm der Kosten für drei Kapazitäten.
-
Aufgabe 12.6 ⭐⭐⭐ — Budgeted Uncertainty. Erweitern Sie Robuste_Optimierung.py um den Ansatz von Bertsimas/Sim: Höchstens \Gamma Titel nehmen gleichzeitig ihren Worst Case an. Variieren Sie \Gamma \in \{0, 1, 2, 3, 4\} und stellen Sie den Verlauf von Ertrag und Absicherung dar. (Hinweis: Bei \Gamma ganzzahlig genügt es, die \Gamma größten \delta_i w_i abzuziehen — das lässt sich in CVXPY mit cp.sum_largest formulieren.)
+
Aufgabe 12.6 ⭐⭐ — Den Preis der Zusage selbst bestimmen.Chance_Constraints.py beziffert die Kosten für 80, 90, 95 und 99 %. (a) Ergänzen Sie die Stufen 99,5 % und 99,9 % und setzen Sie die Kosten je Prozentpunkt fort. Was passiert mit dem Anlagenmix? (b) Drosseln Sie die Ausbaugrenze der Biomasse von 350 auf 200 MW. Das szenariobasierte Modell meldet dann bei 95 % infeasible, das analytische dagegen optimal. Erklären Sie den Unterschied. Welchem der beiden würden Sie glauben? (c) Ein Vorstand fordert „99,99 % Versorgungssicherheit“. Formulieren Sie in drei Sätzen, was Sie ihm mit dieser Kurve antworten.
+
Aufgabe 12.7 ⭐⭐⭐ — Budgeted Uncertainty. Erweitern Sie Robuste_Optimierung.py um den Ansatz von Bertsimas/Sim: Höchstens \Gamma Titel nehmen gleichzeitig ihren Worst Case an. Variieren Sie \Gamma \in \{0, 1, 2, 3, 4\} und stellen Sie den Verlauf von Ertrag und Absicherung dar. (Hinweis: Bei \Gamma ganzzahlig genügt es, die \Gamma größten \delta_i w_i abzuziehen — das lässt sich in CVXPY mit cp.sum_largest formulieren.)
-
12.8 Finde den Denkfehler
+
12.9 Finde den Denkfehler
🐛 Finde den Denkfehler 9.1: Warum jedes Projekt zu spät fertig wird
Ein Projektleiter plant eine Umbaumaßnahme. Fünf Gewerke arbeiten parallel und unabhängig voneinander; fertig ist der Umbau, wenn das letzte fertig ist. Für jedes Gewerk liegt eine Schätzung vor: mindestens 6 Tage, wahrscheinlich 10, im schlechtesten Fall 18.
Er rechnet: „Jedes Gewerk braucht im Mittel (6 + 10 + 18)/3 = 11{,}3 Tage. Alle laufen gleichzeitig. Also ist der Umbau nach 11,3 Tagen fertig — ich plane großzügig 12 ein.“
Nachgerechnet mit 200 000 simulierten Umbauten:
-
import numpy as np
-
-rng = np.random.default_rng(3)
-dauer = rng.triangular(6, 10, 18, size=(200_000, 5)) # 5 Gewerke, je eine Schaetzung
-projekt = dauer.max(axis=1) # fertig, wenn das LETZTE fertig ist
-
-print(f"Mittelwert einer Einzeldauer: {dauer.mean():.2f} Tage")
-print(f"Tatsaechliche Projektdauer: {projekt.mean():.2f} Tage")
-print(f"90-%-Quantil: {np.quantile(projekt, 0.9):.2f} Tage")
-print(f"Anteil ueber 11,34 Tage: {(projekt >11.34).mean() *100:.1f} %")
+
import numpy as np
+
+rng = np.random.default_rng(3)
+dauer = rng.triangular(6, 10, 18, size=(200_000, 5)) # 5 Gewerke, je eine Schaetzung
+projekt = dauer.max(axis=1) # fertig, wenn das LETZTE fertig ist
+
+print(f"Mittelwert einer Einzeldauer: {dauer.mean():.2f} Tage")
+print(f"Tatsaechliche Projektdauer: {projekt.mean():.2f} Tage")
+print(f"90-%-Quantil: {np.quantile(projekt, 0.9):.2f} Tage")
+print(f"Anteil ueber 11,34 Tage: {(projekt >11.34).mean() *100:.1f} %")
Ausgabe:
Mittelwert einer Einzeldauer: 11.34 Tage
Tatsaechliche Projektdauer: 14.38 Tage
@@ -17138,7 +17501,7 @@ Anteil ueber 11,34 Tage: 95.5 %
🎯 Merksatz Der Fluch des Durchschnitts hat zwei Gesichter. Beim Ersatzteil aus Abschnitt 12.1 führt der Mittelwert in die Irre, weil die Kosten asymmetrisch sind. Hier führt er in die Irre, weil die Verknüpfung asymmetrisch ist: Ein Gewerk, das früher fertig wird, hilft niemandem — ein Gewerk, das sich verspätet, verzögert alles. Beide Male gilt: Der Mittelwert einer Funktion ist nicht die Funktion des Mittelwerts.
-
12.9 Micro-Quiz
+
12.10 Micro-Quiz
❓ Micro-Quiz 9: Drei Fragen zum Selbstcheck
Genau eine Antwort ist jeweils richtig. Auflösung in Anhang A.
3. Ein Kollege ersetzt in seinem Modell alle unsicheren Größen durch ihre Mittelwerte und rechnet deterministisch. Wann ist das unproblematisch? (a) Immer — der Erwartungswert ist die beste Einzelschätzung. (b) Nie. (c) Wenn Zielfunktion und Nebenbedingungen in den unsicheren Größen linear sind und keine Entscheidung erst nach Beobachtung fällt. Sobald Minimum, Maximum, Betrag oder eine Nachbesserungsentscheidung im Spiel ist, gilt es nicht mehr.
Was misst der EVPI, und wofür ist er praktisch nützlich?
Warum braucht robuste Optimierung keine Wahrscheinlichkeiten?
Wie macht man ein Min-Max-Problem mit Box-Unsicherheit lösbar?
+
Warum wird aus \mathbb{P}(\mathbf{a}^\top\mathbf{x} \ge b) \ge 1-\alpha unter Normalverteilung eine Norm und nicht ein fester Zuschlag je Variable?
+
Ein Plan mit Chance Constraint verspricht 95 % und hält gemessen 87 %. Woran liegt das — und was ändert die szenariobasierte Formulierung daran?
-
12.11 Zusammenfassung
+
12.12 Zusammenfassung
Der Fluch des Durchschnitts ist kein Randphänomen und hat zwei Gesichter. Bei asymmetrischen Kosten liegt das Optimum systematisch neben dem Mittelwert — das Newsvendor-Verhältnis c_{-}/(c_{-}+c_{+}) sagt, wie weit. Bei asymmetrischer Verknüpfung (Maximum über parallele Vorgänge) ist der Erwartungswert des Ganzen größer als das Ganze der Erwartungswerte. Beide Male gilt: Der Mittelwert einer Funktion ist nicht die Funktion des Mittelwerts (Jensensche Ungleichung).
Eine Terminzusage ist ein Quantil, kein Mittelwert. Wer den Erwartungswert zusagt, reißt den Termin bei fünf parallelen Vorgängen in 95 % der Fälle.
Zweistufige stochastische Programme trennen die Festlegung von der Reaktion — ein \mathbf{x}, aber je Szenario ein \mathbf{y}_s.
Der EVPI begrenzt, was eine perfekte Prognose wert sein darf.
Robuste Optimierung braucht keine Wahrscheinlichkeiten, nur eine Unsicherheitsmenge — und wird für einfache Mengen zu einem gewöhnlichen Problem mit Abzugsterm.
+
Chance Constraints schreiben die Zusage selbst ins Modell. Unter Normalverteilung wird daraus eine Kegelbedingung (der Sicherheitszuschlag ist eine Norm, deshalb belohnt sie Mischung), szenariobasiert ein MILP mit Big-M ohne jede Verteilungsannahme.
+
Sicherheit ist konvex bepreist. Im Beispiel kostet der letzte Prozentpunkt das 4,5-fache des ersten. Die richtige Managementfrage lautet nicht „was kostet Sicherheit“, sondern „was kostet der nächste Prozentpunkt“.
+
Eine Zusage gilt nur für die unterstellte Verteilung. Ein Plan, der unter Normalverteilung 95 % verspricht, hielt gemessen 87 %, weil das Modell die Kältewelle nicht kannte, in der alles zugleich ausfällt. Szenariobasierte Zusagen wiederum überanpassen — beide müssen an zurückgehaltenen Daten nachgemessen werden.
Beziffern Sie immer Preis und Nutzen der Absicherung. Ohne beide Zahlen ist die Entscheidung nicht begründbar.
Ausblick.Kapitel 13 fügt die Zeitdimension hinzu: Entscheidungen, die über viele Perioden aufeinander aufbauen — gelöst mit der Bellman-Gleichung.
Ergebnis:V_0(3) = 5, erreicht durch n_0 = 1 (dann n_1 = 2) odern_0 = 2 (dann n_1 = 1). Zwei gleichwertige Optima.
Die Lehre: Alles auf einmal zu verkaufen kostet 9, gleichmäßiges Aufteilen nur 5. Das ist der Kern des Ausführungsproblems: Bei überproportionalen Kosten lohnt sich Stückelung. Und beachten Sie, dass wir nur 4 + 4 = 8 Kombinationen bewertet haben statt aller Verkaufspfade — bei größeren Problemen ist dieser Unterschied dramatisch.
-
#!/usr/bin/env python3
-
-# Bellman_Minimalbeispiel.py
-"""
-Kapitel Dynamische Programmierung: Die Handrechnung zur Rueckwaertsinduktion als Code.
-Zeigt die Wertfunktionstabelle und die optimale Politik Schritt fuer Schritt.
-"""
-
-import numpy as np
-
-GESAMT =3# zu verkaufende Einheiten
-PERIODEN =2# Anzahl Verkaufsperioden
-
-
-def kosten(menge: int) ->float:
-"""Ueberproportionale Marktauswirkung: doppelte Menge kostet vierfach."""
-returnfloat(menge **2)
-
-
-def loese_rueckwaerts():
-# V[t, x] = minimale Restkosten, wenn zu Beginn von Periode t noch x Stueck offen sind
- V = np.full((PERIODEN +1, GESAMT +1), np.inf)
- politik = np.zeros((PERIODEN, GESAMT +1), dtype=int)
-
-# Endbedingung: nach der letzten Periode darf nichts mehr offen sein
- V[PERIODEN, 0] =0.0
-
-print("="*70)
-print(" RUECKWAERTSINDUKTION SCHRITT FUER SCHRITT")
-print("="*70)
-
-for t inrange(PERIODEN -1, -1, -1):
- letzte_periode = (t == PERIODEN -1)
-print(f"\nStufe t = {t}"+ (" (letzte Periode: alles muss weg)"if letzte_periode
-else" (freie Wahl der Menge)"))
-print(f" {'Zustand x':>10} | {'beste Aktion':>12} | {'Sofortkosten':>13} | "
-f"{'V[t+1]':>9} | {'V[t]':>8}")
-print(" "+"-"*62)
-
-for x inrange(GESAMT +1):
- aktionen = [x] if letzte_periode elserange(x +1)
- bester_wert, beste_aktion, beste_teile = np.inf, 0, (0.0, 0.0)
-
-for n in aktionen:
- rest = x - n
- sofort = kosten(n)
- zukunft = V[t +1, rest]
- gesamt = sofort + zukunft
-if gesamt < bester_wert:
- bester_wert, beste_aktion = gesamt, n
- beste_teile = (sofort, zukunft)
-
- V[t, x] = bester_wert
- politik[t, x] = beste_aktion
-print(f" {x:>10} | {beste_aktion:>12} | {beste_teile[0]:>13.1f} | "
-f"{beste_teile[1]:>9.1f} | {bester_wert:>8.1f}")
-
-return V, politik
-
-
-if__name__=="__main__":
- V, politik = loese_rueckwaerts()
-
-# --- Vorwaertspfad: der optimalen Politik folgen ---------------------
-print("\n"+"="*70)
-print(" OPTIMALER PFAD (Vorwaertssimulation)")
-print("="*70)
- bestand = GESAMT
- gesamtkosten =0.0
-for t inrange(PERIODEN):
- aktion = politik[t, bestand]
- gesamtkosten += kosten(aktion)
-print(f" Periode {t}: Bestand {bestand} -> verkaufe {aktion} "
-f"(Kosten {kosten(aktion):.1f}) -> Rest {bestand - aktion}")
- bestand -= aktion
-
-print(f"\n Gesamtkosten: {gesamtkosten:.1f} (V[0, {GESAMT}] = {V[0, GESAMT]:.1f})")
-assertabs(gesamtkosten - V[0, GESAMT]) <1e-9, "Pfadkosten != Wertfunktion!"
-
-# --- Vergleich mit naiven Strategien ---------------------------------
- alles_sofort = kosten(GESAMT)
-print(f"\n Zum Vergleich - alles in Periode 0 verkaufen: {alles_sofort:.1f}")
-print(f" Ersparnis durch Stueckelung: {alles_sofort - gesamtkosten:.1f} "
-f"({(1- gesamtkosten/alles_sofort)*100:.0f} %)")
-print("="*70)
+
#!/usr/bin/env python3
+
+# Bellman_Minimalbeispiel.py
+"""
+Kapitel Dynamische Programmierung: Die Handrechnung zur Rueckwaertsinduktion als Code.
+Zeigt die Wertfunktionstabelle und die optimale Politik Schritt fuer Schritt.
+"""
+
+import numpy as np
+
+GESAMT =3# zu verkaufende Einheiten
+PERIODEN =2# Anzahl Verkaufsperioden
+
+
+def kosten(menge: int) ->float:
+"""Ueberproportionale Marktauswirkung: doppelte Menge kostet vierfach."""
+returnfloat(menge **2)
+
+
+def loese_rueckwaerts():
+# V[t, x] = minimale Restkosten, wenn zu Beginn von Periode t noch x Stueck offen sind
+ V = np.full((PERIODEN +1, GESAMT +1), np.inf)
+ politik = np.zeros((PERIODEN, GESAMT +1), dtype=int)
+
+# Endbedingung: nach der letzten Periode darf nichts mehr offen sein
+ V[PERIODEN, 0] =0.0
+
+print("="*70)
+print(" RUECKWAERTSINDUKTION SCHRITT FUER SCHRITT")
+print("="*70)
+
+for t inrange(PERIODEN -1, -1, -1):
+ letzte_periode = (t == PERIODEN -1)
+print(f"\nStufe t = {t}"+ (" (letzte Periode: alles muss weg)"if letzte_periode
+else" (freie Wahl der Menge)"))
+print(f" {'Zustand x':>10} | {'beste Aktion':>12} | {'Sofortkosten':>13} | "
+f"{'V[t+1]':>9} | {'V[t]':>8}")
+print(" "+"-"*62)
+
+for x inrange(GESAMT +1):
+ aktionen = [x] if letzte_periode elserange(x +1)
+ bester_wert, beste_aktion, beste_teile = np.inf, 0, (0.0, 0.0)
+
+for n in aktionen:
+ rest = x - n
+ sofort = kosten(n)
+ zukunft = V[t +1, rest]
+ gesamt = sofort + zukunft
+if gesamt < bester_wert:
+ bester_wert, beste_aktion = gesamt, n
+ beste_teile = (sofort, zukunft)
+
+ V[t, x] = bester_wert
+ politik[t, x] = beste_aktion
+print(f" {x:>10} | {beste_aktion:>12} | {beste_teile[0]:>13.1f} | "
+f"{beste_teile[1]:>9.1f} | {bester_wert:>8.1f}")
+
+return V, politik
+
+
+if__name__=="__main__":
+ V, politik = loese_rueckwaerts()
+
+# --- Vorwaertspfad: der optimalen Politik folgen ---------------------
+print("\n"+"="*70)
+print(" OPTIMALER PFAD (Vorwaertssimulation)")
+print("="*70)
+ bestand = GESAMT
+ gesamtkosten =0.0
+for t inrange(PERIODEN):
+ aktion = politik[t, bestand]
+ gesamtkosten += kosten(aktion)
+print(f" Periode {t}: Bestand {bestand} -> verkaufe {aktion} "
+f"(Kosten {kosten(aktion):.1f}) -> Rest {bestand - aktion}")
+ bestand -= aktion
+
+print(f"\n Gesamtkosten: {gesamtkosten:.1f} (V[0, {GESAMT}] = {V[0, GESAMT]:.1f})")
+assertabs(gesamtkosten - V[0, GESAMT]) <1e-9, "Pfadkosten != Wertfunktion!"
+
+# --- Vergleich mit naiven Strategien ---------------------------------
+ alles_sofort = kosten(GESAMT)
+print(f"\n Zum Vergleich - alles in Periode 0 verkaufen: {alles_sofort:.1f}")
+print(f" Ersparnis durch Stueckelung: {alles_sofort - gesamtkosten:.1f} "
+f"({(1- gesamtkosten/alles_sofort)*100:.0f} %)")
+print("="*70)
13.4 Das Almgren-Chriss-Problem: optimale Orderausführung
Ein klassisches OR-Problem an Börsen: Ein institutioneller Händler muss X_0 = 100\,000 Aktien innerhalb von T Handelsperioden verkaufen.
Woher eine solche Zahl kommt, bleibt dann unklar. Faktisch wirkt sie als versteckte Erhöhung der Risikoaversion um fünf Größenordnungen — ein Parameter wie risk_aversion = 1e-6 würde dann in Wahrheit 0{,}1 bedeuten. Solche „magischen Zahlen“ machen ein Modell unkalibrierbar: Niemand kann sagen, ob 1e-6 viel oder wenig ist.
Der Parameter lässt sich stattdessen herleiten. Beide Kostenterme werden in Euro gerechnet: * Marktauswirkung: \eta \cdot n_t^2 mit \eta in €/Stück². * Risiko: \tfrac{\lambda}{2}\cdot \sigma_{\text{Periode}}^2 \cdot P_0^2 \cdot X_t^2 — die Varianz des Werts der offenen Position, multipliziert mit der Risikoaversion.
Durch die Multiplikation mit P_0^2 (dem Quadrat des Aktienkurses) stimmen die Einheiten, und \lambda wird interpretierbar: Es ist der Preis, den man je Einheit Wertvarianz zu zahlen bereit ist.
-
#!/usr/bin/env python3
-
-# Mehrperiodige_Order_Execution.py
-"""
-Kapitel Dynamische Programmierung: Dynamische Programmierung fuer optimale Orderausfuehrung
-(Almgren-Chriss-Rahmen, geloest per Rueckwaertsinduktion).
-
-Eigenschaften:
- * Risikoterm sauber hergeleitet ueber den Aktienkurs P0 (Einheiten: EUR),
- ohne undokumentierte Skalierungsfaktoren
- * Vergleich mit der analytischen Almgren-Chriss-Loesung
- * Vergleich mit naiven Strategien (alles sofort / gleichmaessig)
- * Sensitivitaet gegenueber der Risikoaversion
-"""
-
-import os
-
-import numpy as np
-import pandas as pd
-import matplotlib
-matplotlib.use("Agg")
-import matplotlib.pyplot as plt
-
-OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
-os.makedirs(OUTPUT_DIR, exist_ok=True)
-
-# --- Parameter -------------------------------------------------------------
-GESAMTBESTAND =100_000# X_0, zu verkaufende Aktien
-PERIODEN =5# T Handelsperioden
-KURS =50.0# P_0 in EUR, zur Skalierung des Risikoterms
-ETA =2.5e-6# EUR je Stueck^2 (Slippage-Koeffizient)
-VOLA_JAHR =0.30# 30 % p.a.
-HANDELSSTUNDEN_JAHR =252*6.5
-RISIKOAVERSION =1e-5# 1/EUR; kalibriert, siehe Kommentar unten
-SCHRITTWEITE =1000# Diskretisierung des Zustandsraums
-
-# Kalibrierungshinweis: Die dimensionslose Kennzahl des Modells ist
-# kappa_tilde^2 = lambda * sigma_periode^2 * P0^2 / eta
-# Sie entscheidet ueber den Charakter der Loesung:
-# << 1 -> praktisch gleichmaessige Aufteilung (Risiko spielt keine Rolle)
-# ~ 1 -> ausgewogener Kompromiss <- hier: 0.55
-# >> 1 -> fast alles sofort verkaufen
-# Genau diese Interpretierbarkeit geht mit einem undokumentierten
-# Skalierungsfaktor verloren.
-
-VOLA_PERIODE = VOLA_JAHR / np.sqrt(HANDELSSTUNDEN_JAHR)
-
-
-def periodenkosten(verkauf: float, restbestand: float) ->float:
-"""
- Sofortkosten einer Periode in EUR:
- (1) Marktauswirkung: eta * n^2
- (2) Risiko des Restbestands: lambda/2 * sigma^2 * P0^2 * X^2
- -> P0^2 macht aus "Stueck^2" einen EUR^2-Wert; lambda hat damit
- die Einheit 1/EUR und ist interpretierbar.
- """
- marktauswirkung = ETA * verkauf **2
- wertvarianz = (VOLA_PERIODE **2) * (KURS **2) * (restbestand **2)
- risiko =0.5* RISIKOAVERSION * wertvarianz
-return marktauswirkung + risiko
-
-
-def loese_dp():
-"""Rueckwaertsinduktion ueber den diskretisierten Zustandsraum."""
- zustaende = np.arange(0, GESAMTBESTAND + SCHRITTWEITE, SCHRITTWEITE)
- anzahl =len(zustaende)
-
- V = np.full((PERIODEN +1, anzahl), np.inf)
- politik = np.zeros((PERIODEN, anzahl), dtype=int)
- V[PERIODEN, 0] =0.0# am Ende muss alles verkauft sein
-
-for t inrange(PERIODEN -1, -1, -1):
-for idx, bestand inenumerate(zustaende):
-if t == PERIODEN -1:
- moegliche = [bestand] # letzte Periode: Rest muss weg
-else:
- moegliche = zustaende[zustaende <= bestand]
-
- bester_wert, beste_aktion = np.inf, 0
-for verkauf in moegliche:
- rest = bestand - verkauf
- rest_idx =int(round(rest / SCHRITTWEITE))
- gesamt = periodenkosten(verkauf, rest) + V[t +1, rest_idx]
-if gesamt < bester_wert:
- bester_wert, beste_aktion = gesamt, int(verkauf)
-
- V[t, idx] = bester_wert
- politik[t, idx] = beste_aktion
-
-return zustaende, V, politik
-
-
-def analytische_loesung():
-"""
- Geschlossene Almgren-Chriss-Loesung fuer den kontinuierlichen Fall.
- Der optimale Pfad ist X_t = X_0 * sinh(kappa*(T-t)) / sinh(kappa*T)
- mit kappa = arccosh(tilde_kappa^2/2 + 1), tilde_kappa^2 = lambda*sigma^2*P0^2/eta.
- Dient hier als unabhaengige Kontrolle des DP-Ergebnisses.
- """
- kappa_tilde_quadrat = (RISIKOAVERSION * (VOLA_PERIODE **2) * (KURS **2)) / ETA
- kappa = np.arccosh(kappa_tilde_quadrat /2.0+1.0)
-if kappa <1e-12: # Grenzfall: risikoneutral -> linear
-return np.linspace(GESAMTBESTAND, 0, PERIODEN +1)
- t = np.arange(PERIODEN +1)
-return GESAMTBESTAND * np.sinh(kappa * (PERIODEN - t)) / np.sinh(kappa * PERIODEN)
-
-
-def bewerte_pfad(bestaende):
-"""Gesamtkosten eines beliebigen Bestandspfades."""
- summe =0.0
-for t inrange(len(bestaende) -1):
- verkauf = bestaende[t] - bestaende[t +1]
- summe += periodenkosten(verkauf, bestaende[t +1])
-return summe
-
-
-if__name__=="__main__":
- zustaende, V, politik = loese_dp()
-
-# --- Vorwaertspfad der optimalen Politik -----------------------------
- bestand = GESAMTBESTAND
- verlauf = [bestand]
- verkaeufe = []
-for t inrange(PERIODEN):
- idx =int(round(bestand / SCHRITTWEITE))
- verkauf = politik[t, idx]
- verkaeufe.append(verkauf)
- bestand -= verkauf
- verlauf.append(bestand)
-
-print("="*84)
-print(" OPTIMALE MEHRPERIODIGE ORDER-EXECUTION (BELLMAN DP)")
-print("="*84)
-print(f"Gesamtvolumen: {GESAMTBESTAND:,} Stueck zu {KURS:.2f} EUR "
-f"= {GESAMTBESTAND*KURS:,.0f} EUR Positionswert")
-print(f"Zeithorizont: {PERIODEN} Handelsperioden")
-print(f"Volatilitaet: {VOLA_JAHR*100:.0f} % p.a. "
-f"= {VOLA_PERIODE*100:.3f} % je Periode")
-print(f"Slippage eta: {ETA:.2e} EUR/Stueck^2")
-print(f"Risikoaversion: {RISIKOAVERSION:.2e} 1/EUR")
-print(f"Erwartete Gesamtreibung: {V[0, -1]:,.2f} EUR "
-f"({V[0, -1]/(GESAMTBESTAND*KURS)*10000:.1f} Basispunkte)\n")
-
- plan = pd.DataFrame([{
-"Periode": f"t = {t} -> {t+1}",
-"Startbestand": f"{verlauf[t]:,}",
-"Verkauf n_t": f"{verkaeufe[t]:,}",
-"Restbestand": f"{verlauf[t+1]:,}",
-"Anteil": f"{verkaeufe[t]/GESAMTBESTAND*100:5.1f} %",
-"Kosten (EUR)": f"{periodenkosten(verkaeufe[t], verlauf[t+1]):,.0f}",
- } for t inrange(PERIODEN)])
-print(plan.to_string(index=False))
-
-# --- Vergleich mit Alternativen und der analytischen Loesung ---------
- sofort = [GESAMTBESTAND] + [0] * PERIODEN
- gleichmaessig = [GESAMTBESTAND * (1- t / PERIODEN) for t inrange(PERIODEN +1)]
- analytisch = analytische_loesung()
-
-print("\n"+"-"*84)
-print(f"{'Strategie':<34}{'Kosten (EUR)':>15}{'Basispunkte':>13} "
-f"{'ggue. Optimum':>16}")
-print("-"*84)
- optimum = V[0, -1]
-for name, pfad in [("DP-Optimum", verlauf),
- ("Analytisch (Almgren-Chriss)", list(analytisch)),
- ("Gleichmaessig (TWAP)", gleichmaessig),
- ("Alles sofort", sofort)]:
- kosten = bewerte_pfad(pfad)
- bp = kosten / (GESAMTBESTAND * KURS) *10000
-print(f"{name:<34}{kosten:>15,.0f}{bp:>12.1f} "
-f"{kosten - optimum:>+15,.0f}")
-
-print("-"*84)
- abweichung =abs(bewerte_pfad(list(analytisch)) - optimum) / optimum
-print(f"Abweichung DP zur analytischen Loesung: {abweichung*100:.3f} % "
-f"(Diskretisierung: {SCHRITTWEITE} Stueck)")
-
-# --- Sensitivitaet gegenueber der Risikoaversion ---------------------
-print("\n--- Wie wirkt die Risikoaversion? ---")
-print(f"{'lambda':>10} | {'Verkauf in Periode 0':>22} | {'Charakter':<28}")
-print("-"*70)
-for lam in [1e-7, 1e-6, 1e-5, 1e-4, 1e-3]:
-globals()["RISIKOAVERSION"] = lam
- _, V_l, pol_l = loese_dp()
- erste = pol_l[0, -1]
- anteil = erste / GESAMTBESTAND *100
- charakter = ("nahezu gleichmaessig"if anteil <25else
-"front-loaded"if anteil <60else"fast alles sofort")
-print(f"{lam:>10.0e} | {erste:>13,} ({anteil:5.1f} %) | {charakter:<28}")
-globals()["RISIKOAVERSION"] =1e-5# zuruecksetzen
-
-# --- Diagramm ---------------------------------------------------------
- plt.figure(figsize=(10, 5.5))
- plt.plot(range(PERIODEN +1), verlauf, "o-", lw=2.5, label="DP-Optimum")
- plt.plot(range(PERIODEN +1), analytisch, "s--", lw=1.8, alpha=0.8,
- label="Analytisch (Almgren-Chriss)")
- plt.plot(range(PERIODEN +1), gleichmaessig, "^:", lw=1.8, alpha=0.8,
- label="Gleichmaessig (TWAP)")
- plt.bar(range(PERIODEN), verkaeufe, alpha=0.25, color="orange", width=0.45,
- label="Verkaufstranche $n_t$")
- plt.title("Optimaler Liquidationspfad ueber diskrete Perioden", fontsize=12)
- plt.xlabel("Handelsperiode $t$")
- plt.ylabel("Verbleibender Bestand $X_t$")
- plt.xticks(range(PERIODEN +1))
- plt.grid(True, linestyle=":", alpha=0.6)
- plt.legend()
- plt.tight_layout()
- ziel = os.path.join(OUTPUT_DIR, "optimal_execution_dp.png")
- plt.savefig(ziel, dpi=150)
-print(f"\nDiagramm gespeichert unter '{ziel}'")
-print("="*84)
+
#!/usr/bin/env python3
+
+# Mehrperiodige_Order_Execution.py
+"""
+Kapitel Dynamische Programmierung: Dynamische Programmierung fuer optimale Orderausfuehrung
+(Almgren-Chriss-Rahmen, geloest per Rueckwaertsinduktion).
+
+Eigenschaften:
+ * Risikoterm sauber hergeleitet ueber den Aktienkurs P0 (Einheiten: EUR),
+ ohne undokumentierte Skalierungsfaktoren
+ * Vergleich mit der analytischen Almgren-Chriss-Loesung
+ * Vergleich mit naiven Strategien (alles sofort / gleichmaessig)
+ * Sensitivitaet gegenueber der Risikoaversion
+"""
+
+import os
+
+import numpy as np
+import pandas as pd
+import matplotlib
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt
+
+OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
+os.makedirs(OUTPUT_DIR, exist_ok=True)
+
+# --- Parameter -------------------------------------------------------------
+GESAMTBESTAND =100_000# X_0, zu verkaufende Aktien
+PERIODEN =5# T Handelsperioden
+KURS =50.0# P_0 in EUR, zur Skalierung des Risikoterms
+ETA =2.5e-6# EUR je Stueck^2 (Slippage-Koeffizient)
+VOLA_JAHR =0.30# 30 % p.a.
+HANDELSSTUNDEN_JAHR =252*6.5
+RISIKOAVERSION =1e-5# 1/EUR; kalibriert, siehe Kommentar unten
+SCHRITTWEITE =1000# Diskretisierung des Zustandsraums
+
+# Kalibrierungshinweis: Die dimensionslose Kennzahl des Modells ist
+# kappa_tilde^2 = lambda * sigma_periode^2 * P0^2 / eta
+# Sie entscheidet ueber den Charakter der Loesung:
+# << 1 -> praktisch gleichmaessige Aufteilung (Risiko spielt keine Rolle)
+# ~ 1 -> ausgewogener Kompromiss <- hier: 0.55
+# >> 1 -> fast alles sofort verkaufen
+# Genau diese Interpretierbarkeit geht mit einem undokumentierten
+# Skalierungsfaktor verloren.
+
+VOLA_PERIODE = VOLA_JAHR / np.sqrt(HANDELSSTUNDEN_JAHR)
+
+
+def periodenkosten(verkauf: float, restbestand: float) ->float:
+"""
+ Sofortkosten einer Periode in EUR:
+ (1) Marktauswirkung: eta * n^2
+ (2) Risiko des Restbestands: lambda/2 * sigma^2 * P0^2 * X^2
+ -> P0^2 macht aus "Stueck^2" einen EUR^2-Wert; lambda hat damit
+ die Einheit 1/EUR und ist interpretierbar.
+ """
+ marktauswirkung = ETA * verkauf **2
+ wertvarianz = (VOLA_PERIODE **2) * (KURS **2) * (restbestand **2)
+ risiko =0.5* RISIKOAVERSION * wertvarianz
+return marktauswirkung + risiko
+
+
+def loese_dp():
+"""Rueckwaertsinduktion ueber den diskretisierten Zustandsraum."""
+ zustaende = np.arange(0, GESAMTBESTAND + SCHRITTWEITE, SCHRITTWEITE)
+ anzahl =len(zustaende)
+
+ V = np.full((PERIODEN +1, anzahl), np.inf)
+ politik = np.zeros((PERIODEN, anzahl), dtype=int)
+ V[PERIODEN, 0] =0.0# am Ende muss alles verkauft sein
+
+for t inrange(PERIODEN -1, -1, -1):
+for idx, bestand inenumerate(zustaende):
+if t == PERIODEN -1:
+ moegliche = [bestand] # letzte Periode: Rest muss weg
+else:
+ moegliche = zustaende[zustaende <= bestand]
+
+ bester_wert, beste_aktion = np.inf, 0
+for verkauf in moegliche:
+ rest = bestand - verkauf
+ rest_idx =int(round(rest / SCHRITTWEITE))
+ gesamt = periodenkosten(verkauf, rest) + V[t +1, rest_idx]
+if gesamt < bester_wert:
+ bester_wert, beste_aktion = gesamt, int(verkauf)
+
+ V[t, idx] = bester_wert
+ politik[t, idx] = beste_aktion
+
+return zustaende, V, politik
+
+
+def analytische_loesung():
+"""
+ Geschlossene Almgren-Chriss-Loesung fuer den kontinuierlichen Fall.
+ Der optimale Pfad ist X_t = X_0 * sinh(kappa*(T-t)) / sinh(kappa*T)
+ mit kappa = arccosh(tilde_kappa^2/2 + 1), tilde_kappa^2 = lambda*sigma^2*P0^2/eta.
+ Dient hier als unabhaengige Kontrolle des DP-Ergebnisses.
+ """
+ kappa_tilde_quadrat = (RISIKOAVERSION * (VOLA_PERIODE **2) * (KURS **2)) / ETA
+ kappa = np.arccosh(kappa_tilde_quadrat /2.0+1.0)
+if kappa <1e-12: # Grenzfall: risikoneutral -> linear
+return np.linspace(GESAMTBESTAND, 0, PERIODEN +1)
+ t = np.arange(PERIODEN +1)
+return GESAMTBESTAND * np.sinh(kappa * (PERIODEN - t)) / np.sinh(kappa * PERIODEN)
+
+
+def bewerte_pfad(bestaende):
+"""Gesamtkosten eines beliebigen Bestandspfades."""
+ summe =0.0
+for t inrange(len(bestaende) -1):
+ verkauf = bestaende[t] - bestaende[t +1]
+ summe += periodenkosten(verkauf, bestaende[t +1])
+return summe
+
+
+if__name__=="__main__":
+ zustaende, V, politik = loese_dp()
+
+# --- Vorwaertspfad der optimalen Politik -----------------------------
+ bestand = GESAMTBESTAND
+ verlauf = [bestand]
+ verkaeufe = []
+for t inrange(PERIODEN):
+ idx =int(round(bestand / SCHRITTWEITE))
+ verkauf = politik[t, idx]
+ verkaeufe.append(verkauf)
+ bestand -= verkauf
+ verlauf.append(bestand)
+
+print("="*84)
+print(" OPTIMALE MEHRPERIODIGE ORDER-EXECUTION (BELLMAN DP)")
+print("="*84)
+print(f"Gesamtvolumen: {GESAMTBESTAND:,} Stueck zu {KURS:.2f} EUR "
+f"= {GESAMTBESTAND*KURS:,.0f} EUR Positionswert")
+print(f"Zeithorizont: {PERIODEN} Handelsperioden")
+print(f"Volatilitaet: {VOLA_JAHR*100:.0f} % p.a. "
+f"= {VOLA_PERIODE*100:.3f} % je Periode")
+print(f"Slippage eta: {ETA:.2e} EUR/Stueck^2")
+print(f"Risikoaversion: {RISIKOAVERSION:.2e} 1/EUR")
+print(f"Erwartete Gesamtreibung: {V[0, -1]:,.2f} EUR "
+f"({V[0, -1]/(GESAMTBESTAND*KURS)*10000:.1f} Basispunkte)\n")
+
+ plan = pd.DataFrame([{
+"Periode": f"t = {t} -> {t+1}",
+"Startbestand": f"{verlauf[t]:,}",
+"Verkauf n_t": f"{verkaeufe[t]:,}",
+"Restbestand": f"{verlauf[t+1]:,}",
+"Anteil": f"{verkaeufe[t]/GESAMTBESTAND*100:5.1f} %",
+"Kosten (EUR)": f"{periodenkosten(verkaeufe[t], verlauf[t+1]):,.0f}",
+ } for t inrange(PERIODEN)])
+print(plan.to_string(index=False))
+
+# --- Vergleich mit Alternativen und der analytischen Loesung ---------
+ sofort = [GESAMTBESTAND] + [0] * PERIODEN
+ gleichmaessig = [GESAMTBESTAND * (1- t / PERIODEN) for t inrange(PERIODEN +1)]
+ analytisch = analytische_loesung()
+
+print("\n"+"-"*84)
+print(f"{'Strategie':<34}{'Kosten (EUR)':>15}{'Basispunkte':>13} "
+f"{'ggue. Optimum':>16}")
+print("-"*84)
+ optimum = V[0, -1]
+for name, pfad in [("DP-Optimum", verlauf),
+ ("Analytisch (Almgren-Chriss)", list(analytisch)),
+ ("Gleichmaessig (TWAP)", gleichmaessig),
+ ("Alles sofort", sofort)]:
+ kosten = bewerte_pfad(pfad)
+ bp = kosten / (GESAMTBESTAND * KURS) *10000
+print(f"{name:<34}{kosten:>15,.0f}{bp:>12.1f} "
+f"{kosten - optimum:>+15,.0f}")
+
+print("-"*84)
+ abweichung =abs(bewerte_pfad(list(analytisch)) - optimum) / optimum
+print(f"Abweichung DP zur analytischen Loesung: {abweichung*100:.3f} % "
+f"(Diskretisierung: {SCHRITTWEITE} Stueck)")
+
+# --- Sensitivitaet gegenueber der Risikoaversion ---------------------
+print("\n--- Wie wirkt die Risikoaversion? ---")
+print(f"{'lambda':>10} | {'Verkauf in Periode 0':>22} | {'Charakter':<28}")
+print("-"*70)
+for lam in [1e-7, 1e-6, 1e-5, 1e-4, 1e-3]:
+globals()["RISIKOAVERSION"] = lam
+ _, V_l, pol_l = loese_dp()
+ erste = pol_l[0, -1]
+ anteil = erste / GESAMTBESTAND *100
+ charakter = ("nahezu gleichmaessig"if anteil <25else
+"front-loaded"if anteil <60else"fast alles sofort")
+print(f"{lam:>10.0e} | {erste:>13,} ({anteil:5.1f} %) | {charakter:<28}")
+globals()["RISIKOAVERSION"] =1e-5# zuruecksetzen
+
+# --- Diagramm ---------------------------------------------------------
+ plt.figure(figsize=(10, 5.5))
+ plt.plot(range(PERIODEN +1), verlauf, "o-", lw=2.5, label="DP-Optimum")
+ plt.plot(range(PERIODEN +1), analytisch, "s--", lw=1.8, alpha=0.8,
+ label="Analytisch (Almgren-Chriss)")
+ plt.plot(range(PERIODEN +1), gleichmaessig, "^:", lw=1.8, alpha=0.8,
+ label="Gleichmaessig (TWAP)")
+ plt.bar(range(PERIODEN), verkaeufe, alpha=0.25, color="orange", width=0.45,
+ label="Verkaufstranche $n_t$")
+ plt.title("Optimaler Liquidationspfad ueber diskrete Perioden", fontsize=12)
+ plt.xlabel("Handelsperiode $t$")
+ plt.ylabel("Verbleibender Bestand $X_t$")
+ plt.xticks(range(PERIODEN +1))
+ plt.grid(True, linestyle=":", alpha=0.6)
+ plt.legend()
+ plt.tight_layout()
+ ziel = os.path.join(OUTPUT_DIR, "optimal_execution_dp.png")
+ plt.savefig(ziel, dpi=150)
+print(f"\nDiagramm gespeichert unter '{ziel}'")
+print("="*84)
Der Entwickler überlegt: „Für die Zukunft zählt nur, wie viel ich auf Lager habe. Also ist der Lagerbestand mein Zustand.“
-
import functools
-
-@functools.lru_cache(None)
-def V(t, lager):
-if t ==4:
-return (0.0, ())
- best = (float("inf"), ())
-for p inrange(KAP +1):
- neu = lager + p - BEDARF[t]
-if neu <0or neu > LAGER_MAX:
-continue
- kosten = (RUESTKOSTEN if p >0else0) + STUECKKOSTEN * p + LAGERKOSTEN * neu
- rest, plan = V(t +1, neu)
-if kosten + rest < best[0]:
- best = (kosten + rest, (p,) + plan)
-return best
+
import functools
+
+@functools.lru_cache(None)
+def V(t, lager):
+if t ==4:
+return (0.0, ())
+ best = (float("inf"), ())
+for p inrange(KAP +1):
+ neu = lager + p - BEDARF[t]
+if neu <0or neu > LAGER_MAX:
+continue
+ kosten = (RUESTKOSTEN if p >0else0) + STUECKKOSTEN * p + LAGERKOSTEN * neu
+ rest, plan = V(t +1, neu)
+if kosten + rest < best[0]:
+ best = (kosten + rest, (p,) + plan)
+return best
Ergebnis: Plan (5, 0, 5, 0) für 110 €. Nachgerechnet stimmt der Betrag sogar — dieser Plan kostet tatsächlich 110 €. Das wahre Optimum lautet aber (3, 1, 4, 2) und kostet 70 €.
Ihre Aufgabe: (a) Sehen Sie sich die Zeile mit den Rüstkosten an. Welche Information bräuchte sie, die im Zustand (t, lager) nicht enthalten ist? (b) Welchen Vorteil des Plans (3,1,4,2) kann dieses Modell prinzipiell nicht erkennen? (c) Wie lautet der korrigierte Zustand, und um welchen Faktor wächst der Zustandsraum dadurch? (d) Warum ist es besonders tückisch, dass der ausgegebene Kostenbetrag richtig war?
🚀 In 5 Minuten gelöst: Was kostet ein Kilogramm CO₂?
Eine Spedition vergibt fünf Sendungen an LKW, Bahn oder Kombinierten Verkehr. Der LKW ist billig und schmutzig, die Bahn teuer und sauber — und es sind nur zwei Trassen frei.
Statt zu fragen „wie wichtig ist uns CO₂?” fragen wir: Was bekommen wir für zwei Prozent mehr Geld?
guenstigster Plan: 4,350 EUR 6,760 kg CO2
mit 2 % Aufpreis: 4,430 EUR 6,310 kg CO2 (7% weniger, 0.18 EUR je kg)
@@ -18201,266 +18569,266 @@ f_j(x) < f_j(y)\ \text{ für mindestens ein } j
14.5 Das Programm
Zwölf Sendungen, drei Verkehrsträger, fünf freie Bahntrassen. Der Zielkonflikt entsteht nicht zwischen den Trägern — die Bahn ist immer billiger und sauberer als der LKW —, sondern durch die Knappheit der Trassen. Genau so sieht es in der Praxis aus.
-
#!/usr/bin/env python3
-
-# Mehrziel_Pareto.py
-"""
-Kapitel Mehrziel: Kosten gegen CO2 - und warum Gewichte nicht genuegen.
-
-Eine Spedition vergibt zwoelf Sendungen an drei Verkehrstraeger: LKW (schnell,
-teuer, schmutzig), Bahn (billig und sauber, aber nur fuenf Trassen frei) und
-Kombinierten Verkehr (dazwischen). Zwei Ziele stehen gegeneinander:
-Transportkosten und CO2-Ausstoss.
-
-Der uebliche Reflex ist, beide Ziele zu einem zusammenzuruehren:
-
- minimiere Kosten + w * CO2
-
-Das ist bequem, liefert zulaessige Loesungen - und ist unvollstaendig. Bei
-ganzzahligen Entscheidungen gibt es Kompromisse, die auf diesem Weg
-GRUNDSAETZLICH nicht erreichbar sind, egal welches w man waehlt. Nicht "schwer
-zu finden", sondern beweisbar unerreichbar.
-
-Das Programm zeigt in vier Teilen:
-
- 1. Die beiden Extreme - was jedes Ziel allein kostet.
- 2. Die lineare Skalarisierung ueber ein feines Gewichtsraster.
- 3. Die vollstaendige Pareto-Front ueber das eps-Constraint-Verfahren.
- 4. Den Nachweis, dass die Luecke keine Frage des Rasters ist, sondern
- Geometrie: Die fehlenden Punkte liegen strikt oberhalb der konvexen
- Huelle und koennen deshalb von keiner Geraden gestuetzt werden.
-
-Benoetigt: numpy, scipy
-"""
-
-from __future__ import annotations
-
-import numpy as np
-from scipy.optimize import linprog
-
-TRAEGER = ["LKW", "Bahn", "Kombiniert"]
-BAHN_TRASSEN =5# so viele Sendungen passen hoechstens auf die Bahn
-SAAT =5
-
-
-def erzeuge_sendungen(anzahl: int=12, saat: int= SAAT):
-"""Kosten und CO2 je Sendung und Verkehrstraeger.
-
- Die Bahn ist immer billiger und sauberer als der LKW - der Zielkonflikt
- entsteht nicht zwischen den Traegern, sondern durch die KNAPPHEIT der
- Trassen. Genau so sieht es in der Praxis aus.
- """
- rng = np.random.default_rng(saat)
- kosten = np.zeros((anzahl, 3))
- co2 = np.zeros((anzahl, 3))
-for i inrange(anzahl):
- grund_kosten = rng.integers(600, 2400)
- grund_co2 = rng.integers(400, 1800)
- kosten[i] = [grund_kosten,
- grund_kosten * rng.uniform(0.55, 0.85),
- grund_kosten * rng.uniform(0.70, 1.00)]
- co2[i] = [grund_co2,
- grund_co2 * rng.uniform(0.15, 0.35),
- grund_co2 * rng.uniform(0.40, 0.70)]
-return np.round(kosten).astype(int), np.round(co2).astype(int)
-
-
-KOSTEN, CO2 = erzeuge_sendungen()
-N =len(KOSTEN)
-
-
-def plane(ziel: np.ndarray, co2_grenze: float|None=None,
- kosten_grenze: float|None=None):
-"""Jede Sendung genau einem Traeger zuordnen; Trassen sind knapp.
-
- 'ziel' ist die zu minimierende Matrix (Kosten, CO2 oder eine Mischung).
- Die beiden Grenzen sind das Werkzeug fuer eps-Constraint und
- lexikografische Optimierung - sie machen aus einem Ziel eine Schranke.
- """
- gleichungen = np.zeros((N, N *3))
-for i inrange(N):
- gleichungen[i, i *3:(i +1) *3] =1.0# genau ein Traeger
-
- ungleichungen = [[1.0if j ==1else0.0for _ inrange(N) for j inrange(3)]]
- grenzen = [float(BAHN_TRASSEN)]
-if co2_grenze isnotNone:
- ungleichungen.append(CO2.reshape(-1).astype(float))
- grenzen.append(float(co2_grenze))
-if kosten_grenze isnotNone:
- ungleichungen.append(KOSTEN.reshape(-1).astype(float))
- grenzen.append(float(kosten_grenze))
-
- ergebnis = linprog(ziel.reshape(-1).astype(float),
- A_ub=ungleichungen, b_ub=grenzen,
- A_eq=gleichungen, b_eq=np.ones(N),
- bounds=(0, 1), integrality=1, method="highs")
-ifnot ergebnis.success:
-returnNone
- plan = np.round(ergebnis.x).astype(int)
-return (int(KOSTEN.reshape(-1) @ plan), int(CO2.reshape(-1) @ plan), plan)
-
-
-def pareto_front():
-"""Vollstaendige Front ueber das eps-Constraint-Verfahren.
-
- Der Ablauf ist der eigentliche Inhalt dieser Funktion: Erst das
- Kostenminimum bestimmen (der eine Rand der Front), dann die CO2-Schranke
- schrittweise um genau ein Kilogramm unter den zuletzt erreichten Wert
- druecken. Jeder Lauf liefert den naechsten Punkt - und wenn keiner mehr
- zulaessig ist, ist die Front vollstaendig.
-
- Das braucht so viele Solveraufrufe, wie die Front Punkte hat. Ein Raster
- ueber alle moeglichen CO2-Werte braeuchte hier fast tausend.
- """
- front = []
- start = plane(KOSTEN)
- grenze = start[1]
-whileTrue:
- ergebnis = plane(KOSTEN, co2_grenze=grenze)
-if ergebnis isNone:
-break
- front.append((ergebnis[0], ergebnis[1]))
- grenze = ergebnis[1] -1
-return front
-
-
-def skalarisierung(gewichte):
-"""Was 'Kosten + w * CO2' fuer viele w hergibt - als Menge von Punkten."""
- gefunden = {}
-for w in gewichte:
- ergebnis = plane(KOSTEN + w * CO2)
-if ergebnis:
- gefunden.setdefault((ergebnis[0], ergebnis[1]), []).append(w)
-return gefunden
-
-
-def untere_huelle(punkte):
-"""Untere linke konvexe Huelle - genau die Punkte, die eine Gerade stuetzt.
-
- Der Zusammenhang, um den es geht: 'Kosten + w*CO2 minimieren' heisst
- geometrisch, eine Gerade der Steigung -1/w von links unten an die
- Punktwolke zu schieben. Sie beruehrt immer einen Eckpunkt der konvexen
- Huelle. Punkte, die oberhalb liegen, werden nie beruehrt - fuer kein w.
- """
- huelle = []
-for punkt insorted(punkte):
-whilelen(huelle) >=2:
- (x1, y1), (x2, y2) = huelle[-2], huelle[-1]
-if (x2 - x1) * (punkt[1] - y1) - (y2 - y1) * (punkt[0] - x1) <=0:
- huelle.pop()
-else:
-break
- huelle.append(punkt)
-return huelle
-
-
-if__name__=="__main__":
-print("="*80)
-print(" KOSTEN GEGEN CO2 - UND WARUM GEWICHTE NICHT GENUEGEN")
-print("="*80)
-print(f"{N} Sendungen, {len(TRAEGER)} Verkehrstraeger, "
-f"{BAHN_TRASSEN} freie Bahntrassen.\n")
-
-# --- 1. Die beiden Extreme -------------------------------------------
- guenstigst = plane(KOSTEN)
- saubersten = plane(CO2)
-print("1. Was jedes Ziel allein ergibt\n")
-print(f" {'':<22}{'Kosten':>10}{'CO2':>10}")
-print(" "+"-"*44)
-print(f" {'nur Kosten minimal':<22}{guenstigst[0]:>10,}{guenstigst[1]:>9,} kg")
-print(f" {'nur CO2 minimal':<22}{saubersten[0]:>10,}{saubersten[1]:>9,} kg")
-print(f"\n Der Zielkonflikt ist echt, aber klein: "
-f"{saubersten[0] - guenstigst[0]:,} EUR mehr")
-print(f" ({(saubersten[0] - guenstigst[0]) / guenstigst[0] *100:.1f} %) sparen "
-f"{guenstigst[1] - saubersten[1]:,} kg CO2 "
-f"({(guenstigst[1] - saubersten[1]) / guenstigst[1] *100:.1f} %).")
-print(" Genau solche Zahlen will die Geschaeftsfuehrung sehen - nicht ein")
-print(" Gewicht, das niemand interpretieren kann.")
-
-# --- 2. Die lineare Skalarisierung -----------------------------------
- gewichte = np.concatenate([np.linspace(0.0, 3.0, 1201),
- np.geomspace(3.0, 1000.0, 200)])
- gefunden = skalarisierung(gewichte)
-print("\n"+"-"*80)
-print(f"2. Lineare Skalarisierung: 'Kosten + w * CO2' fuer "
-f"{len(gewichte):,} Gewichte\n")
-print(f" {'Kosten':>10}{'CO2':>10}{'gefunden bei w':>16}")
-print(" "+"-"*46)
-for (k, c), ws insorted(gefunden.items()):
-print(f" {k:>10,}{c:>9,} kg {min(ws):>7.3f} bis {max(ws):>7.3f}")
-print(f"\n{len(gefunden)} verschiedene Plaene - fuer {len(gewichte):,} Gewichte.")
-print(" Das Gewicht ist also gar keine Feineinstellung: Weite Bereiche")
-print(" liefern dasselbe Ergebnis, und dazwischen springt es.")
-
-# --- 3. Die vollstaendige Front --------------------------------------
- front = pareto_front()
-print("\n"+"-"*80)
-print(f"3. Die vollstaendige Pareto-Front ueber eps-Constraint "
-f"({len(front)} Solverlaeufe)\n")
-print(f" {'Kosten':>10}{'CO2':>10}{'Aufpreis':>9}{'CO2-Ersparnis':>14} "
-f"{'EUR je kg':>10}")
-print(" "+"-"*60)
-for k, c in front:
- auf = k - guenstigst[0]
- ersparnis = guenstigst[1] - c
- preis = auf / ersparnis if ersparnis else0.0
-print(f" {k:>10,}{c:>9,} kg {auf:>8,}{ersparnis:>13,} "
-f"{preis:>10.2f}")
-
-# --- 4. Der Nachweis --------------------------------------------------
- huelle = untere_huelle(front)
- unerreichbar = [p for p in front if p notin huelle]
-print("\n"+"-"*80)
-print("4. Was die Skalarisierung nicht findet\n")
- erreicht = [p for p in front if p in gefunden]
-print(f" Pareto-Punkte insgesamt: {len(front)}")
-print(f" davon von der Skalarisierung gefunden: {len(erreicht)}")
-print(f" nie gefunden: {len(front) -len(erreicht)}")
-print()
-print(" Diese Kompromisse sind fuer KEIN Gewicht erreichbar:\n")
-print(f" {'Kosten':>10}{'CO2':>10}{'Aufpreis':>9}{'CO2-Ersparnis':>14}")
-print(" "+"-"*50)
-for k, c in unerreichbar:
-print(f" {k:>10,}{c:>9,} kg {k - guenstigst[0]:>8,} "
-f"{guenstigst[1] - c:>13,}")
-
- stimmt =sorted(unerreichbar) ==sorted(p for p in front if p notin gefunden)
-print(f"\n Gegenprobe ueber die Geometrie: {'bestanden'if stimmt else'ABWEICHUNG'}")
-print(" Genau die Punkte, die das Gewichtsraster verfehlt, liegen strikt")
-print(" oberhalb der unteren konvexen Huelle. Das ist kein Rasterproblem -")
-print(" eine Gerade, die von links unten an die Wolke geschoben wird,")
-print(" beruehrt immer einen Eckpunkt der Huelle und nie einen Punkt")
-print(" darueber. Ein feineres Raster aendert daran nichts.")
-
-# --- 5. Lexikografisch -------------------------------------------------
-print("\n"+"-"*80)
-print("5. Lexikografisch: erst Kosten, dann CO2 im Rahmen eines Budgets\n")
-print(f" {'Kostenbudget':>14}{'Kosten':>10}{'CO2':>10}{'gegenueber Minimum':>20}")
-print(" "+"-"*58)
-for aufschlag in (0.00, 0.01, 0.02, 0.05, 0.10):
- budget = guenstigst[0] * (1+ aufschlag)
- ergebnis = plane(CO2, kosten_grenze=budget)
-if ergebnis isNone:
-print(f" {aufschlag:>13.0%} unzulaessig")
-continue
-print(f" {aufschlag:>13.0%}{ergebnis[0]:>10,}{ergebnis[1]:>9,} kg "
-f"{guenstigst[1] - ergebnis[1]:>15,} kg weniger")
-
-print("\n Das ist die Form, die im Betrieb am ehesten trifft: Nicht 'wie")
-print(" wichtig ist CO2?', sondern 'wir geben zwei Prozent mehr aus - was")
-print(" bringt das?'. Die Frage kann ein Kaufmann beantworten.")
-
-print("\n"+"="*80)
-print(" WAS MAN DARAUS MITNIMMT")
-print("="*80)
-print("Ein Gewicht zu setzen heisst, die Entscheidung heimlich zu treffen -")
-print("und dabei einen Teil der Moeglichkeiten gar nicht erst zu sehen.")
-print()
-print("Die Pareto-Front ist die ehrlichere Antwort: Sie legt dem Betrieb")
-print("alle sinnvollen Kompromisse vor und ueberlaesst ihm die Wahl. Die")
-print("Spalte 'EUR je kg' macht sie entscheidbar - man vergleicht sie mit")
-print("dem CO2-Preis, den das Unternehmen ohnehin ansetzt.")
-print("="*80)
+
#!/usr/bin/env python3
+
+# Mehrziel_Pareto.py
+"""
+Kapitel Mehrziel: Kosten gegen CO2 - und warum Gewichte nicht genuegen.
+
+Eine Spedition vergibt zwoelf Sendungen an drei Verkehrstraeger: LKW (schnell,
+teuer, schmutzig), Bahn (billig und sauber, aber nur fuenf Trassen frei) und
+Kombinierten Verkehr (dazwischen). Zwei Ziele stehen gegeneinander:
+Transportkosten und CO2-Ausstoss.
+
+Der uebliche Reflex ist, beide Ziele zu einem zusammenzuruehren:
+
+ minimiere Kosten + w * CO2
+
+Das ist bequem, liefert zulaessige Loesungen - und ist unvollstaendig. Bei
+ganzzahligen Entscheidungen gibt es Kompromisse, die auf diesem Weg
+GRUNDSAETZLICH nicht erreichbar sind, egal welches w man waehlt. Nicht "schwer
+zu finden", sondern beweisbar unerreichbar.
+
+Das Programm zeigt in vier Teilen:
+
+ 1. Die beiden Extreme - was jedes Ziel allein kostet.
+ 2. Die lineare Skalarisierung ueber ein feines Gewichtsraster.
+ 3. Die vollstaendige Pareto-Front ueber das eps-Constraint-Verfahren.
+ 4. Den Nachweis, dass die Luecke keine Frage des Rasters ist, sondern
+ Geometrie: Die fehlenden Punkte liegen strikt oberhalb der konvexen
+ Huelle und koennen deshalb von keiner Geraden gestuetzt werden.
+
+Benoetigt: numpy, scipy
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from scipy.optimize import linprog
+
+TRAEGER = ["LKW", "Bahn", "Kombiniert"]
+BAHN_TRASSEN =5# so viele Sendungen passen hoechstens auf die Bahn
+SAAT =5
+
+
+def erzeuge_sendungen(anzahl: int=12, saat: int= SAAT):
+"""Kosten und CO2 je Sendung und Verkehrstraeger.
+
+ Die Bahn ist immer billiger und sauberer als der LKW - der Zielkonflikt
+ entsteht nicht zwischen den Traegern, sondern durch die KNAPPHEIT der
+ Trassen. Genau so sieht es in der Praxis aus.
+ """
+ rng = np.random.default_rng(saat)
+ kosten = np.zeros((anzahl, 3))
+ co2 = np.zeros((anzahl, 3))
+for i inrange(anzahl):
+ grund_kosten = rng.integers(600, 2400)
+ grund_co2 = rng.integers(400, 1800)
+ kosten[i] = [grund_kosten,
+ grund_kosten * rng.uniform(0.55, 0.85),
+ grund_kosten * rng.uniform(0.70, 1.00)]
+ co2[i] = [grund_co2,
+ grund_co2 * rng.uniform(0.15, 0.35),
+ grund_co2 * rng.uniform(0.40, 0.70)]
+return np.round(kosten).astype(int), np.round(co2).astype(int)
+
+
+KOSTEN, CO2 = erzeuge_sendungen()
+N =len(KOSTEN)
+
+
+def plane(ziel: np.ndarray, co2_grenze: float|None=None,
+ kosten_grenze: float|None=None):
+"""Jede Sendung genau einem Traeger zuordnen; Trassen sind knapp.
+
+ 'ziel' ist die zu minimierende Matrix (Kosten, CO2 oder eine Mischung).
+ Die beiden Grenzen sind das Werkzeug fuer eps-Constraint und
+ lexikografische Optimierung - sie machen aus einem Ziel eine Schranke.
+ """
+ gleichungen = np.zeros((N, N *3))
+for i inrange(N):
+ gleichungen[i, i *3:(i +1) *3] =1.0# genau ein Traeger
+
+ ungleichungen = [[1.0if j ==1else0.0for _ inrange(N) for j inrange(3)]]
+ grenzen = [float(BAHN_TRASSEN)]
+if co2_grenze isnotNone:
+ ungleichungen.append(CO2.reshape(-1).astype(float))
+ grenzen.append(float(co2_grenze))
+if kosten_grenze isnotNone:
+ ungleichungen.append(KOSTEN.reshape(-1).astype(float))
+ grenzen.append(float(kosten_grenze))
+
+ ergebnis = linprog(ziel.reshape(-1).astype(float),
+ A_ub=ungleichungen, b_ub=grenzen,
+ A_eq=gleichungen, b_eq=np.ones(N),
+ bounds=(0, 1), integrality=1, method="highs")
+ifnot ergebnis.success:
+returnNone
+ plan = np.round(ergebnis.x).astype(int)
+return (int(KOSTEN.reshape(-1) @ plan), int(CO2.reshape(-1) @ plan), plan)
+
+
+def pareto_front():
+"""Vollstaendige Front ueber das eps-Constraint-Verfahren.
+
+ Der Ablauf ist der eigentliche Inhalt dieser Funktion: Erst das
+ Kostenminimum bestimmen (der eine Rand der Front), dann die CO2-Schranke
+ schrittweise um genau ein Kilogramm unter den zuletzt erreichten Wert
+ druecken. Jeder Lauf liefert den naechsten Punkt - und wenn keiner mehr
+ zulaessig ist, ist die Front vollstaendig.
+
+ Das braucht so viele Solveraufrufe, wie die Front Punkte hat. Ein Raster
+ ueber alle moeglichen CO2-Werte braeuchte hier fast tausend.
+ """
+ front = []
+ start = plane(KOSTEN)
+ grenze = start[1]
+whileTrue:
+ ergebnis = plane(KOSTEN, co2_grenze=grenze)
+if ergebnis isNone:
+break
+ front.append((ergebnis[0], ergebnis[1]))
+ grenze = ergebnis[1] -1
+return front
+
+
+def skalarisierung(gewichte):
+"""Was 'Kosten + w * CO2' fuer viele w hergibt - als Menge von Punkten."""
+ gefunden = {}
+for w in gewichte:
+ ergebnis = plane(KOSTEN + w * CO2)
+if ergebnis:
+ gefunden.setdefault((ergebnis[0], ergebnis[1]), []).append(w)
+return gefunden
+
+
+def untere_huelle(punkte):
+"""Untere linke konvexe Huelle - genau die Punkte, die eine Gerade stuetzt.
+
+ Der Zusammenhang, um den es geht: 'Kosten + w*CO2 minimieren' heisst
+ geometrisch, eine Gerade der Steigung -1/w von links unten an die
+ Punktwolke zu schieben. Sie beruehrt immer einen Eckpunkt der konvexen
+ Huelle. Punkte, die oberhalb liegen, werden nie beruehrt - fuer kein w.
+ """
+ huelle = []
+for punkt insorted(punkte):
+whilelen(huelle) >=2:
+ (x1, y1), (x2, y2) = huelle[-2], huelle[-1]
+if (x2 - x1) * (punkt[1] - y1) - (y2 - y1) * (punkt[0] - x1) <=0:
+ huelle.pop()
+else:
+break
+ huelle.append(punkt)
+return huelle
+
+
+if__name__=="__main__":
+print("="*80)
+print(" KOSTEN GEGEN CO2 - UND WARUM GEWICHTE NICHT GENUEGEN")
+print("="*80)
+print(f"{N} Sendungen, {len(TRAEGER)} Verkehrstraeger, "
+f"{BAHN_TRASSEN} freie Bahntrassen.\n")
+
+# --- 1. Die beiden Extreme -------------------------------------------
+ guenstigst = plane(KOSTEN)
+ saubersten = plane(CO2)
+print("1. Was jedes Ziel allein ergibt\n")
+print(f" {'':<22}{'Kosten':>10}{'CO2':>10}")
+print(" "+"-"*44)
+print(f" {'nur Kosten minimal':<22}{guenstigst[0]:>10,}{guenstigst[1]:>9,} kg")
+print(f" {'nur CO2 minimal':<22}{saubersten[0]:>10,}{saubersten[1]:>9,} kg")
+print(f"\n Der Zielkonflikt ist echt, aber klein: "
+f"{saubersten[0] - guenstigst[0]:,} EUR mehr")
+print(f" ({(saubersten[0] - guenstigst[0]) / guenstigst[0] *100:.1f} %) sparen "
+f"{guenstigst[1] - saubersten[1]:,} kg CO2 "
+f"({(guenstigst[1] - saubersten[1]) / guenstigst[1] *100:.1f} %).")
+print(" Genau solche Zahlen will die Geschaeftsfuehrung sehen - nicht ein")
+print(" Gewicht, das niemand interpretieren kann.")
+
+# --- 2. Die lineare Skalarisierung -----------------------------------
+ gewichte = np.concatenate([np.linspace(0.0, 3.0, 1201),
+ np.geomspace(3.0, 1000.0, 200)])
+ gefunden = skalarisierung(gewichte)
+print("\n"+"-"*80)
+print(f"2. Lineare Skalarisierung: 'Kosten + w * CO2' fuer "
+f"{len(gewichte):,} Gewichte\n")
+print(f" {'Kosten':>10}{'CO2':>10}{'gefunden bei w':>16}")
+print(" "+"-"*46)
+for (k, c), ws insorted(gefunden.items()):
+print(f" {k:>10,}{c:>9,} kg {min(ws):>7.3f} bis {max(ws):>7.3f}")
+print(f"\n{len(gefunden)} verschiedene Plaene - fuer {len(gewichte):,} Gewichte.")
+print(" Das Gewicht ist also gar keine Feineinstellung: Weite Bereiche")
+print(" liefern dasselbe Ergebnis, und dazwischen springt es.")
+
+# --- 3. Die vollstaendige Front --------------------------------------
+ front = pareto_front()
+print("\n"+"-"*80)
+print(f"3. Die vollstaendige Pareto-Front ueber eps-Constraint "
+f"({len(front)} Solverlaeufe)\n")
+print(f" {'Kosten':>10}{'CO2':>10}{'Aufpreis':>9}{'CO2-Ersparnis':>14} "
+f"{'EUR je kg':>10}")
+print(" "+"-"*60)
+for k, c in front:
+ auf = k - guenstigst[0]
+ ersparnis = guenstigst[1] - c
+ preis = auf / ersparnis if ersparnis else0.0
+print(f" {k:>10,}{c:>9,} kg {auf:>8,}{ersparnis:>13,} "
+f"{preis:>10.2f}")
+
+# --- 4. Der Nachweis --------------------------------------------------
+ huelle = untere_huelle(front)
+ unerreichbar = [p for p in front if p notin huelle]
+print("\n"+"-"*80)
+print("4. Was die Skalarisierung nicht findet\n")
+ erreicht = [p for p in front if p in gefunden]
+print(f" Pareto-Punkte insgesamt: {len(front)}")
+print(f" davon von der Skalarisierung gefunden: {len(erreicht)}")
+print(f" nie gefunden: {len(front) -len(erreicht)}")
+print()
+print(" Diese Kompromisse sind fuer KEIN Gewicht erreichbar:\n")
+print(f" {'Kosten':>10}{'CO2':>10}{'Aufpreis':>9}{'CO2-Ersparnis':>14}")
+print(" "+"-"*50)
+for k, c in unerreichbar:
+print(f" {k:>10,}{c:>9,} kg {k - guenstigst[0]:>8,} "
+f"{guenstigst[1] - c:>13,}")
+
+ stimmt =sorted(unerreichbar) ==sorted(p for p in front if p notin gefunden)
+print(f"\n Gegenprobe ueber die Geometrie: {'bestanden'if stimmt else'ABWEICHUNG'}")
+print(" Genau die Punkte, die das Gewichtsraster verfehlt, liegen strikt")
+print(" oberhalb der unteren konvexen Huelle. Das ist kein Rasterproblem -")
+print(" eine Gerade, die von links unten an die Wolke geschoben wird,")
+print(" beruehrt immer einen Eckpunkt der Huelle und nie einen Punkt")
+print(" darueber. Ein feineres Raster aendert daran nichts.")
+
+# --- 5. Lexikografisch -------------------------------------------------
+print("\n"+"-"*80)
+print("5. Lexikografisch: erst Kosten, dann CO2 im Rahmen eines Budgets\n")
+print(f" {'Kostenbudget':>14}{'Kosten':>10}{'CO2':>10}{'gegenueber Minimum':>20}")
+print(" "+"-"*58)
+for aufschlag in (0.00, 0.01, 0.02, 0.05, 0.10):
+ budget = guenstigst[0] * (1+ aufschlag)
+ ergebnis = plane(CO2, kosten_grenze=budget)
+if ergebnis isNone:
+print(f" {aufschlag:>13.0%} unzulaessig")
+continue
+print(f" {aufschlag:>13.0%}{ergebnis[0]:>10,}{ergebnis[1]:>9,} kg "
+f"{guenstigst[1] - ergebnis[1]:>15,} kg weniger")
+
+print("\n Das ist die Form, die im Betrieb am ehesten trifft: Nicht 'wie")
+print(" wichtig ist CO2?', sondern 'wir geben zwei Prozent mehr aus - was")
+print(" bringt das?'. Die Frage kann ein Kaufmann beantworten.")
+
+print("\n"+"="*80)
+print(" WAS MAN DARAUS MITNIMMT")
+print("="*80)
+print("Ein Gewicht zu setzen heisst, die Entscheidung heimlich zu treffen -")
+print("und dabei einen Teil der Moeglichkeiten gar nicht erst zu sehen.")
+print()
+print("Die Pareto-Front ist die ehrlichere Antwort: Sie legt dem Betrieb")
+print("alle sinnvollen Kompromisse vor und ueberlaesst ihm die Wahl. Die")
+print("Spalte 'EUR je kg' macht sie entscheidbar - man vergleicht sie mit")
+print("dem CO2-Preis, den das Unternehmen ohnehin ansetzt.")
+print("="*80)
Erwartete Ausgabe:
================================================================================
KOSTEN GEGEN CO2 - UND WARUM GEWICHTE NICHT GENUEGEN
@@ -18748,22 +19116,22 @@ e(x) \le \varepsilon
🚀 In 5 Minuten gelöst: Der Durchschnitt ist die falsche Zahl
Eine Bäckerei kennt die Nachfrage der letzten 20 Tage. Ein Brot bringt 6 € Marge, ein übriges kostet 3 € Einkauf. Wie viele soll sie ansetzen?
Die naheliegende Antwort — den Durchschnitt — ist nachweislich falsch:
Dieselbe Bäckerei, aber mit Merkmalen: Wochentag, Temperatur, Aktionstage. Die Nachfrage muss jetzt modelliert werden.
Eine Eigenschaft der Daten ist dabei entscheidend und in der Praxis der Normalfall: An Aktionstagen ist die Nachfrage nicht nur höher, sondern auch viel unsicherer (heteroskedastisch).
-
#!/usr/bin/env python3
-
-# Predict_then_Optimize.py
-"""
-Kapitel Prognose: Die bessere Prognose trifft die schlechtere Entscheidung.
-
-Eine Baeckerei muss jeden Abend entscheiden, wie viel sie fuer den naechsten Tag
-ansetzt. Zu wenig kostet die Marge des entgangenen Verkaufs, zu viel kostet den
-Einkaufspreis der Retoure. Das ist das Newsvendor-Problem aus dem Kapitel
-Unsicherheit - nur dass die Nachfrage diesmal nicht aus einer Verteilung kommt,
-sondern PROGNOSTIZIERT werden muss: aus Wochentag, Temperatur und Aktionstagen.
-
-Damit zerfaellt die Aufgabe in zwei Schritte, und genau an der Naht entsteht der
-Fehler, um den es hier geht:
-
- PREDICT ein Modell schaetzt die Nachfrage
- OPTIMIZE daraus wird eine Bestellmenge
-
-Der Prognostiker optimiert seinen Modellfehler, meist den MSE. Der Planer traegt
-die Kosten. Beide messen etwas anderes - und die beiden Masse widersprechen
-einander. Das Programm zeigt:
-
- 1. Vier Verfahren, verglichen nach MSE UND nach Entscheidungskosten. Das
- Verfahren mit dem BESTEN MSE hat die HOECHSTEN Kosten.
- 2. Warum ein pauschaler Sicherheitszuschlag zu kurz greift - die Streuung der
- Nachfrage haengt selbst von den Merkmalen ab.
- 3. Eine Messfalle, in die der Autor dieses Programms zuerst selbst getappt
- ist: Bei kurzen Testzeitraeumen ist der MSE-Vergleich nicht stabil.
-
-Benoetigt: numpy, scipy, scikit-learn
-"""
-
-from __future__ import annotations
-
-import numpy as np
-from scipy.stats import norm
-from sklearn.linear_model import LinearRegression, QuantileRegressor
-
-VERKAUFSPREIS =9.0
-EINKAUFSPREIS =3.0
-KOSTEN_FEHLMENGE = VERKAUFSPREIS - EINKAUFSPREIS # entgangene Marge: 6 EUR
-KOSTEN_UEBERHANG = EINKAUFSPREIS # Retoure: 3 EUR
-KRITISCHES_VERHAELTNIS = KOSTEN_FEHLMENGE / (KOSTEN_FEHLMENGE + KOSTEN_UEBERHANG)
-
-TAGE =5000# Simulation, siehe Hinweis unten
-TRAINING =1000
-SAAT =11
-
-
-def erzeuge_daten(tage: int= TAGE, saat: int= SAAT):
-"""Taegliche Nachfrage mit Wochentag, Temperatur und Aktionstagen.
-
- Die entscheidende Eigenschaft steckt in 'streuung': An Aktionstagen ist die
- Nachfrage nicht nur hoeher, sondern auch viel UNSICHERER. Solche
- heteroskedastischen Daten sind der Normalfall - und der Grund, warum ein
- pauschaler Sicherheitszuschlag nicht genuegt.
- """
- rng = np.random.default_rng(saat)
- wochentag = np.arange(tage) %7
- temperatur = (12+10* np.sin(2* np.pi * np.arange(tage) /365)
-+ rng.normal(0, 3, tage))
- aktion = (rng.random(tage) <0.15).astype(float)
-
- merkmale = np.column_stack([np.eye(7)[wochentag][:, 1:], temperatur, aktion])
- erwartung = (120
-+ np.eye(7)[wochentag] @ np.array([0, 10, 12, 14, 18, 35, -40])
-+1.8* temperatur +45* aktion)
- streuung =8+22* aktion
- nachfrage = np.maximum(0.0, erwartung + rng.normal(0, 1, tage) * streuung)
-return merkmale, nachfrage, aktion
-
-
-def tageskosten(bestellung: np.ndarray, nachfrage: np.ndarray) ->float:
-"""Die Zahl, auf die es ankommt - und die kein Prognosemass kennt."""
- fehlmenge = np.maximum(0.0, nachfrage - bestellung)
- ueberhang = np.maximum(0.0, bestellung - nachfrage)
-returnfloat((KOSTEN_FEHLMENGE * fehlmenge
-+ KOSTEN_UEBERHANG * ueberhang).mean())
-
-
-if__name__=="__main__":
- merkmale, nachfrage, aktion = erzeuge_daten()
- lernen =slice(0, TRAINING)
- pruefen =slice(TRAINING, TAGE)
-
-print("="*84)
-print(" DIE BESSERE PROGNOSE TRIFFT DIE SCHLECHTERE ENTSCHEIDUNG")
-print("="*84)
-print(f"Verkaufspreis {VERKAUFSPREIS:.0f} EUR, Einkauf {EINKAUFSPREIS:.0f} EUR.")
-print(f"Fehlmenge kostet {KOSTEN_FEHLMENGE:.0f} EUR, Ueberhang "
-f"{KOSTEN_UEBERHANG:.0f} EUR je Stueck.")
-print(f"Kritisches Verhaeltnis: {KRITISCHES_VERHAELTNIS:.3f} - der Planer sollte "
-f"also das")
-print(f"{KRITISCHES_VERHAELTNIS:.1%}-Quantil der Nachfrage bestellen, nicht ihren "
-f"Erwartungswert.")
-print(f"\nTraining: Tag 1 bis {TRAINING}. Bewertung: die restlichen "
-f"{TAGE - TRAINING} Tage.\n")
-
-# --- Die vier Verfahren ----------------------------------------------
- kleinste_quadrate = LinearRegression().fit(merkmale[lernen], nachfrage[lernen])
- punktprognose = kleinste_quadrate.predict(merkmale[pruefen])
-
- restfehler = nachfrage[lernen] - kleinste_quadrate.predict(merkmale[lernen])
- pauschalzuschlag = norm.ppf(KRITISCHES_VERHAELTNIS) * restfehler.std()
-
-# Ein Zuschlag, der nicht aus der Normalverteilung kommt, sondern direkt
-# auf den Trainingsdaten die Kosten minimiert.
- kandidaten = np.linspace(-10.0, 30.0, 401)
- trainingsprognose = kleinste_quadrate.predict(merkmale[lernen])
- kostenzuschlag =float(kandidaten[np.argmin(
- [tageskosten(trainingsprognose + z, nachfrage[lernen]) for z in kandidaten])])
-
-# Und das Verfahren, das von vornherein das richtige Quantil schaetzt.
- quantilmodell = QuantileRegressor(quantile=KRITISCHES_VERHAELTNIS,
- alpha=0.0, solver="highs")
- quantilmodell.fit(merkmale[lernen], nachfrage[lernen])
- quantilprognose = quantilmodell.predict(merkmale[pruefen])
-
- verfahren = [
- ("bestelle die Punktprognose", punktprognose, punktprognose),
- ("+ Zuschlag aus der Normalverteilung",
- punktprognose, punktprognose + pauschalzuschlag),
- ("+ Zuschlag auf Kosten trainiert",
- punktprognose, punktprognose + kostenzuschlag),
- ("Quantilregression aufs kritische Quantil",
- quantilprognose, quantilprognose),
- ]
-
-print(f" {'Verfahren':<42}{'MSE':>9}{'Kosten/Tag':>12}{'gegen Zeile 1':>14}")
-print(" "+"-"*80)
- ergebnisse = {}
-for name, prognose, bestellung in verfahren:
- mse =float(((prognose - nachfrage[pruefen]) **2).mean())
- kosten = tageskosten(bestellung, nachfrage[pruefen])
- ergebnisse[name] = (mse, kosten)
- basis = ergebnisse[verfahren[0][0]][1]
- vergleich =""if name == verfahren[0][0] elsef"{(kosten - basis) / basis:+13.1%}"
-print(f" {name:<42}{mse:>9.1f}{kosten:>10.2f} EUR {vergleich:>14}")
-
- bester_mse =min(ergebnisse, key=lambda k: ergebnisse[k][0])
- beste_kosten =min(ergebnisse, key=lambda k: ergebnisse[k][1])
-print(f"\n bester MSE: {bester_mse}")
-print(f" beste Kosten: {beste_kosten}")
-print(f"\n Das Verfahren mit dem besten MSE hat die HOECHSTEN Kosten, und das")
-print(f" Verfahren mit den besten Kosten hat einen um "
-f"{(ergebnisse[beste_kosten][0] / ergebnisse[bester_mse][0] -1):.0%} SCHLECHTEREN MSE.")
-print(" Wer Prognosemodelle nach MSE auswaehlt, waehlt hier das falsche.")
-
-# --- Warum der pauschale Zuschlag zu kurz greift ---------------------
-print("\n"+"-"*84)
-print("Warum ein pauschaler Zuschlag nicht genuegt\n")
- ist_aktion = aktion[pruefen] >0.5
-print(f" {'Verfahren':<42}{'normale Tage':>14}{'Aktionstage':>14}")
-print(" "+"-"*74)
-for name, _, bestellung in verfahren:
- normal = tageskosten(bestellung[~ist_aktion], nachfrage[pruefen][~ist_aktion])
- aktionstag = tageskosten(bestellung[ist_aktion], nachfrage[pruefen][ist_aktion])
-print(f" {name:<42}{normal:>10.2f} EUR {aktionstag:>10.2f} EUR")
-
-# Nachgerechnet statt behauptet: Welcher Zuschlag waere je Tagesart richtig?
- aktion_training = aktion[lernen] >0.5
- z = norm.ppf(KRITISCHES_VERHAELTNIS)
- richtig_normal = z * restfehler[~aktion_training].std()
- richtig_aktion = z * restfehler[aktion_training].std()
-print(f"\n Der pauschale Zuschlag betraegt {pauschalzuschlag:.1f} Stueck. Aus den")
-print(f" Trainingsresten getrennt nach Tagesart waere richtig:")
-print(f" normale Tage : {richtig_normal:5.1f} Stueck")
-print(f" Aktionstage : {richtig_aktion:5.1f} Stueck")
-print(f" Ein Zuschlag fuer alle Tage kann nur einen Mittelweg treffen - hier")
-print(f" ist er an normalen Tagen {pauschalzuschlag / richtig_normal:.1f}-mal zu gross und an")
-print(f" Aktionstagen nur {pauschalzuschlag / richtig_aktion:.0%} dessen, was noetig waere.")
-print(f"\n Die Quantilregression schaetzt das {KRITISCHES_VERHAELTNIS:.1%}-Quantil "
-f"direkt aus den")
-print(f" Merkmalen und darf deshalb an verschiedenen Tagen verschieden weit")
-print(f" ueber dem Erwartungswert liegen. Genau das ist der Unterschied")
-print(f" zwischen 'ein Modell und danach eine Formel' und 'ein Modell, das")
-print(f" weiss, wofuer es gebraucht wird'.")
-
-# --- Die Messfalle ---------------------------------------------------
-print("\n"+"-"*84)
-print("Eine Messfalle, in die der Autor zuerst selbst getappt ist\n")
-print(" Der erste Entwurf dieses Programms bewertete auf 230 Testtagen - ein")
-print(" realistischer Zeitraum. Dort hatte die Quantilregression den BESSEREN")
-print(" MSE, und die ganze Aussage des Kapitels stand auf dem Kopf.")
-print("\n Wie oft das passiert, laesst sich ausmessen:\n")
- rng = np.random.default_rng(0)
-print(f" {'Testfenster':>14}{'QR sieht MSE-besser aus':>26}")
-print(" "+"-"*42)
-for fenster in (180, 365, 730, 2000):
- treffer =0
- versuche =400
-for _ inrange(versuche):
- start =int(rng.integers(TRAINING, TAGE - fenster))
- ausschnitt =slice(start, start + fenster)
- mse_punkt = ((kleinste_quadrate.predict(merkmale[ausschnitt])
-- nachfrage[ausschnitt]) **2).mean()
- mse_quantil = ((quantilmodell.predict(merkmale[ausschnitt])
-- nachfrage[ausschnitt]) **2).mean()
- treffer += mse_quantil < mse_punkt
-print(f" {fenster:>10} Tage {treffer / versuche:>24.1%}")
-
-print("\n Bei einem halben Jahr Testdaten sieht das schlechtere Modell in gut")
-print(" jedem zehnten Fall besser aus. Das ist keine grosse Zahl - aber wer")
-print(" EINMAL misst, hat genau eine Ziehung aus dieser Verteilung.")
-print("\n Die Lehre ist nicht 'nimm 4.000 Testtage' - die hat niemand. Sie")
-print(" lautet: Ein Kennzahlenvergleich ohne Angabe seiner Streuung ist keine")
-print(" Aussage. Bei kurzen Zeitraeumen gehoert eine Kreuzvalidierung dazu.")
-
-print("\n"+"="*84)
-print(" WAS MAN DARAUS MITNIMMT")
-print("="*84)
-print("Der Prognostiker optimiert den MSE, der Planer traegt die Kosten - und")
-print("die beiden Masse zeigen hier in verschiedene Richtungen. Drei Saetze:")
-print()
-print(" 1. Sagen Sie nicht den Erwartungswert vorher, sondern die Groesse, die")
-print(" in die Entscheidung eingeht. Beim Newsvendor ist das das kritische")
-print(" Quantil - und das kann man direkt schaetzen.")
-print(" 2. Bewerten Sie Prognosemodelle an den ENTSCHEIDUNGSKOSTEN. Die sind")
-print(" in Euro und damit vergleichbar; ein MSE ist es nicht.")
-print(" 3. Ein pauschaler Sicherheitszuschlag ist besser als nichts und")
-print(" schlechter als ein Modell, das die Unsicherheit selbst aus den")
-print(" Merkmalen liest.")
-print()
-print("Der naechste Schritt - Prognosemodelle so zu trainieren, dass sie die")
-print("Entscheidungskosten direkt minimieren (Smart Predict-then-Optimize,")
-print("differenzierbare Optimierungsschichten) - ist Forschungsstand und")
-print("erfordert Bibliotheken wie cvxpylayers. Die dritte Zeile der Tabelle")
-print("oben ist seine einfachste denkbare Form: ein einziger Parameter, auf")
-print("Kosten statt auf Fehler trainiert.")
-print("="*84)
+
#!/usr/bin/env python3
+
+# Predict_then_Optimize.py
+"""
+Kapitel Prognose: Die bessere Prognose trifft die schlechtere Entscheidung.
+
+Eine Baeckerei muss jeden Abend entscheiden, wie viel sie fuer den naechsten Tag
+ansetzt. Zu wenig kostet die Marge des entgangenen Verkaufs, zu viel kostet den
+Einkaufspreis der Retoure. Das ist das Newsvendor-Problem aus dem Kapitel
+Unsicherheit - nur dass die Nachfrage diesmal nicht aus einer Verteilung kommt,
+sondern PROGNOSTIZIERT werden muss: aus Wochentag, Temperatur und Aktionstagen.
+
+Damit zerfaellt die Aufgabe in zwei Schritte, und genau an der Naht entsteht der
+Fehler, um den es hier geht:
+
+ PREDICT ein Modell schaetzt die Nachfrage
+ OPTIMIZE daraus wird eine Bestellmenge
+
+Der Prognostiker optimiert seinen Modellfehler, meist den MSE. Der Planer traegt
+die Kosten. Beide messen etwas anderes - und die beiden Masse widersprechen
+einander. Das Programm zeigt:
+
+ 1. Vier Verfahren, verglichen nach MSE UND nach Entscheidungskosten. Das
+ Verfahren mit dem BESTEN MSE hat die HOECHSTEN Kosten.
+ 2. Warum ein pauschaler Sicherheitszuschlag zu kurz greift - die Streuung der
+ Nachfrage haengt selbst von den Merkmalen ab.
+ 3. Eine Messfalle, in die der Autor dieses Programms zuerst selbst getappt
+ ist: Bei kurzen Testzeitraeumen ist der MSE-Vergleich nicht stabil.
+
+Benoetigt: numpy, scipy, scikit-learn
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from scipy.stats import norm
+from sklearn.linear_model import LinearRegression, QuantileRegressor
+
+VERKAUFSPREIS =9.0
+EINKAUFSPREIS =3.0
+KOSTEN_FEHLMENGE = VERKAUFSPREIS - EINKAUFSPREIS # entgangene Marge: 6 EUR
+KOSTEN_UEBERHANG = EINKAUFSPREIS # Retoure: 3 EUR
+KRITISCHES_VERHAELTNIS = KOSTEN_FEHLMENGE / (KOSTEN_FEHLMENGE + KOSTEN_UEBERHANG)
+
+TAGE =5000# Simulation, siehe Hinweis unten
+TRAINING =1000
+SAAT =11
+
+
+def erzeuge_daten(tage: int= TAGE, saat: int= SAAT):
+"""Taegliche Nachfrage mit Wochentag, Temperatur und Aktionstagen.
+
+ Die entscheidende Eigenschaft steckt in 'streuung': An Aktionstagen ist die
+ Nachfrage nicht nur hoeher, sondern auch viel UNSICHERER. Solche
+ heteroskedastischen Daten sind der Normalfall - und der Grund, warum ein
+ pauschaler Sicherheitszuschlag nicht genuegt.
+ """
+ rng = np.random.default_rng(saat)
+ wochentag = np.arange(tage) %7
+ temperatur = (12+10* np.sin(2* np.pi * np.arange(tage) /365)
++ rng.normal(0, 3, tage))
+ aktion = (rng.random(tage) <0.15).astype(float)
+
+ merkmale = np.column_stack([np.eye(7)[wochentag][:, 1:], temperatur, aktion])
+ erwartung = (120
++ np.eye(7)[wochentag] @ np.array([0, 10, 12, 14, 18, 35, -40])
++1.8* temperatur +45* aktion)
+ streuung =8+22* aktion
+ nachfrage = np.maximum(0.0, erwartung + rng.normal(0, 1, tage) * streuung)
+return merkmale, nachfrage, aktion
+
+
+def tageskosten(bestellung: np.ndarray, nachfrage: np.ndarray) ->float:
+"""Die Zahl, auf die es ankommt - und die kein Prognosemass kennt."""
+ fehlmenge = np.maximum(0.0, nachfrage - bestellung)
+ ueberhang = np.maximum(0.0, bestellung - nachfrage)
+returnfloat((KOSTEN_FEHLMENGE * fehlmenge
++ KOSTEN_UEBERHANG * ueberhang).mean())
+
+
+if__name__=="__main__":
+ merkmale, nachfrage, aktion = erzeuge_daten()
+ lernen =slice(0, TRAINING)
+ pruefen =slice(TRAINING, TAGE)
+
+print("="*84)
+print(" DIE BESSERE PROGNOSE TRIFFT DIE SCHLECHTERE ENTSCHEIDUNG")
+print("="*84)
+print(f"Verkaufspreis {VERKAUFSPREIS:.0f} EUR, Einkauf {EINKAUFSPREIS:.0f} EUR.")
+print(f"Fehlmenge kostet {KOSTEN_FEHLMENGE:.0f} EUR, Ueberhang "
+f"{KOSTEN_UEBERHANG:.0f} EUR je Stueck.")
+print(f"Kritisches Verhaeltnis: {KRITISCHES_VERHAELTNIS:.3f} - der Planer sollte "
+f"also das")
+print(f"{KRITISCHES_VERHAELTNIS:.1%}-Quantil der Nachfrage bestellen, nicht ihren "
+f"Erwartungswert.")
+print(f"\nTraining: Tag 1 bis {TRAINING}. Bewertung: die restlichen "
+f"{TAGE - TRAINING} Tage.\n")
+
+# --- Die vier Verfahren ----------------------------------------------
+ kleinste_quadrate = LinearRegression().fit(merkmale[lernen], nachfrage[lernen])
+ punktprognose = kleinste_quadrate.predict(merkmale[pruefen])
+
+ restfehler = nachfrage[lernen] - kleinste_quadrate.predict(merkmale[lernen])
+ pauschalzuschlag = norm.ppf(KRITISCHES_VERHAELTNIS) * restfehler.std()
+
+# Ein Zuschlag, der nicht aus der Normalverteilung kommt, sondern direkt
+# auf den Trainingsdaten die Kosten minimiert.
+ kandidaten = np.linspace(-10.0, 30.0, 401)
+ trainingsprognose = kleinste_quadrate.predict(merkmale[lernen])
+ kostenzuschlag =float(kandidaten[np.argmin(
+ [tageskosten(trainingsprognose + z, nachfrage[lernen]) for z in kandidaten])])
+
+# Und das Verfahren, das von vornherein das richtige Quantil schaetzt.
+ quantilmodell = QuantileRegressor(quantile=KRITISCHES_VERHAELTNIS,
+ alpha=0.0, solver="highs")
+ quantilmodell.fit(merkmale[lernen], nachfrage[lernen])
+ quantilprognose = quantilmodell.predict(merkmale[pruefen])
+
+ verfahren = [
+ ("bestelle die Punktprognose", punktprognose, punktprognose),
+ ("+ Zuschlag aus der Normalverteilung",
+ punktprognose, punktprognose + pauschalzuschlag),
+ ("+ Zuschlag auf Kosten trainiert",
+ punktprognose, punktprognose + kostenzuschlag),
+ ("Quantilregression aufs kritische Quantil",
+ quantilprognose, quantilprognose),
+ ]
+
+print(f" {'Verfahren':<42}{'MSE':>9}{'Kosten/Tag':>12}{'gegen Zeile 1':>14}")
+print(" "+"-"*80)
+ ergebnisse = {}
+for name, prognose, bestellung in verfahren:
+ mse =float(((prognose - nachfrage[pruefen]) **2).mean())
+ kosten = tageskosten(bestellung, nachfrage[pruefen])
+ ergebnisse[name] = (mse, kosten)
+ basis = ergebnisse[verfahren[0][0]][1]
+ vergleich =""if name == verfahren[0][0] elsef"{(kosten - basis) / basis:+13.1%}"
+print(f" {name:<42}{mse:>9.1f}{kosten:>10.2f} EUR {vergleich:>14}")
+
+ bester_mse =min(ergebnisse, key=lambda k: ergebnisse[k][0])
+ beste_kosten =min(ergebnisse, key=lambda k: ergebnisse[k][1])
+print(f"\n bester MSE: {bester_mse}")
+print(f" beste Kosten: {beste_kosten}")
+print(f"\n Das Verfahren mit dem besten MSE hat die HOECHSTEN Kosten, und das")
+print(f" Verfahren mit den besten Kosten hat einen um "
+f"{(ergebnisse[beste_kosten][0] / ergebnisse[bester_mse][0] -1):.0%} SCHLECHTEREN MSE.")
+print(" Wer Prognosemodelle nach MSE auswaehlt, waehlt hier das falsche.")
+
+# --- Warum der pauschale Zuschlag zu kurz greift ---------------------
+print("\n"+"-"*84)
+print("Warum ein pauschaler Zuschlag nicht genuegt\n")
+ ist_aktion = aktion[pruefen] >0.5
+print(f" {'Verfahren':<42}{'normale Tage':>14}{'Aktionstage':>14}")
+print(" "+"-"*74)
+for name, _, bestellung in verfahren:
+ normal = tageskosten(bestellung[~ist_aktion], nachfrage[pruefen][~ist_aktion])
+ aktionstag = tageskosten(bestellung[ist_aktion], nachfrage[pruefen][ist_aktion])
+print(f" {name:<42}{normal:>10.2f} EUR {aktionstag:>10.2f} EUR")
+
+# Nachgerechnet statt behauptet: Welcher Zuschlag waere je Tagesart richtig?
+ aktion_training = aktion[lernen] >0.5
+ z = norm.ppf(KRITISCHES_VERHAELTNIS)
+ richtig_normal = z * restfehler[~aktion_training].std()
+ richtig_aktion = z * restfehler[aktion_training].std()
+print(f"\n Der pauschale Zuschlag betraegt {pauschalzuschlag:.1f} Stueck. Aus den")
+print(f" Trainingsresten getrennt nach Tagesart waere richtig:")
+print(f" normale Tage : {richtig_normal:5.1f} Stueck")
+print(f" Aktionstage : {richtig_aktion:5.1f} Stueck")
+print(f" Ein Zuschlag fuer alle Tage kann nur einen Mittelweg treffen - hier")
+print(f" ist er an normalen Tagen {pauschalzuschlag / richtig_normal:.1f}-mal zu gross und an")
+print(f" Aktionstagen nur {pauschalzuschlag / richtig_aktion:.0%} dessen, was noetig waere.")
+print(f"\n Die Quantilregression schaetzt das {KRITISCHES_VERHAELTNIS:.1%}-Quantil "
+f"direkt aus den")
+print(f" Merkmalen und darf deshalb an verschiedenen Tagen verschieden weit")
+print(f" ueber dem Erwartungswert liegen. Genau das ist der Unterschied")
+print(f" zwischen 'ein Modell und danach eine Formel' und 'ein Modell, das")
+print(f" weiss, wofuer es gebraucht wird'.")
+
+# --- Die Messfalle ---------------------------------------------------
+print("\n"+"-"*84)
+print("Eine Messfalle, in die der Autor zuerst selbst getappt ist\n")
+print(" Der erste Entwurf dieses Programms bewertete auf 230 Testtagen - ein")
+print(" realistischer Zeitraum. Dort hatte die Quantilregression den BESSEREN")
+print(" MSE, und die ganze Aussage des Kapitels stand auf dem Kopf.")
+print("\n Wie oft das passiert, laesst sich ausmessen:\n")
+ rng = np.random.default_rng(0)
+print(f" {'Testfenster':>14}{'QR sieht MSE-besser aus':>26}")
+print(" "+"-"*42)
+for fenster in (180, 365, 730, 2000):
+ treffer =0
+ versuche =400
+for _ inrange(versuche):
+ start =int(rng.integers(TRAINING, TAGE - fenster))
+ ausschnitt =slice(start, start + fenster)
+ mse_punkt = ((kleinste_quadrate.predict(merkmale[ausschnitt])
+- nachfrage[ausschnitt]) **2).mean()
+ mse_quantil = ((quantilmodell.predict(merkmale[ausschnitt])
+- nachfrage[ausschnitt]) **2).mean()
+ treffer += mse_quantil < mse_punkt
+print(f" {fenster:>10} Tage {treffer / versuche:>24.1%}")
+
+print("\n Bei einem halben Jahr Testdaten sieht das schlechtere Modell in gut")
+print(" jedem zehnten Fall besser aus. Das ist keine grosse Zahl - aber wer")
+print(" EINMAL misst, hat genau eine Ziehung aus dieser Verteilung.")
+print("\n Die Lehre ist nicht 'nimm 4.000 Testtage' - die hat niemand. Sie")
+print(" lautet: Ein Kennzahlenvergleich ohne Angabe seiner Streuung ist keine")
+print(" Aussage. Bei kurzen Zeitraeumen gehoert eine Kreuzvalidierung dazu.")
+
+print("\n"+"="*84)
+print(" WAS MAN DARAUS MITNIMMT")
+print("="*84)
+print("Der Prognostiker optimiert den MSE, der Planer traegt die Kosten - und")
+print("die beiden Masse zeigen hier in verschiedene Richtungen. Drei Saetze:")
+print()
+print(" 1. Sagen Sie nicht den Erwartungswert vorher, sondern die Groesse, die")
+print(" in die Entscheidung eingeht. Beim Newsvendor ist das das kritische")
+print(" Quantil - und das kann man direkt schaetzen.")
+print(" 2. Bewerten Sie Prognosemodelle an den ENTSCHEIDUNGSKOSTEN. Die sind")
+print(" in Euro und damit vergleichbar; ein MSE ist es nicht.")
+print(" 3. Ein pauschaler Sicherheitszuschlag ist besser als nichts und")
+print(" schlechter als ein Modell, das die Unsicherheit selbst aus den")
+print(" Merkmalen liest.")
+print()
+print("Der naechste Schritt - Prognosemodelle so zu trainieren, dass sie die")
+print("Entscheidungskosten direkt minimieren (Smart Predict-then-Optimize,")
+print("differenzierbare Optimierungsschichten) - ist Forschungsstand und")
+print("erfordert Bibliotheken wie cvxpylayers. Die dritte Zeile der Tabelle")
+print("oben ist seine einfachste denkbare Form: ein einziger Parameter, auf")
+print("Kosten statt auf Fehler trainiert.")
+print("="*84)
Erwartete Ausgabe:
====================================================================================
DIE BESSERE PROGNOSE TRIFFT DIE SCHLECHTERE ENTSCHEIDUNG
@@ -19432,22 +19800,22 @@ Kosten statt auf Fehler trainiert.
Lesen Sie die Tabelle noch einmal — und ersetzen Sie „Kapital” durch „Montagestunden” und „Risikobudget” durch „Plattenmaterial”. Es ist die Schreinerei aus Kapitel 1, Wort für Wort.
@@ -19469,281 +19837,281 @@ Kosten statt auf Fehler trainiert.
16.3 Das Programm
-
#!/usr/bin/env python3
-
-# Strukturbruecke.py
-"""
-Kapitel Bruecke: Derselbe Code, zwei Welten - der Beweis statt der Behauptung.
-
-Der Teil-Auftakt stellt eine Tabelle auf: "Ressourcen auf Produkte verteilen"
-entspreche "Kapital auf Anlagen verteilen", "gegen den Worst Case absichern"
-entspreche "Absicherung gegen Kursabstuerze". Solche Tabellen stehen in vielen
-Buechern. Sie sind billig - und man kann sie pruefen.
-
-Dieses Programm prueft sie. Es fuettert zweimal DENSELBEN Code mit Daten aus
-zwei Welten und zeigt die Ergebnisse nebeneinander:
-
- 1. Die Allokation als LP. Das Domaenenmodell aus or_kern.py, einmal mit einer
- Schreinerei (Montagestunden, Plattenmaterial) und einmal mit einem Depot
- (Kapital, Risikobudget). Gleiche Klasse, gleicher Modellbauer, gleiche
- Abnahmepruefung - und der Schattenpreis heisst in der einen Welt
- "Wert einer zusaetzlichen Montagestunde" und in der anderen "Preis des
- Risikos".
- 2. Die Absicherung gegen den schlechtesten Fall als CVaR. EINE Funktion,
- einmal mit Kursrenditen und einmal mit Lieferverzuegen.
- 3. Was NICHT hinueberreicht. Der ehrliche Teil: Drei Unterschiede, die die
- Analogie begrenzen - und die man kennen muss, bevor man Methoden aus
- der einen Welt in die andere traegt.
-
-ZUR SOLVERWAHL: Teil 1 benutzt 'loese_mit_scipy' und nicht 'loese_mit_glop'.
-Das ist kein Zufall - CVXPY laedt fuer Teil 2 highspy, und ortools vertraegt
-sich damit nicht im selben Prozess (Kapitel Oekosystem). Wer hier GLOP nimmt,
-bekommt beim cvxpy-Import eine Fehlermeldung ueber ein 'undefined symbol'.
-Genau deshalb laedt or_kern.py seine Solver erst beim Aufruf.
-
-Benoetigt: numpy, cvxpy, scipy und pydantic (ueber or_kern)
-"""
-
-from __future__ import annotations
-
-import numpy as np
-
-from or_kern import (Produkt, Produktionsproblem, loese_mit_scipy,
- pruefe_loesung)
-
-SAAT =7
-SZENARIEN =500
-ALPHA =0.95# die schlechtesten 5 % der Faelle
-MAX_ANTEIL =0.40# Streuungsgebot in beiden Welten
-
-
-# --- Teil 1: Dieselbe Klasse, zwei Welten ----------------------------------
-
-def schreinerei() -> Produktionsproblem:
-"""Montagestunden und Plattenmaterial auf Tische und Stuehle verteilen."""
-return Produktionsproblem(
- produkte=[
- Produkt(name="Tisch", deckungsbeitrag=240.0,
- verbrauch={"Montagestunden": 3.0, "Plattenmaterial": 6.0}),
- Produkt(name="Stuhl", deckungsbeitrag=60.0,
- verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0}),
- Produkt(name="Regal", deckungsbeitrag=130.0,
- verbrauch={"Montagestunden": 2.0, "Plattenmaterial": 4.0}),
- ],
- kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0})
-
-
-def depot() -> Produktionsproblem:
-"""Kapital und Risikobudget auf Anlageklassen verteilen.
-
- Eine "Einheit" ist hier 1.000 EUR Anlagesumme. Der Deckungsbeitrag ist der
- erwartete Jahresertrag dieser Einheit, der Verbrauch die beanspruchte
- Kapital- und Risikomenge. Das ist keine Analogie, sondern buchstaeblich
- dasselbe Modell - deshalb passt es in dieselbe Klasse.
- """
-return Produktionsproblem(
- produkte=[
- Produkt(name="Aktien Welt", deckungsbeitrag=75.0,
- verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 1.00}),
- Produkt(name="Anleihen", deckungsbeitrag=28.0,
- verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 0.22}),
- Produkt(name="Immobilienfonds", deckungsbeitrag=46.0,
- verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 0.55}),
- ],
- kapazitaeten={"Kapital (Tsd. EUR)": 150.0, "Risikobudget": 90.0})
-
-
-def berichte_allokation(titel: str, problem: Produktionsproblem,
- einheit: str, ertragsname: str) ->None:
-"""Ein Bericht fuer beide Welten - nur die Beschriftung wechselt."""
- loesung = loese_mit_scipy(problem)
- beanstandungen = pruefe_loesung(problem, loesung)
-
-print(f" {titel}")
-print(f" {loesung.als_bericht()}")
-for produkt in problem.produkte:
-print(f" {produkt.name:<18}{loesung.werte[produkt.name]:8.2f}{einheit}")
-print(f" {ertragsname:<18}{loesung.zielwert:8.2f} EUR")
-for ressource, preis in loesung.schattenpreise.items():
-print(f" Schattenpreis {ressource:<24}{preis:7.2f} EUR")
-print(f" Abnahmepruefung: "
-f"{'bestanden'ifnot beanstandungen else beanstandungen}")
-
-
-# --- Teil 2: Eine CVaR-Funktion, zwei Welten -------------------------------
-
-def optimiere_cvar(verluste: np.ndarray, ertrag: np.ndarray,
- mindestertrag: float, alpha: float= ALPHA,
- max_anteil: float= MAX_ANTEIL):
-"""Minimiert den CVaR der Verluste unter einer Mindestertragsbedingung.
-
- 'verluste' hat die Form (Szenarien x Optionen) und enthaelt, was in
- Szenario s passiert, wenn eine Einheit in Option j steckt. Was ein
- "Verlust" ist, entscheidet allein die Einheit der Matrix: Prozentpunkte
- Kursverlust oder Tage Lieferverzug - die Formel sieht keinen Unterschied.
-
- Das ist die Rockafellar-Uryasev-Formulierung aus dem Kapitel CVaR, hier
- ohne jede Aenderung wiederverwendet.
- """
-import cvxpy as cp
-
- anzahl_szenarien, anzahl_optionen = verluste.shape
- anteil = cp.Variable(anzahl_optionen, nonneg=True)
- schwelle = cp.Variable() # wird im Optimum zum VaR
- ueberschuss = cp.Variable(anzahl_szenarien, nonneg=True)
-
- cvar = schwelle + (1.0/ (anzahl_szenarien * (1- alpha))) * cp.sum(ueberschuss)
- problem = cp.Problem(
- cp.Minimize(cvar),
- [ueberschuss >= verluste @ anteil - schwelle,
- cp.sum(anteil) ==1,
- anteil <= max_anteil,
- ertrag @ anteil >= mindestertrag])
- problem.solve()
-if problem.status notin ("optimal", "optimal_inaccurate"):
-raiseSystemExit(f"CVaR-Problem nicht loesbar: {problem.status}")
-return anteil.value, float(cvar.value), float(schwelle.value)
-
-
-def kursszenarien(rng) ->tuple[np.ndarray, np.ndarray, list[str]]:
-"""Taegliche Verluste (negative Renditen) von sechs Anlageklassen."""
- namen = ["Aktien Welt", "Aktien EU", "Schwellenlaender",
-"Staatsanleihen", "Unternehmensanl.", "Rohstoffe"]
- rendite_pa = np.array([0.080, 0.065, 0.110, 0.025, 0.045, 0.070])
- schwankung = np.array([0.180, 0.160, 0.260, 0.040, 0.075, 0.210])
- taeglich = rng.normal(rendite_pa /252, schwankung / np.sqrt(252),
- (SZENARIEN, len(namen)))
-return-taeglich *100.0, rendite_pa *100.0, namen
-
-
-def lieferszenarien(rng) ->tuple[np.ndarray, np.ndarray, list[str]]:
-"""Lieferverzug in Tagen bei sechs Lieferanten.
-
- Der Aufbau ist bewusst anders als bei den Kursen: Hier gibt es einen
- normalen Verzug UND seltene Totalausfaelle, die 14 Tage kosten. Das ist
- genau die Art fetter Raender, wegen der man in beiden Welten CVaR statt
- Standardabweichung benutzt.
- """
- namen = ["Nordwerk", "Sued-Metall", "Fernost A", "Lokalzulieferer",
-"Fernost B", "Osteuropa"]
- zuverlaessigkeit = np.array([0.94, 0.97, 0.90, 0.99, 0.92, 0.95])
- verzugsstreuung = np.array([3.5, 1.8, 5.0, 0.9, 4.2, 2.8])
- normaler_verzug = np.maximum(0.0, rng.normal(0.5, 1.0, (SZENARIEN, len(namen)))
-* verzugsstreuung)
- totalausfall = (rng.random((SZENARIEN, len(namen))) > zuverlaessigkeit) *14.0
-return normaler_verzug + totalausfall, zuverlaessigkeit, namen
-
-
-def berichte_cvar(titel: str, verluste, ertrag, namen, mindestertrag,
- einheit: str, ertragsname: str):
- anteile, cvar, var = optimiere_cvar(verluste, ertrag, mindestertrag)
- mittel =float((verluste @ anteile).mean())
-print(f" {titel}")
-print(f" VaR {ALPHA:.0%}: {var:8.3f}{einheit}")
-print(f" CVaR {ALPHA:.0%}: {cvar:8.3f}{einheit} "
-f"(Mittelwert ueber alle Szenarien: {mittel:.3f})")
-print(f" {ertragsname}: {float(ertrag @ anteile):.3f}")
-print(" Aufteilung:")
-for name, anteil inzip(namen, anteile):
- balken ="#"*int(round(anteil *40))
- grenze =" <- an der Streuungsgrenze"if anteil > MAX_ANTEIL -1e-4else""
-print(f" {name:<18}{anteil:6.1%}{balken}{grenze}")
-return anteile
-
-
-if__name__=="__main__":
- rng = np.random.default_rng(SAAT)
-
-print("="*80)
-print(" DIESELBE STRUKTUR, ZWEI WELTEN")
-print("="*80)
-
-# --- Teil 1 ----------------------------------------------------------
-print("\n1. Allokation als LP - EINE Klasse, EIN Modellbauer\n")
- berichte_allokation("Werkstatt: Produktionsprogramm", schreinerei(),
-"Stueck", "Deckungsbeitrag")
-print()
- berichte_allokation("Depot: Anlageaufteilung", depot(),
-"Tsd. ", "Erwarteter Ertrag")
-
-print("\n Beide Ausgaben stammen aus derselben Funktion "
-"'berichte_allokation'.")
-print(" Ausgetauscht wurden nur die Daten und die Beschriftungen -")
-print(" keine Zeile Modellcode.")
-print()
-print(" Lesen Sie die Schattenpreise nebeneinander: In der Werkstatt sagt")
-print(" er, was eine zusaetzliche Montagestunde wert waere. Im Depot sagt")
-print(" dieselbe Zahl, was eine zusaetzliche Einheit Risikobudget wert")
-print(" waere - der PREIS DES RISIKOS. Das ist kein Sprachbild, sondern")
-print(" derselbe Dualwert derselben Nebenbedingung.")
-
-# --- Teil 2 ----------------------------------------------------------
-print("\n"+"-"*80)
-print("2. Absicherung gegen den schlechtesten Fall - EINE CVaR-Funktion\n")
-
- kurse, rendite, anlagen = kursszenarien(rng)
- anteile_depot = berichte_cvar(
-"Depot: die schlechtesten 5 % der Handelstage",
- kurse, rendite, anlagen, 5.5,
-"% je Tag ", "Erwartete Jahresrendite (%)")
-print()
- lieferung, zuverlaessig, lieferanten = lieferszenarien(rng)
- anteile_einkauf = berichte_cvar("Einkauf: die schlechtesten 5 % der Bestellungen",
- lieferung, zuverlaessig, lieferanten, 0.945,
-"Tage Verzug ", "Mittlere Zuverlaessigkeit")
-
-print("\n Auch hier: eine Funktion, zwei Aufrufe. Die Zielfunktion")
-print(" interessiert sich nicht dafuer, ob in der Matrix Prozentpunkte")
-print(" oder Tage stehen.")
-print()
-print(f" Interessant ist, WO die Streuungsgrenze von {MAX_ANTEIL:.0%} bindet:")
-print(f" Depot : {(anteile_depot > MAX_ANTEIL -1e-4).sum()} von "
-f"{len(anteile_depot)} Posten am Anschlag")
-print(f" Einkauf : {(anteile_einkauf > MAX_ANTEIL -1e-4).sum()} von "
-f"{len(anteile_einkauf)} Posten am Anschlag")
-print()
-print(" Im Einkauf zieht es die Loesung an den sicheren Lokalzulieferer,")
-print(" bis die Grenze sie stoppt. Im Depot nicht - dort verhindert die")
-print(" Mindestrendite, dass alles in Anleihen wandert. Zwei verschiedene")
-print(" Bremsen also, und sie stehen an verschiedenen Stellen des Modells:")
-print(" einmal in einer Nebenbedingung ueber die Anteile, einmal in einer")
-print(" ueber den Ertrag. Wer eine Struktur uebertraegt, uebertraegt eben")
-print(" nicht automatisch mit, WELCHE Bedingung am Ende bindet.")
-
-# --- Teil 3: Der ehrliche Teil ---------------------------------------
-print("\n"+"="*80)
-print(" WAS NICHT HINUEBERREICHT")
-print("="*80)
-print("Die Struktur traegt. Drei Unterschiede tragen NICHT mit, und wer sie")
-print("uebersieht, macht aus einer nuetzlichen Analogie einen Fehler:")
-print()
-print("1. WOHER DIE ZAHLEN KOMMEN.")
-print(" In der Werkstatt ist der Verbrauch je Tisch gemessen - drei")
-print(" Montagestunden sind drei Montagestunden. Im Depot ist die")
-print(" erwartete Rendite GESCHAETZT, und zwar mit einem Fehler, der")
-print(" groesser sein kann als die Unterschiede zwischen den Anlagen")
-print(" (Kapitel Markowitz, Renditeschaetzung_Falle.py). Dieselbe")
-print(" Optimierung ist im einen Fall Planung und im anderen")
-print(" Fehlerverstaerkung.")
-print()
-print("2. OB DIE VERGANGENHEIT ETWAS UEBER DIE ZUKUNFT SAGT.")
-print(" Lieferzeiten haben physikalische Ursachen: Entfernung, Zoll,")
-print(" Kapazitaet. Sie aendern sich langsam und nachvollziehbar.")
-print(" Kursrenditen entstehen aus dem Verhalten von Marktteilnehmern,")
-print(" die selbst auf Modelle reagieren - dort verschwindet ein")
-print(" erkanntes Muster oft genau deshalb, weil es erkannt wurde")
-print(" (Kapitel Handelsmaschine, Data_Snooping.py).")
-print()
-print("3. OB TEILBARKEIT ERLAUBT IST.")
-print(" 37,4 % eines Aktienfonds sind ein normaler Auftrag. 37,4 % eines")
-print(" Lieferanten sind es nicht - Vertraege, Mindestabnahmen und")
-print(" Ruestzeiten machen Einkaufsentscheidungen ganzzahlig. Genau")
-print(" deshalb ist Teil II voller MILP und Teil V fast frei davon.")
-print()
-print("Die Bruecke traegt also die MODELLE, nicht die Annahmen. Wer sie")
-print("benutzt, spart sich das Lernen der Methoden - nicht das Nachdenken")
-print("ueber die Daten.")
-print("="*80)
+
#!/usr/bin/env python3
+
+# Strukturbruecke.py
+"""
+Kapitel Bruecke: Derselbe Code, zwei Welten - der Beweis statt der Behauptung.
+
+Der Teil-Auftakt stellt eine Tabelle auf: "Ressourcen auf Produkte verteilen"
+entspreche "Kapital auf Anlagen verteilen", "gegen den Worst Case absichern"
+entspreche "Absicherung gegen Kursabstuerze". Solche Tabellen stehen in vielen
+Buechern. Sie sind billig - und man kann sie pruefen.
+
+Dieses Programm prueft sie. Es fuettert zweimal DENSELBEN Code mit Daten aus
+zwei Welten und zeigt die Ergebnisse nebeneinander:
+
+ 1. Die Allokation als LP. Das Domaenenmodell aus or_kern.py, einmal mit einer
+ Schreinerei (Montagestunden, Plattenmaterial) und einmal mit einem Depot
+ (Kapital, Risikobudget). Gleiche Klasse, gleicher Modellbauer, gleiche
+ Abnahmepruefung - und der Schattenpreis heisst in der einen Welt
+ "Wert einer zusaetzlichen Montagestunde" und in der anderen "Preis des
+ Risikos".
+ 2. Die Absicherung gegen den schlechtesten Fall als CVaR. EINE Funktion,
+ einmal mit Kursrenditen und einmal mit Lieferverzuegen.
+ 3. Was NICHT hinueberreicht. Der ehrliche Teil: Drei Unterschiede, die die
+ Analogie begrenzen - und die man kennen muss, bevor man Methoden aus
+ der einen Welt in die andere traegt.
+
+ZUR SOLVERWAHL: Teil 1 benutzt 'loese_mit_scipy' und nicht 'loese_mit_glop'.
+Das ist kein Zufall - CVXPY laedt fuer Teil 2 highspy, und ortools vertraegt
+sich damit nicht im selben Prozess (Kapitel Oekosystem). Wer hier GLOP nimmt,
+bekommt beim cvxpy-Import eine Fehlermeldung ueber ein 'undefined symbol'.
+Genau deshalb laedt or_kern.py seine Solver erst beim Aufruf.
+
+Benoetigt: numpy, cvxpy, scipy und pydantic (ueber or_kern)
+"""
+
+from __future__ import annotations
+
+import numpy as np
+
+from or_kern import (Produkt, Produktionsproblem, loese_mit_scipy,
+ pruefe_loesung)
+
+SAAT =7
+SZENARIEN =500
+ALPHA =0.95# die schlechtesten 5 % der Faelle
+MAX_ANTEIL =0.40# Streuungsgebot in beiden Welten
+
+
+# --- Teil 1: Dieselbe Klasse, zwei Welten ----------------------------------
+
+def schreinerei() -> Produktionsproblem:
+"""Montagestunden und Plattenmaterial auf Tische und Stuehle verteilen."""
+return Produktionsproblem(
+ produkte=[
+ Produkt(name="Tisch", deckungsbeitrag=240.0,
+ verbrauch={"Montagestunden": 3.0, "Plattenmaterial": 6.0}),
+ Produkt(name="Stuhl", deckungsbeitrag=60.0,
+ verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0}),
+ Produkt(name="Regal", deckungsbeitrag=130.0,
+ verbrauch={"Montagestunden": 2.0, "Plattenmaterial": 4.0}),
+ ],
+ kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0})
+
+
+def depot() -> Produktionsproblem:
+"""Kapital und Risikobudget auf Anlageklassen verteilen.
+
+ Eine "Einheit" ist hier 1.000 EUR Anlagesumme. Der Deckungsbeitrag ist der
+ erwartete Jahresertrag dieser Einheit, der Verbrauch die beanspruchte
+ Kapital- und Risikomenge. Das ist keine Analogie, sondern buchstaeblich
+ dasselbe Modell - deshalb passt es in dieselbe Klasse.
+ """
+return Produktionsproblem(
+ produkte=[
+ Produkt(name="Aktien Welt", deckungsbeitrag=75.0,
+ verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 1.00}),
+ Produkt(name="Anleihen", deckungsbeitrag=28.0,
+ verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 0.22}),
+ Produkt(name="Immobilienfonds", deckungsbeitrag=46.0,
+ verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 0.55}),
+ ],
+ kapazitaeten={"Kapital (Tsd. EUR)": 150.0, "Risikobudget": 90.0})
+
+
+def berichte_allokation(titel: str, problem: Produktionsproblem,
+ einheit: str, ertragsname: str) ->None:
+"""Ein Bericht fuer beide Welten - nur die Beschriftung wechselt."""
+ loesung = loese_mit_scipy(problem)
+ beanstandungen = pruefe_loesung(problem, loesung)
+
+print(f" {titel}")
+print(f" {loesung.als_bericht()}")
+for produkt in problem.produkte:
+print(f" {produkt.name:<18}{loesung.werte[produkt.name]:8.2f}{einheit}")
+print(f" {ertragsname:<18}{loesung.zielwert:8.2f} EUR")
+for ressource, preis in loesung.schattenpreise.items():
+print(f" Schattenpreis {ressource:<24}{preis:7.2f} EUR")
+print(f" Abnahmepruefung: "
+f"{'bestanden'ifnot beanstandungen else beanstandungen}")
+
+
+# --- Teil 2: Eine CVaR-Funktion, zwei Welten -------------------------------
+
+def optimiere_cvar(verluste: np.ndarray, ertrag: np.ndarray,
+ mindestertrag: float, alpha: float= ALPHA,
+ max_anteil: float= MAX_ANTEIL):
+"""Minimiert den CVaR der Verluste unter einer Mindestertragsbedingung.
+
+ 'verluste' hat die Form (Szenarien x Optionen) und enthaelt, was in
+ Szenario s passiert, wenn eine Einheit in Option j steckt. Was ein
+ "Verlust" ist, entscheidet allein die Einheit der Matrix: Prozentpunkte
+ Kursverlust oder Tage Lieferverzug - die Formel sieht keinen Unterschied.
+
+ Das ist die Rockafellar-Uryasev-Formulierung aus dem Kapitel CVaR, hier
+ ohne jede Aenderung wiederverwendet.
+ """
+import cvxpy as cp
+
+ anzahl_szenarien, anzahl_optionen = verluste.shape
+ anteil = cp.Variable(anzahl_optionen, nonneg=True)
+ schwelle = cp.Variable() # wird im Optimum zum VaR
+ ueberschuss = cp.Variable(anzahl_szenarien, nonneg=True)
+
+ cvar = schwelle + (1.0/ (anzahl_szenarien * (1- alpha))) * cp.sum(ueberschuss)
+ problem = cp.Problem(
+ cp.Minimize(cvar),
+ [ueberschuss >= verluste @ anteil - schwelle,
+ cp.sum(anteil) ==1,
+ anteil <= max_anteil,
+ ertrag @ anteil >= mindestertrag])
+ problem.solve()
+if problem.status notin ("optimal", "optimal_inaccurate"):
+raiseSystemExit(f"CVaR-Problem nicht loesbar: {problem.status}")
+return anteil.value, float(cvar.value), float(schwelle.value)
+
+
+def kursszenarien(rng) ->tuple[np.ndarray, np.ndarray, list[str]]:
+"""Taegliche Verluste (negative Renditen) von sechs Anlageklassen."""
+ namen = ["Aktien Welt", "Aktien EU", "Schwellenlaender",
+"Staatsanleihen", "Unternehmensanl.", "Rohstoffe"]
+ rendite_pa = np.array([0.080, 0.065, 0.110, 0.025, 0.045, 0.070])
+ schwankung = np.array([0.180, 0.160, 0.260, 0.040, 0.075, 0.210])
+ taeglich = rng.normal(rendite_pa /252, schwankung / np.sqrt(252),
+ (SZENARIEN, len(namen)))
+return-taeglich *100.0, rendite_pa *100.0, namen
+
+
+def lieferszenarien(rng) ->tuple[np.ndarray, np.ndarray, list[str]]:
+"""Lieferverzug in Tagen bei sechs Lieferanten.
+
+ Der Aufbau ist bewusst anders als bei den Kursen: Hier gibt es einen
+ normalen Verzug UND seltene Totalausfaelle, die 14 Tage kosten. Das ist
+ genau die Art fetter Raender, wegen der man in beiden Welten CVaR statt
+ Standardabweichung benutzt.
+ """
+ namen = ["Nordwerk", "Sued-Metall", "Fernost A", "Lokalzulieferer",
+"Fernost B", "Osteuropa"]
+ zuverlaessigkeit = np.array([0.94, 0.97, 0.90, 0.99, 0.92, 0.95])
+ verzugsstreuung = np.array([3.5, 1.8, 5.0, 0.9, 4.2, 2.8])
+ normaler_verzug = np.maximum(0.0, rng.normal(0.5, 1.0, (SZENARIEN, len(namen)))
+* verzugsstreuung)
+ totalausfall = (rng.random((SZENARIEN, len(namen))) > zuverlaessigkeit) *14.0
+return normaler_verzug + totalausfall, zuverlaessigkeit, namen
+
+
+def berichte_cvar(titel: str, verluste, ertrag, namen, mindestertrag,
+ einheit: str, ertragsname: str):
+ anteile, cvar, var = optimiere_cvar(verluste, ertrag, mindestertrag)
+ mittel =float((verluste @ anteile).mean())
+print(f" {titel}")
+print(f" VaR {ALPHA:.0%}: {var:8.3f}{einheit}")
+print(f" CVaR {ALPHA:.0%}: {cvar:8.3f}{einheit} "
+f"(Mittelwert ueber alle Szenarien: {mittel:.3f})")
+print(f" {ertragsname}: {float(ertrag @ anteile):.3f}")
+print(" Aufteilung:")
+for name, anteil inzip(namen, anteile):
+ balken ="#"*int(round(anteil *40))
+ grenze =" <- an der Streuungsgrenze"if anteil > MAX_ANTEIL -1e-4else""
+print(f" {name:<18}{anteil:6.1%}{balken}{grenze}")
+return anteile
+
+
+if__name__=="__main__":
+ rng = np.random.default_rng(SAAT)
+
+print("="*80)
+print(" DIESELBE STRUKTUR, ZWEI WELTEN")
+print("="*80)
+
+# --- Teil 1 ----------------------------------------------------------
+print("\n1. Allokation als LP - EINE Klasse, EIN Modellbauer\n")
+ berichte_allokation("Werkstatt: Produktionsprogramm", schreinerei(),
+"Stueck", "Deckungsbeitrag")
+print()
+ berichte_allokation("Depot: Anlageaufteilung", depot(),
+"Tsd. ", "Erwarteter Ertrag")
+
+print("\n Beide Ausgaben stammen aus derselben Funktion "
+"'berichte_allokation'.")
+print(" Ausgetauscht wurden nur die Daten und die Beschriftungen -")
+print(" keine Zeile Modellcode.")
+print()
+print(" Lesen Sie die Schattenpreise nebeneinander: In der Werkstatt sagt")
+print(" er, was eine zusaetzliche Montagestunde wert waere. Im Depot sagt")
+print(" dieselbe Zahl, was eine zusaetzliche Einheit Risikobudget wert")
+print(" waere - der PREIS DES RISIKOS. Das ist kein Sprachbild, sondern")
+print(" derselbe Dualwert derselben Nebenbedingung.")
+
+# --- Teil 2 ----------------------------------------------------------
+print("\n"+"-"*80)
+print("2. Absicherung gegen den schlechtesten Fall - EINE CVaR-Funktion\n")
+
+ kurse, rendite, anlagen = kursszenarien(rng)
+ anteile_depot = berichte_cvar(
+"Depot: die schlechtesten 5 % der Handelstage",
+ kurse, rendite, anlagen, 5.5,
+"% je Tag ", "Erwartete Jahresrendite (%)")
+print()
+ lieferung, zuverlaessig, lieferanten = lieferszenarien(rng)
+ anteile_einkauf = berichte_cvar("Einkauf: die schlechtesten 5 % der Bestellungen",
+ lieferung, zuverlaessig, lieferanten, 0.945,
+"Tage Verzug ", "Mittlere Zuverlaessigkeit")
+
+print("\n Auch hier: eine Funktion, zwei Aufrufe. Die Zielfunktion")
+print(" interessiert sich nicht dafuer, ob in der Matrix Prozentpunkte")
+print(" oder Tage stehen.")
+print()
+print(f" Interessant ist, WO die Streuungsgrenze von {MAX_ANTEIL:.0%} bindet:")
+print(f" Depot : {(anteile_depot > MAX_ANTEIL -1e-4).sum()} von "
+f"{len(anteile_depot)} Posten am Anschlag")
+print(f" Einkauf : {(anteile_einkauf > MAX_ANTEIL -1e-4).sum()} von "
+f"{len(anteile_einkauf)} Posten am Anschlag")
+print()
+print(" Im Einkauf zieht es die Loesung an den sicheren Lokalzulieferer,")
+print(" bis die Grenze sie stoppt. Im Depot nicht - dort verhindert die")
+print(" Mindestrendite, dass alles in Anleihen wandert. Zwei verschiedene")
+print(" Bremsen also, und sie stehen an verschiedenen Stellen des Modells:")
+print(" einmal in einer Nebenbedingung ueber die Anteile, einmal in einer")
+print(" ueber den Ertrag. Wer eine Struktur uebertraegt, uebertraegt eben")
+print(" nicht automatisch mit, WELCHE Bedingung am Ende bindet.")
+
+# --- Teil 3: Der ehrliche Teil ---------------------------------------
+print("\n"+"="*80)
+print(" WAS NICHT HINUEBERREICHT")
+print("="*80)
+print("Die Struktur traegt. Drei Unterschiede tragen NICHT mit, und wer sie")
+print("uebersieht, macht aus einer nuetzlichen Analogie einen Fehler:")
+print()
+print("1. WOHER DIE ZAHLEN KOMMEN.")
+print(" In der Werkstatt ist der Verbrauch je Tisch gemessen - drei")
+print(" Montagestunden sind drei Montagestunden. Im Depot ist die")
+print(" erwartete Rendite GESCHAETZT, und zwar mit einem Fehler, der")
+print(" groesser sein kann als die Unterschiede zwischen den Anlagen")
+print(" (Kapitel Markowitz, Renditeschaetzung_Falle.py). Dieselbe")
+print(" Optimierung ist im einen Fall Planung und im anderen")
+print(" Fehlerverstaerkung.")
+print()
+print("2. OB DIE VERGANGENHEIT ETWAS UEBER DIE ZUKUNFT SAGT.")
+print(" Lieferzeiten haben physikalische Ursachen: Entfernung, Zoll,")
+print(" Kapazitaet. Sie aendern sich langsam und nachvollziehbar.")
+print(" Kursrenditen entstehen aus dem Verhalten von Marktteilnehmern,")
+print(" die selbst auf Modelle reagieren - dort verschwindet ein")
+print(" erkanntes Muster oft genau deshalb, weil es erkannt wurde")
+print(" (Kapitel Handelsmaschine, Data_Snooping.py).")
+print()
+print("3. OB TEILBARKEIT ERLAUBT IST.")
+print(" 37,4 % eines Aktienfonds sind ein normaler Auftrag. 37,4 % eines")
+print(" Lieferanten sind es nicht - Vertraege, Mindestabnahmen und")
+print(" Ruestzeiten machen Einkaufsentscheidungen ganzzahlig. Genau")
+print(" deshalb ist Teil II voller MILP und Teil V fast frei davon.")
+print()
+print("Die Bruecke traegt also die MODELLE, nicht die Annahmen. Wer sie")
+print("benutzt, spart sich das Lernen der Methoden - nicht das Nachdenken")
+print("ueber die Daten.")
+print("="*80)
Erwartete Ausgabe:
================================================================================
DIESELBE STRUKTUR, ZWEI WELTEN
@@ -20066,23 +20434,23 @@ u_s \ge \ell_s^\top w - \gamma, \quad u_s \ge 0
🚀 In 5 Minuten gelöst: Warum „billigstes Kraftwerk zuerst” falsch ist
Zwei Kraftwerke können 120 MW liefern. Der Kernblock produziert für 22 €/MWh, die Gasturbine für 105 € — fünfmal so teuer. Die Merit-Order-Regel sagt: Kernblock.
Sie übersieht die Anfahrkosten: 40 000 € gegen 1 500 €.
-
# Zwei Kraftwerke, die 120 MW decken sollen - aber wie lange?
-# Grenzkosten Anfahrkosten
-kernblock =dict(name="Kernblock", grenz=22, anfahrt=40_000)
-gasturbine =dict(name="Gasturbine", grenz=105, anfahrt=1_500)
-LEISTUNG =120# MW, die gebraucht werden
-
-kosten =lambda kw, stunden: kw["anfahrt"] + kw["grenz"] * LEISTUNG * stunden
-
-print(f"{'Dauer':>7}{'Kernblock':>12}{'Gasturbine':>12} guenstiger")
-for stunden in (1, 2, 3, 4, 5, 6, 8):
- k, g = kosten(kernblock, stunden), kosten(gasturbine, stunden)
-print(f"{stunden:>5} h {k:>12,.0f}{g:>12,.0f} "
-f"{kernblock['name'] if k < g else gasturbine['name']}")
-
-wechsel = (kernblock["anfahrt"] - gasturbine["anfahrt"]) / (
- (gasturbine["grenz"] - kernblock["grenz"]) * LEISTUNG)
-print(f"\nUmschlagpunkt: {wechsel:.1f} Stunden")
+
# Zwei Kraftwerke, die 120 MW decken sollen - aber wie lange?
+# Grenzkosten Anfahrkosten
+kernblock =dict(name="Kernblock", grenz=22, anfahrt=40_000)
+gasturbine =dict(name="Gasturbine", grenz=105, anfahrt=1_500)
+LEISTUNG =120# MW, die gebraucht werden
+
+kosten =lambda kw, stunden: kw["anfahrt"] + kw["grenz"] * LEISTUNG * stunden
+
+print(f"{'Dauer':>7}{'Kernblock':>12}{'Gasturbine':>12} guenstiger")
+for stunden in (1, 2, 3, 4, 5, 6, 8):
+ k, g = kosten(kernblock, stunden), kosten(gasturbine, stunden)
+print(f"{stunden:>5} h {k:>12,.0f}{g:>12,.0f} "
+f"{kernblock['name'] if k < g else gasturbine['name']}")
+
+wechsel = (kernblock["anfahrt"] - gasturbine["anfahrt"]) / (
+ (gasturbine["grenz"] - kernblock["grenz"]) * LEISTUNG)
+print(f"\nUmschlagpunkt: {wechsel:.1f} Stunden")
#!/usr/bin/env python3
-
-# Kraftwerkseinsatz.py
-"""
-Kapitel Supply-Chain: Welche Bloecke laufen morgen? Und was, wenn kein Wind weht?
-
-Die Kraftwerkseinsatzplanung (englisch Unit Commitment) ist die Aufgabe, an der
-sich in der Energiewirtschaft alles entscheidet: Fuer jede Stunde des naechsten
-Tages muss feststehen, welche Bloecke am Netz sind. Ein Kernblock braucht acht
-Stunden Mindestlaufzeit und 40.000 Euro Anfahrkosten - wer ihn abschaltet, hat
-ihn fuer den Rest des Tages verloren.
-
-Das Besondere liegt in der Zeitstruktur, und es geht ueber das zweistufige
-Modell aus dem Kapitel Unsicherheit hinaus:
-
- STUFE 1 Das AN/AUS je Block und Stunde. Binaer, und es steht am Vorabend
- fest - bevor irgendjemand weiss, wie viel Wind morgen weht.
- STUFE 2 Die Fahrweise: wie viel jeder laufende Block liefert. Das darf
- sich stundenweise an die Wirklichkeit anpassen.
-
-Die erste Stufe ist also GANZZAHLIG und szenariouebergreifend gleich, die
-zweite kontinuierlich und je Szenario verschieden. Genau diese Kombination
-macht das Problem interessant - und sie ist der Grund, warum ein Plan, der auf
-den Wind-Erwartungswert gerechnet wurde, in der Wirklichkeit teuer wird.
-
-Das Programm zeigt drei Plaene auf denselben Daten:
-
- 1. Deterministisch: gerechnet mit dem Wind-ERWARTUNGSWERT, danach gegen 40
- Szenarien ausgewertet.
- 2. Zweistufig: der Commitment-Plan sieht alle 40 Szenarien.
- 3. Mit Versorgungssicherheit: die Nichtdeckung in den schlechtesten Faellen
- wird begrenzt - und der Preis dafuer in Euro je vermiedener MWh
- ausgewiesen.
-
-Benoetigt: numpy, ortools
-"""
-
-from __future__ import annotations
-
-import time
-
-import numpy as np
-from ortools.linear_solver import pywraplp
-
-# (Name, Mindestleistung, Nennleistung, Grenzkosten, Anfahrkosten, Mindestlaufzeit)
-KRAFTWERKE = [
- ("Kernblock", 200, 600, 22, 40_000, 8),
- ("Braunkohle", 100, 400, 35, 18_000, 6),
- ("Steinkohle", 80, 300, 48, 12_000, 4),
- ("Gas GuD", 50, 250, 72, 6_000, 2),
- ("Gasturbine", 20, 150, 105, 1_500, 1),
-]
-STUNDEN =24
-SZENARIEN =40
-FLAUTENANTEIL =0.15
-ALPHA =0.90# die schlechtesten 10 % der Szenarien
-LASTABWURF =3_000.0# EUR je nicht gedeckter MWh ("value of lost load")
-NIEDRIGER_ABWURFPREIS =300.0# zum Vergleich: ein Marktpreisdeckel
-SAAT =4
-
-_t = np.arange(STUNDEN)
-LAST =620+260* np.sin((_t -7) /24*2* np.pi) +90* np.sin((_t -4) /12*2* np.pi)
-WIND_ERWARTUNG = np.maximum(0.0, 160+130* np.sin((_t -14) /24*2* np.pi))
-
-
-def erzeuge_windszenarien(anzahl: int= SZENARIEN, saat: int= SAAT):
-"""Windeinspeisung je Szenario und Stunde.
-
- Zwei Zutaten, die zusammen den Unterschied machen: eine breite
- lognormale Streuung des Tagesniveaus - und in 15 % der Faelle eine
- DUNKELFLAUTE, in der praktisch gar kein Wind weht. Solche seltenen,
- extremen Faelle sind der Grund, warum der Erwartungswert als
- Planungsgrundlage nicht genuegt.
- """
- rng = np.random.default_rng(saat)
- tagesniveau = rng.lognormal(0, 0.40, (anzahl, 1))
- wind = np.clip(WIND_ERWARTUNG * tagesniveau
-+ rng.normal(0, 25, (anzahl, STUNDEN)), 0, 420)
- ist_flaute = rng.random(anzahl) < FLAUTENANTEIL
- wind[ist_flaute] *=0.05
-return wind, ist_flaute
-
-
-def plane(wind: np.ndarray, gewichte, cvar_grenze: float|None=None,
- abwurfpreis: float=None, zeitlimit: float=300.0):
-"""Commitment (Stufe 1) und Fahrweise je Szenario (Stufe 2) in einem Modell.
-
- 'wind' hat die Form (Szenarien, Stunden). Mit einer einzigen Zeile
- Windprognose wird daraus die deterministische Planung.
-
- 'cvar_grenze' begrenzt den CVaR der Nichtdeckung ueber die Szenarien -
- dieselbe Rockafellar-Uryasev-Konstruktion wie im Kapitel CVaR, nur dass
- hier keine Verluste in Euro, sondern Megawattstunden begrenzt werden.
- """
- abwurfpreis = LASTABWURF if abwurfpreis isNoneelse abwurfpreis
- anzahl_szenarien = wind.shape[0]
- anzahl_bloecke =len(KRAFTWERKE)
- solver = pywraplp.Solver.CreateSolver("SCIP")
- solver.SetTimeLimit(int(zeitlimit *1000))
-
-# --- Stufe 1: binaer, szenariouebergreifend gleich --------------------
- laeuft = [[solver.BoolVar(f"laeuft_{k}_{t}") for t inrange(STUNDEN)]
-for k inrange(anzahl_bloecke)]
- faehrt_an = [[solver.BoolVar(f"start_{k}_{t}") for t inrange(STUNDEN)]
-for k inrange(anzahl_bloecke)]
-
-# --- Stufe 2: kontinuierlich, je Szenario -----------------------------
- leistung = [[[solver.NumVar(0, KRAFTWERKE[k][2], f"p_{j}_{k}_{t}")
-for t inrange(STUNDEN)] for k inrange(anzahl_bloecke)]
-for j inrange(anzahl_szenarien)]
- nichtdeckung = [[solver.NumVar(0, solver.infinity(), f"y_{j}_{t}")
-for t inrange(STUNDEN)] for j inrange(anzahl_szenarien)]
-
-for k, (_, pmin, pmax, _, _, mindestlaufzeit) inenumerate(KRAFTWERKE):
-for t inrange(STUNDEN):
-# Anfahren erkennen: aus im Vortakt, an im aktuellen
- vorher = laeuft[k][t -1] if t >0else0
- solver.Add(faehrt_an[k][t] >= laeuft[k][t] - vorher)
-# Mindestlaufzeit: wer anfaehrt, laeuft die naechsten Stunden weiter
-for spaeter inrange(t, min(STUNDEN, t + mindestlaufzeit)):
- solver.Add(laeuft[k][spaeter] >= faehrt_an[k][t])
-# Ein laufender Block liefert zwischen Mindest- und Nennleistung,
-# ein stehender gar nichts. Das koppelt beide Stufen.
-for j inrange(anzahl_szenarien):
- solver.Add(leistung[j][k][t] >= pmin * laeuft[k][t])
- solver.Add(leistung[j][k][t] <= pmax * laeuft[k][t])
-
-for j inrange(anzahl_szenarien):
-for t inrange(STUNDEN):
- solver.Add(sum(leistung[j][k][t] for k inrange(anzahl_bloecke))
-+float(wind[j, t]) + nichtdeckung[j][t] >= LAST[t])
-
-# --- Versorgungssicherheit als CVaR-Schranke --------------------------
-if cvar_grenze isnotNone:
- schwelle = solver.NumVar(-solver.infinity(), solver.infinity(), "schwelle")
- ueberschuss = [solver.NumVar(0, solver.infinity(), f"u_{j}")
-for j inrange(anzahl_szenarien)]
-for j inrange(anzahl_szenarien):
- solver.Add(ueberschuss[j] >=sum(nichtdeckung[j][t]
-for t inrange(STUNDEN)) - schwelle)
- solver.Add(schwelle + (1.0/ (anzahl_szenarien * (1- ALPHA)))
-*sum(ueberschuss) <= cvar_grenze)
-
- anfahrkosten =sum(KRAFTWERKE[k][4] * faehrt_an[k][t]
-for k inrange(anzahl_bloecke) for t inrange(STUNDEN))
- betriebskosten =sum(
- gewichte[j] *sum(KRAFTWERKE[k][3] * leistung[j][k][t]
-for k inrange(anzahl_bloecke) for t inrange(STUNDEN))
-for j inrange(anzahl_szenarien))
- abwurfkosten =sum(gewichte[j] * abwurfpreis
-*sum(nichtdeckung[j][t] for t inrange(STUNDEN))
-for j inrange(anzahl_szenarien))
- solver.Minimize(anfahrkosten + betriebskosten + abwurfkosten)
-
- beginn = time.perf_counter()
- status = solver.Solve()
- dauer = time.perf_counter() - beginn
-if status notin (pywraplp.Solver.OPTIMAL, pywraplp.Solver.FEASIBLE):
-returnNone, dauer, False
- plan = np.array([[laeuft[k][t].solution_value() for t inrange(STUNDEN)]
-for k inrange(anzahl_bloecke)]).round()
-return plan, dauer, status == pywraplp.Solver.OPTIMAL
-
-
-def bewerte(plan: np.ndarray, wind: np.ndarray, abwurfpreis: float=None):
-"""Commitment steht fest - nur noch die Fahrweise je Szenario optimieren.
-
- Das ist die ehrliche Bewertung eines Plans: Er wird der Wirklichkeit
- ausgesetzt und darf nur noch das anpassen, was sich am Tag selbst
- anpassen laesst.
- """
- abwurfpreis = LASTABWURF if abwurfpreis isNoneelse abwurfpreis
- anzahl_bloecke =len(KRAFTWERKE)
- kosten, fehlmengen = [], []
-for j inrange(wind.shape[0]):
- solver = pywraplp.Solver.CreateSolver("GLOP")
- leistung = [[solver.NumVar(0, KRAFTWERKE[k][2], f"p_{k}_{t}")
-for t inrange(STUNDEN)] for k inrange(anzahl_bloecke)]
- fehlt = [solver.NumVar(0, solver.infinity(), f"y_{t}")
-for t inrange(STUNDEN)]
-for k, (_, pmin, pmax, _, _, _) inenumerate(KRAFTWERKE):
-for t inrange(STUNDEN):
- solver.Add(leistung[k][t] >= pmin * plan[k, t])
- solver.Add(leistung[k][t] <= pmax * plan[k, t])
-for t inrange(STUNDEN):
- solver.Add(sum(leistung[k][t] for k inrange(anzahl_bloecke))
-+float(wind[j, t]) + fehlt[t] >= LAST[t])
- solver.Minimize(
-sum(KRAFTWERKE[k][3] * leistung[k][t]
-for k inrange(anzahl_bloecke) for t inrange(STUNDEN))
-+sum(abwurfpreis * fehlt[t] for t inrange(STUNDEN)))
- solver.Solve()
- kosten.append(solver.Objective().Value())
- fehlmengen.append(sum(f.solution_value() for f in fehlt))
-
- anfahrten =sum(KRAFTWERKE[k][4]
-*max(0.0, plan[k, t] - (plan[k, t -1] if t >0else0.0))
-for k inrange(anzahl_bloecke) for t inrange(STUNDEN))
-return np.array(kosten) + anfahrten, np.array(fehlmengen)
-
-
-def cvar(werte: np.ndarray, alpha: float= ALPHA) ->float:
-"""Mittelwert der schlechtesten (1-alpha) Faelle."""
- schwelle = np.quantile(werte, alpha)
- schlechteste = werte[werte >= schwelle]
-returnfloat(schlechteste.mean()) iflen(schlechteste) else0.0
-
-
-def zeige(name: str, plan, wind, abwurfpreis: float=None) ->dict:
- kosten, fehl = bewerte(plan, wind, abwurfpreis)
- kennzahlen = {"mittel": kosten.mean(), "max": kosten.max(),
-"fehl_mittel": fehl.mean(), "fehl_cvar": cvar(fehl),
-"betroffen": int((fehl >0.01).sum()),
-"blockstunden": int(plan.sum())}
-print(f" {name:<32}{kennzahlen['mittel']:>10,.0f}{kennzahlen['max']:>11,.0f} "
-f"{kennzahlen['fehl_mittel']:>9.1f}{kennzahlen['fehl_cvar']:>10.1f} "
-f"{kennzahlen['betroffen']:>6}/{len(fehl)}")
-return kennzahlen
-
-
-if__name__=="__main__":
- wind, ist_flaute = erzeuge_windszenarien()
-
-print("="*88)
-print(" KRAFTWERKSEINSATZ: DER PLAN STEHT, BEVOR DER WIND WEHT")
-print("="*88)
-print(f"{len(KRAFTWERKE)} Bloecke, {STUNDEN} Stunden, {SZENARIEN} Windszenarien "
-f"(davon {int(ist_flaute.sum())} Dunkelflauten).")
-print(f"Last {LAST.min():.0f} bis {LAST.max():.0f} MW, "
-f"Wind im Erwartungswert {WIND_ERWARTUNG.min():.0f} bis "
-f"{WIND_ERWARTUNG.max():.0f} MW.")
-print(f"Nicht gedeckte Last kostet {LASTABWURF:,.0f} EUR je MWh.\n")
-
-print(f" {'Planungsgrundlage':<32}{'Kosten':>10}{'Kosten':>11} "
-f"{'Fehlmenge':>9}{'Fehlmenge':>10}{'Szen. mit':>12}")
-print(f" {'':<32}{'im Mittel':>10}{'schlimmst.':>11} "
-f"{'Mittel':>9}{'CVaR 90%':>10}{'Abwurf':>12}")
-print(" "+"-"*86)
-
-# --- 1. Deterministisch ----------------------------------------------
- plan_det, dauer_det, _ = plane(WIND_ERWARTUNG.reshape(1, -1), [1.0])
- det = zeige("nur Wind-Erwartungswert", plan_det, wind)
-
-# --- 2. Zweistufig, risikoneutral ------------------------------------
- gleich = [1.0/ SZENARIEN] * SZENARIEN
- plan_sto, dauer_sto, optimal_sto = plane(wind, gleich)
- sto = zeige("alle 40 Szenarien", plan_sto, wind)
-
-# --- 3. Mit Versorgungssicherheit ------------------------------------
- plan_sicher, dauer_sicher, optimal_sicher = plane(wind, gleich, cvar_grenze=0.0)
- sicher = zeige("+ Nichtdeckung CVaR 90 % = 0", plan_sicher, wind)
-
-print(f"\n Rechenzeiten: {dauer_det:.1f}s / {dauer_sto:.1f}s / {dauer_sicher:.1f}s "
-f"(alle beweisbar optimal: {optimal_sto and optimal_sicher})")
-
-# --- Was die Zeilen bedeuten -----------------------------------------
-print("\n"+"-"*88)
-print("Was der Erwartungswert-Plan anrichtet\n")
-print(f" Er ist auf dem Papier der billigste - gerechnet auf den mittleren")
-print(f" Wind kostet er weniger als jeder andere. In der Wirklichkeit liegt")
-print(f" er {det['mittel'] / sto['mittel'] -1:+.0%} ueber dem zweistufigen Plan, und sein "
-f"schlimmster Tag")
-print(f" kostet {det['max'] / sto['max']:.1f}-mal so viel.")
-print(f"\n Der Grund steht in der letzten Spalte: In {det['betroffen']} von {SZENARIEN} "
-f"Szenarien muss")
-print(f" Last abgeworfen werden. Was fehlt, sind nicht Kraftwerke, sondern")
-print(f" ANGEFAHRENE Kraftwerke - und ein Block mit acht Stunden")
-print(f" Mindestlaufzeit laesst sich um 18 Uhr nicht mehr herbeirufen.")
-
-print(f"\n Der Unterschied im Plan ist klein:")
-for name, plan in [("Erwartungswert", plan_det), ("zweistufig", plan_sto),
- ("mit Sicherheit", plan_sicher)]:
- laufend = plan.sum(axis=0).astype(int)
-print(f" {name:<16}{''.join(str(x) for x in laufend)} "
-f"({int(plan.sum())} Blockstunden)")
-print(f"\n Der zweistufige Plan haelt {sto['blockstunden'] - det['blockstunden']} "
-f"Blockstunden mehr vor - das genuegt,")
-print(f" um alle {det['betroffen']} Lastabwuerfe zu vermeiden.")
-
-# --- Der Preis der Sicherheit ----------------------------------------
-print("\n"+"-"*88)
-print("Was Versorgungssicherheit kostet\n")
-print(f" Bei {LASTABWURF:,.0f} EUR/MWh kostet die CVaR-Schranke NICHTS: Der")
-print(" risikoneutrale Plan haelt sie schon ein. Das ist kein Zufall - bei")
-print(" diesem Preis lohnt sich Vorhaltung bereits im Erwartungswert.")
-print("\n Interessant wird die Schranke dort, wo der Schaden ZU NIEDRIG")
-print(" bepreist ist. Dieselbe Rechnung mit einem Marktpreisdeckel von")
-print(f" {NIEDRIGER_ABWURFPREIS:,.0f} EUR/MWh statt {LASTABWURF:,.0f}:\n")
-
-print(f" {'Planungsgrundlage':<32}{'Kosten':>10}{'Kosten':>11} "
-f"{'Fehlmenge':>9}{'Fehlmenge':>10}{'Szen. mit':>12}")
-print(f" {'':<32}{'im Mittel':>10}{'schlimmst.':>11} "
-f"{'Mittel':>9}{'CVaR 90%':>10}{'Abwurf':>12}")
-print(" "+"-"*86)
- plan_billig, _, _ = plane(wind, gleich, abwurfpreis=NIEDRIGER_ABWURFPREIS)
- billig = zeige("risikoneutral, billiger Abwurf", plan_billig, wind,
- NIEDRIGER_ABWURFPREIS)
- plan_billig_sicher, _, _ = plane(wind, gleich, cvar_grenze=0.0,
- abwurfpreis=NIEDRIGER_ABWURFPREIS)
- billig_sicher = zeige("+ CVaR 90 % der Fehlmenge = 0", plan_billig_sicher, wind,
- NIEDRIGER_ABWURFPREIS)
-
- aufpreis = billig_sicher["mittel"] - billig["mittel"]
- vermieden = billig["fehl_cvar"] - billig_sicher["fehl_cvar"]
-print(f"\n Jetzt greift die Schranke: Der risikoneutrale Plan nimmt in")
-print(f" {billig['betroffen']} von {SZENARIEN} Szenarien einen Lastabwurf in Kauf, weil er bei")
-print(f" {NIEDRIGER_ABWURFPREIS:,.0f} EUR/MWh billiger ist als das Vorhalten eines Blocks.")
-print(f"\n Aufpreis fuer die Sicherheit: {aufpreis:,.0f} EUR je Tag")
-if vermieden >0.01:
-print(f" Vermiedene Fehlmenge (CVaR 90 %): {vermieden:.1f} MWh")
-print(f" -> {aufpreis / vermieden:,.0f} EUR je vermiedener MWh")
-print(f"\n Diese Zahl ist die Entscheidungsgrundlage - genau wie die Spalte")
-print(f" 'EUR je kg' im Kapitel Mehrziel. Sie sagt der Aufsicht, was ihre")
-print(f" Versorgungssicherheitsvorgabe tatsaechlich kostet, statt darueber")
-print(f" zu streiten, wie wichtig sie ist.")
-
-print("\n"+"="*88)
-print(" WAS MAN DARAUS MITNIMMT")
-print("="*88)
-print("1. Die erste Stufe ist binaer und traege. Was am Vorabend nicht")
-print(" angefahren wurde, steht am naechsten Tag nicht zur Verfuegung -")
-print(" egal, wie hoch der Preis dann steigt.")
-print("2. Deshalb ist der Erwartungswert als Planungsgrundlage nicht nur")
-print(" ungenau, sondern SYSTEMATISCH zu knapp: Er plant fuer einen Tag,")
-print(" der so nie eintritt, und laesst keine Reserve fuer die Haelfte")
-print(" der Faelle, in denen es schlechter kommt.")
-print("3. Der Ausweg ist keine bessere Windprognose, sondern ein Modell,")
-print(" das die Szenarien SIEHT. Die Rechenzeit dafuer liegt hier im")
-print(" Sekundenbereich - der Aufwand ist die Modellierung, nicht der")
-print(" Solver.")
-print("4. Wo der Lastabwurf teuer genug bepreist ist, deckt schon die")
-print(" Minimierung der ERWARTETEN Kosten die Extremfaelle mit ab. Die")
-print(" Risikoschranke wird genau dann gebraucht, wenn der Schaden ZU")
-print(" NIEDRIG bepreist ist - was bei Versorgungssicherheit die Regel")
-print(" ist, weil ein Marktpreisdeckel nicht den volkswirtschaftlichen")
-print(" Schaden abbildet. Dann liefert sie den Preis der Vorgabe in Euro")
-print(" je vermiedener MWh, und darueber laesst sich verhandeln.")
-print("="*88)
+
#!/usr/bin/env python3
+
+# Kraftwerkseinsatz.py
+"""
+Kapitel Supply-Chain: Welche Bloecke laufen morgen? Und was, wenn kein Wind weht?
+
+Die Kraftwerkseinsatzplanung (englisch Unit Commitment) ist die Aufgabe, an der
+sich in der Energiewirtschaft alles entscheidet: Fuer jede Stunde des naechsten
+Tages muss feststehen, welche Bloecke am Netz sind. Ein Kernblock braucht acht
+Stunden Mindestlaufzeit und 40.000 Euro Anfahrkosten - wer ihn abschaltet, hat
+ihn fuer den Rest des Tages verloren.
+
+Das Besondere liegt in der Zeitstruktur, und es geht ueber das zweistufige
+Modell aus dem Kapitel Unsicherheit hinaus:
+
+ STUFE 1 Das AN/AUS je Block und Stunde. Binaer, und es steht am Vorabend
+ fest - bevor irgendjemand weiss, wie viel Wind morgen weht.
+ STUFE 2 Die Fahrweise: wie viel jeder laufende Block liefert. Das darf
+ sich stundenweise an die Wirklichkeit anpassen.
+
+Die erste Stufe ist also GANZZAHLIG und szenariouebergreifend gleich, die
+zweite kontinuierlich und je Szenario verschieden. Genau diese Kombination
+macht das Problem interessant - und sie ist der Grund, warum ein Plan, der auf
+den Wind-Erwartungswert gerechnet wurde, in der Wirklichkeit teuer wird.
+
+Das Programm zeigt drei Plaene auf denselben Daten:
+
+ 1. Deterministisch: gerechnet mit dem Wind-ERWARTUNGSWERT, danach gegen 40
+ Szenarien ausgewertet.
+ 2. Zweistufig: der Commitment-Plan sieht alle 40 Szenarien.
+ 3. Mit Versorgungssicherheit: die Nichtdeckung in den schlechtesten Faellen
+ wird begrenzt - und der Preis dafuer in Euro je vermiedener MWh
+ ausgewiesen.
+
+Benoetigt: numpy, ortools
+"""
+
+from __future__ import annotations
+
+import time
+
+import numpy as np
+from ortools.linear_solver import pywraplp
+
+# (Name, Mindestleistung, Nennleistung, Grenzkosten, Anfahrkosten, Mindestlaufzeit)
+KRAFTWERKE = [
+ ("Kernblock", 200, 600, 22, 40_000, 8),
+ ("Braunkohle", 100, 400, 35, 18_000, 6),
+ ("Steinkohle", 80, 300, 48, 12_000, 4),
+ ("Gas GuD", 50, 250, 72, 6_000, 2),
+ ("Gasturbine", 20, 150, 105, 1_500, 1),
+]
+STUNDEN =24
+SZENARIEN =40
+FLAUTENANTEIL =0.15
+ALPHA =0.90# die schlechtesten 10 % der Szenarien
+LASTABWURF =3_000.0# EUR je nicht gedeckter MWh ("value of lost load")
+NIEDRIGER_ABWURFPREIS =300.0# zum Vergleich: ein Marktpreisdeckel
+SAAT =4
+
+_t = np.arange(STUNDEN)
+LAST =620+260* np.sin((_t -7) /24*2* np.pi) +90* np.sin((_t -4) /12*2* np.pi)
+WIND_ERWARTUNG = np.maximum(0.0, 160+130* np.sin((_t -14) /24*2* np.pi))
+
+
+def erzeuge_windszenarien(anzahl: int= SZENARIEN, saat: int= SAAT):
+"""Windeinspeisung je Szenario und Stunde.
+
+ Zwei Zutaten, die zusammen den Unterschied machen: eine breite
+ lognormale Streuung des Tagesniveaus - und in 15 % der Faelle eine
+ DUNKELFLAUTE, in der praktisch gar kein Wind weht. Solche seltenen,
+ extremen Faelle sind der Grund, warum der Erwartungswert als
+ Planungsgrundlage nicht genuegt.
+ """
+ rng = np.random.default_rng(saat)
+ tagesniveau = rng.lognormal(0, 0.40, (anzahl, 1))
+ wind = np.clip(WIND_ERWARTUNG * tagesniveau
++ rng.normal(0, 25, (anzahl, STUNDEN)), 0, 420)
+ ist_flaute = rng.random(anzahl) < FLAUTENANTEIL
+ wind[ist_flaute] *=0.05
+return wind, ist_flaute
+
+
+def plane(wind: np.ndarray, gewichte, cvar_grenze: float|None=None,
+ abwurfpreis: float=None, zeitlimit: float=300.0):
+"""Commitment (Stufe 1) und Fahrweise je Szenario (Stufe 2) in einem Modell.
+
+ 'wind' hat die Form (Szenarien, Stunden). Mit einer einzigen Zeile
+ Windprognose wird daraus die deterministische Planung.
+
+ 'cvar_grenze' begrenzt den CVaR der Nichtdeckung ueber die Szenarien -
+ dieselbe Rockafellar-Uryasev-Konstruktion wie im Kapitel CVaR, nur dass
+ hier keine Verluste in Euro, sondern Megawattstunden begrenzt werden.
+ """
+ abwurfpreis = LASTABWURF if abwurfpreis isNoneelse abwurfpreis
+ anzahl_szenarien = wind.shape[0]
+ anzahl_bloecke =len(KRAFTWERKE)
+ solver = pywraplp.Solver.CreateSolver("SCIP")
+ solver.SetTimeLimit(int(zeitlimit *1000))
+
+# --- Stufe 1: binaer, szenariouebergreifend gleich --------------------
+ laeuft = [[solver.BoolVar(f"laeuft_{k}_{t}") for t inrange(STUNDEN)]
+for k inrange(anzahl_bloecke)]
+ faehrt_an = [[solver.BoolVar(f"start_{k}_{t}") for t inrange(STUNDEN)]
+for k inrange(anzahl_bloecke)]
+
+# --- Stufe 2: kontinuierlich, je Szenario -----------------------------
+ leistung = [[[solver.NumVar(0, KRAFTWERKE[k][2], f"p_{j}_{k}_{t}")
+for t inrange(STUNDEN)] for k inrange(anzahl_bloecke)]
+for j inrange(anzahl_szenarien)]
+ nichtdeckung = [[solver.NumVar(0, solver.infinity(), f"y_{j}_{t}")
+for t inrange(STUNDEN)] for j inrange(anzahl_szenarien)]
+
+for k, (_, pmin, pmax, _, _, mindestlaufzeit) inenumerate(KRAFTWERKE):
+for t inrange(STUNDEN):
+# Anfahren erkennen: aus im Vortakt, an im aktuellen
+ vorher = laeuft[k][t -1] if t >0else0
+ solver.Add(faehrt_an[k][t] >= laeuft[k][t] - vorher)
+# Mindestlaufzeit: wer anfaehrt, laeuft die naechsten Stunden weiter
+for spaeter inrange(t, min(STUNDEN, t + mindestlaufzeit)):
+ solver.Add(laeuft[k][spaeter] >= faehrt_an[k][t])
+# Ein laufender Block liefert zwischen Mindest- und Nennleistung,
+# ein stehender gar nichts. Das koppelt beide Stufen.
+for j inrange(anzahl_szenarien):
+ solver.Add(leistung[j][k][t] >= pmin * laeuft[k][t])
+ solver.Add(leistung[j][k][t] <= pmax * laeuft[k][t])
+
+for j inrange(anzahl_szenarien):
+for t inrange(STUNDEN):
+ solver.Add(sum(leistung[j][k][t] for k inrange(anzahl_bloecke))
++float(wind[j, t]) + nichtdeckung[j][t] >= LAST[t])
+
+# --- Versorgungssicherheit als CVaR-Schranke --------------------------
+if cvar_grenze isnotNone:
+ schwelle = solver.NumVar(-solver.infinity(), solver.infinity(), "schwelle")
+ ueberschuss = [solver.NumVar(0, solver.infinity(), f"u_{j}")
+for j inrange(anzahl_szenarien)]
+for j inrange(anzahl_szenarien):
+ solver.Add(ueberschuss[j] >=sum(nichtdeckung[j][t]
+for t inrange(STUNDEN)) - schwelle)
+ solver.Add(schwelle + (1.0/ (anzahl_szenarien * (1- ALPHA)))
+*sum(ueberschuss) <= cvar_grenze)
+
+ anfahrkosten =sum(KRAFTWERKE[k][4] * faehrt_an[k][t]
+for k inrange(anzahl_bloecke) for t inrange(STUNDEN))
+ betriebskosten =sum(
+ gewichte[j] *sum(KRAFTWERKE[k][3] * leistung[j][k][t]
+for k inrange(anzahl_bloecke) for t inrange(STUNDEN))
+for j inrange(anzahl_szenarien))
+ abwurfkosten =sum(gewichte[j] * abwurfpreis
+*sum(nichtdeckung[j][t] for t inrange(STUNDEN))
+for j inrange(anzahl_szenarien))
+ solver.Minimize(anfahrkosten + betriebskosten + abwurfkosten)
+
+ beginn = time.perf_counter()
+ status = solver.Solve()
+ dauer = time.perf_counter() - beginn
+if status notin (pywraplp.Solver.OPTIMAL, pywraplp.Solver.FEASIBLE):
+returnNone, dauer, False
+ plan = np.array([[laeuft[k][t].solution_value() for t inrange(STUNDEN)]
+for k inrange(anzahl_bloecke)]).round()
+return plan, dauer, status == pywraplp.Solver.OPTIMAL
+
+
+def bewerte(plan: np.ndarray, wind: np.ndarray, abwurfpreis: float=None):
+"""Commitment steht fest - nur noch die Fahrweise je Szenario optimieren.
+
+ Das ist die ehrliche Bewertung eines Plans: Er wird der Wirklichkeit
+ ausgesetzt und darf nur noch das anpassen, was sich am Tag selbst
+ anpassen laesst.
+ """
+ abwurfpreis = LASTABWURF if abwurfpreis isNoneelse abwurfpreis
+ anzahl_bloecke =len(KRAFTWERKE)
+ kosten, fehlmengen = [], []
+for j inrange(wind.shape[0]):
+ solver = pywraplp.Solver.CreateSolver("GLOP")
+ leistung = [[solver.NumVar(0, KRAFTWERKE[k][2], f"p_{k}_{t}")
+for t inrange(STUNDEN)] for k inrange(anzahl_bloecke)]
+ fehlt = [solver.NumVar(0, solver.infinity(), f"y_{t}")
+for t inrange(STUNDEN)]
+for k, (_, pmin, pmax, _, _, _) inenumerate(KRAFTWERKE):
+for t inrange(STUNDEN):
+ solver.Add(leistung[k][t] >= pmin * plan[k, t])
+ solver.Add(leistung[k][t] <= pmax * plan[k, t])
+for t inrange(STUNDEN):
+ solver.Add(sum(leistung[k][t] for k inrange(anzahl_bloecke))
++float(wind[j, t]) + fehlt[t] >= LAST[t])
+ solver.Minimize(
+sum(KRAFTWERKE[k][3] * leistung[k][t]
+for k inrange(anzahl_bloecke) for t inrange(STUNDEN))
++sum(abwurfpreis * fehlt[t] for t inrange(STUNDEN)))
+ solver.Solve()
+ kosten.append(solver.Objective().Value())
+ fehlmengen.append(sum(f.solution_value() for f in fehlt))
+
+ anfahrten =sum(KRAFTWERKE[k][4]
+*max(0.0, plan[k, t] - (plan[k, t -1] if t >0else0.0))
+for k inrange(anzahl_bloecke) for t inrange(STUNDEN))
+return np.array(kosten) + anfahrten, np.array(fehlmengen)
+
+
+def cvar(werte: np.ndarray, alpha: float= ALPHA) ->float:
+"""Mittelwert der schlechtesten (1-alpha) Faelle."""
+ schwelle = np.quantile(werte, alpha)
+ schlechteste = werte[werte >= schwelle]
+returnfloat(schlechteste.mean()) iflen(schlechteste) else0.0
+
+
+def zeige(name: str, plan, wind, abwurfpreis: float=None) ->dict:
+ kosten, fehl = bewerte(plan, wind, abwurfpreis)
+ kennzahlen = {"mittel": kosten.mean(), "max": kosten.max(),
+"fehl_mittel": fehl.mean(), "fehl_cvar": cvar(fehl),
+"betroffen": int((fehl >0.01).sum()),
+"blockstunden": int(plan.sum())}
+print(f" {name:<32}{kennzahlen['mittel']:>10,.0f}{kennzahlen['max']:>11,.0f} "
+f"{kennzahlen['fehl_mittel']:>9.1f}{kennzahlen['fehl_cvar']:>10.1f} "
+f"{kennzahlen['betroffen']:>6}/{len(fehl)}")
+return kennzahlen
+
+
+if__name__=="__main__":
+ wind, ist_flaute = erzeuge_windszenarien()
+
+print("="*88)
+print(" KRAFTWERKSEINSATZ: DER PLAN STEHT, BEVOR DER WIND WEHT")
+print("="*88)
+print(f"{len(KRAFTWERKE)} Bloecke, {STUNDEN} Stunden, {SZENARIEN} Windszenarien "
+f"(davon {int(ist_flaute.sum())} Dunkelflauten).")
+print(f"Last {LAST.min():.0f} bis {LAST.max():.0f} MW, "
+f"Wind im Erwartungswert {WIND_ERWARTUNG.min():.0f} bis "
+f"{WIND_ERWARTUNG.max():.0f} MW.")
+print(f"Nicht gedeckte Last kostet {LASTABWURF:,.0f} EUR je MWh.\n")
+
+print(f" {'Planungsgrundlage':<32}{'Kosten':>10}{'Kosten':>11} "
+f"{'Fehlmenge':>9}{'Fehlmenge':>10}{'Szen. mit':>12}")
+print(f" {'':<32}{'im Mittel':>10}{'schlimmst.':>11} "
+f"{'Mittel':>9}{'CVaR 90%':>10}{'Abwurf':>12}")
+print(" "+"-"*86)
+
+# --- 1. Deterministisch ----------------------------------------------
+ plan_det, dauer_det, _ = plane(WIND_ERWARTUNG.reshape(1, -1), [1.0])
+ det = zeige("nur Wind-Erwartungswert", plan_det, wind)
+
+# --- 2. Zweistufig, risikoneutral ------------------------------------
+ gleich = [1.0/ SZENARIEN] * SZENARIEN
+ plan_sto, dauer_sto, optimal_sto = plane(wind, gleich)
+ sto = zeige("alle 40 Szenarien", plan_sto, wind)
+
+# --- 3. Mit Versorgungssicherheit ------------------------------------
+ plan_sicher, dauer_sicher, optimal_sicher = plane(wind, gleich, cvar_grenze=0.0)
+ sicher = zeige("+ Nichtdeckung CVaR 90 % = 0", plan_sicher, wind)
+
+print(f"\n Rechenzeiten: {dauer_det:.1f}s / {dauer_sto:.1f}s / {dauer_sicher:.1f}s "
+f"(alle beweisbar optimal: {optimal_sto and optimal_sicher})")
+
+# --- Was die Zeilen bedeuten -----------------------------------------
+print("\n"+"-"*88)
+print("Was der Erwartungswert-Plan anrichtet\n")
+print(f" Er ist auf dem Papier der billigste - gerechnet auf den mittleren")
+print(f" Wind kostet er weniger als jeder andere. In der Wirklichkeit liegt")
+print(f" er {det['mittel'] / sto['mittel'] -1:+.0%} ueber dem zweistufigen Plan, und sein "
+f"schlimmster Tag")
+print(f" kostet {det['max'] / sto['max']:.1f}-mal so viel.")
+print(f"\n Der Grund steht in der letzten Spalte: In {det['betroffen']} von {SZENARIEN} "
+f"Szenarien muss")
+print(f" Last abgeworfen werden. Was fehlt, sind nicht Kraftwerke, sondern")
+print(f" ANGEFAHRENE Kraftwerke - und ein Block mit acht Stunden")
+print(f" Mindestlaufzeit laesst sich um 18 Uhr nicht mehr herbeirufen.")
+
+print(f"\n Der Unterschied im Plan ist klein:")
+for name, plan in [("Erwartungswert", plan_det), ("zweistufig", plan_sto),
+ ("mit Sicherheit", plan_sicher)]:
+ laufend = plan.sum(axis=0).astype(int)
+print(f" {name:<16}{''.join(str(x) for x in laufend)} "
+f"({int(plan.sum())} Blockstunden)")
+print(f"\n Der zweistufige Plan haelt {sto['blockstunden'] - det['blockstunden']} "
+f"Blockstunden mehr vor - das genuegt,")
+print(f" um alle {det['betroffen']} Lastabwuerfe zu vermeiden.")
+
+# --- Der Preis der Sicherheit ----------------------------------------
+print("\n"+"-"*88)
+print("Was Versorgungssicherheit kostet\n")
+print(f" Bei {LASTABWURF:,.0f} EUR/MWh kostet die CVaR-Schranke NICHTS: Der")
+print(" risikoneutrale Plan haelt sie schon ein. Das ist kein Zufall - bei")
+print(" diesem Preis lohnt sich Vorhaltung bereits im Erwartungswert.")
+print("\n Interessant wird die Schranke dort, wo der Schaden ZU NIEDRIG")
+print(" bepreist ist. Dieselbe Rechnung mit einem Marktpreisdeckel von")
+print(f" {NIEDRIGER_ABWURFPREIS:,.0f} EUR/MWh statt {LASTABWURF:,.0f}:\n")
+
+print(f" {'Planungsgrundlage':<32}{'Kosten':>10}{'Kosten':>11} "
+f"{'Fehlmenge':>9}{'Fehlmenge':>10}{'Szen. mit':>12}")
+print(f" {'':<32}{'im Mittel':>10}{'schlimmst.':>11} "
+f"{'Mittel':>9}{'CVaR 90%':>10}{'Abwurf':>12}")
+print(" "+"-"*86)
+ plan_billig, _, _ = plane(wind, gleich, abwurfpreis=NIEDRIGER_ABWURFPREIS)
+ billig = zeige("risikoneutral, billiger Abwurf", plan_billig, wind,
+ NIEDRIGER_ABWURFPREIS)
+ plan_billig_sicher, _, _ = plane(wind, gleich, cvar_grenze=0.0,
+ abwurfpreis=NIEDRIGER_ABWURFPREIS)
+ billig_sicher = zeige("+ CVaR 90 % der Fehlmenge = 0", plan_billig_sicher, wind,
+ NIEDRIGER_ABWURFPREIS)
+
+ aufpreis = billig_sicher["mittel"] - billig["mittel"]
+ vermieden = billig["fehl_cvar"] - billig_sicher["fehl_cvar"]
+print(f"\n Jetzt greift die Schranke: Der risikoneutrale Plan nimmt in")
+print(f" {billig['betroffen']} von {SZENARIEN} Szenarien einen Lastabwurf in Kauf, weil er bei")
+print(f" {NIEDRIGER_ABWURFPREIS:,.0f} EUR/MWh billiger ist als das Vorhalten eines Blocks.")
+print(f"\n Aufpreis fuer die Sicherheit: {aufpreis:,.0f} EUR je Tag")
+if vermieden >0.01:
+print(f" Vermiedene Fehlmenge (CVaR 90 %): {vermieden:.1f} MWh")
+print(f" -> {aufpreis / vermieden:,.0f} EUR je vermiedener MWh")
+print(f"\n Diese Zahl ist die Entscheidungsgrundlage - genau wie die Spalte")
+print(f" 'EUR je kg' im Kapitel Mehrziel. Sie sagt der Aufsicht, was ihre")
+print(f" Versorgungssicherheitsvorgabe tatsaechlich kostet, statt darueber")
+print(f" zu streiten, wie wichtig sie ist.")
+
+print("\n"+"="*88)
+print(" WAS MAN DARAUS MITNIMMT")
+print("="*88)
+print("1. Die erste Stufe ist binaer und traege. Was am Vorabend nicht")
+print(" angefahren wurde, steht am naechsten Tag nicht zur Verfuegung -")
+print(" egal, wie hoch der Preis dann steigt.")
+print("2. Deshalb ist der Erwartungswert als Planungsgrundlage nicht nur")
+print(" ungenau, sondern SYSTEMATISCH zu knapp: Er plant fuer einen Tag,")
+print(" der so nie eintritt, und laesst keine Reserve fuer die Haelfte")
+print(" der Faelle, in denen es schlechter kommt.")
+print("3. Der Ausweg ist keine bessere Windprognose, sondern ein Modell,")
+print(" das die Szenarien SIEHT. Die Rechenzeit dafuer liegt hier im")
+print(" Sekundenbereich - der Aufwand ist die Modellierung, nicht der")
+print(" Solver.")
+print("4. Wo der Lastabwurf teuer genug bepreist ist, deckt schon die")
+print(" Minimierung der ERWARTETEN Kosten die Extremfaelle mit ab. Die")
+print(" Risikoschranke wird genau dann gebraucht, wenn der Schaden ZU")
+print(" NIEDRIG bepreist ist - was bei Versorgungssicherheit die Regel")
+print(" ist, weil ein Marktpreisdeckel nicht den volkswirtschaftlichen")
+print(" Schaden abbildet. Dann liefert sie den Preis der Vorgabe in Euro")
+print(" je vermiedener MWh, und darueber laesst sich verhandeln.")
+print("="*88)
Erwartete Ausgabe (Laufzeiten hardwareabhängig, die Werte nicht):
========================================================================================
KRAFTWERKSEINSATZ: DER PLAN STEHT, BEVOR DER WIND WEHT
@@ -20839,18 +21207,18 @@ zweistufig 111111222222222222222111 (39 Blockstunden)
🚀 In 5 Minuten gelöst: Die durchschnittliche Rendite, die es nicht gibt
Eine Anlage wurde über vier Perioden beobachtet. Der Kurs stand bei 100, dann bei 50, 75, 150 und schließlich bei 120.
Die diskreten Renditen suggerieren einen Gewinn von 16,7 %, obwohl der Kurs exakt dort steht, wo er begann. Die Log-Renditen heben sich korrekt auf.
Deshalb ist die Aussage „im Mittel 5 % Rendite“ mehrdeutig. Das arithmetische Mittel diskreter Renditen überschätzt systematisch, was ein Anleger tatsächlich verdient hätte. Für Wachstumsaussagen nimmt man das geometrische Mittel — oder rechnet gleich in Log-Renditen.
-
#!/usr/bin/env python3
-
-# Renditen_Vergleich.py
-"""
-Kapitel Finanzdaten: Diskrete vs. logarithmische Renditen.
-Zeigt an einem simulierten Kursverlauf, welche Eigenschaft wo gilt -
-und warum das arithmetische Mittel diskreter Renditen in die Irre fuehrt.
-"""
-
-import numpy as np
-import pandas as pd
-
-if__name__=="__main__":
- kurse = np.array([100.0, 150.0, 100.0, 120.0, 90.0, 135.0])
- tage = [f"t{i}"for i inrange(len(kurse))]
-
- diskret = kurse[1:] / kurse[:-1] -1.0
- logarithmisch = np.log(kurse[1:] / kurse[:-1])
-
-print("="*74)
-print(" DISKRETE UND LOGARITHMISCHE RENDITEN IM VERGLEICH")
-print("="*74)
- tabelle = pd.DataFrame({
-"Periode": [f"{tage[i]} -> {tage[i+1]}"for i inrange(len(diskret))],
-"Kurs von": kurse[:-1],
-"Kurs bis": kurse[1:],
-"diskret R": [f"{r*100:+7.2f} %"for r in diskret],
-"log r": [f"{r:+8.4f}"for r in logarithmisch],
- })
-print(tabelle.to_string(index=False))
-
-# --- Zeitliche Aggregation ------------------------------------------
- gesamt_wahr = kurse[-1] / kurse[0] -1.0
- summe_log = logarithmisch.sum()
- aus_log_zurueck = np.exp(summe_log) -1.0
- summe_diskret = diskret.sum()
-
-print("\n--- Aggregation ueber die Zeit ---")
-print(f" Tatsaechliche Gesamtrendite: {gesamt_wahr*100:+8.2f} %")
-print(f" Summe der Log-Renditen -> exp()-1: {aus_log_zurueck*100:+8.2f} % "
-f"{'KORREKT'ifabs(aus_log_zurueck - gesamt_wahr) <1e-9else'falsch'}")
-print(f" Summe der diskreten Renditen: {summe_diskret*100:+8.2f} % FALSCH")
-
-# --- Mittelwerte ------------------------------------------------------
- arithmetisch = diskret.mean()
- geometrisch = np.prod(1+ diskret) ** (1/len(diskret)) -1
- aus_log = np.exp(logarithmisch.mean()) -1
-
-print("\n--- Welcher Mittelwert ist der richtige? ---")
-print(f" Arithmetisches Mittel (diskret): {arithmetisch*100:+8.2f} % "
-f"-> ueberschaetzt")
-print(f" Geometrisches Mittel: {geometrisch*100:+8.2f} % -> korrekt")
-print(f" exp(Mittel der Log-Renditen) - 1: {aus_log*100:+8.2f} % "
-f"-> identisch zum geometrischen")
-
-# Probe: Endkapital mit dem jeweiligen Mittelwert hochgerechnet
- n =len(diskret)
-print(f"\n Probe - Startkapital 100 EUR ueber {n} Perioden:")
-print(f" tatsaechlich: {kurse[-1]:8.2f} EUR")
-print(f" mit arithm. Mittel hochgerechnet: {100*(1+arithmetisch)**n:8.2f} EUR")
-print(f" mit geom. Mittel hochgerechnet: {100*(1+geometrisch)**n:8.2f} EUR")
-
-print("\n--- Annualisierung (252 Handelstage) ---")
-print(f" Volatilitaet aus Log-Renditen: "
-f"{logarithmisch.std(ddof=1)*np.sqrt(252)*100:.2f} % p.a.")
-print(" (Die Wurzel-Zeit-Regel gilt nur bei unabhaengigen Renditen -")
-print(" fuer reale Maerkte ist sie eine Naeherung.)")
-print("="*74)
+
#!/usr/bin/env python3
+
+# Renditen_Vergleich.py
+"""
+Kapitel Finanzdaten: Diskrete vs. logarithmische Renditen.
+Zeigt an einem simulierten Kursverlauf, welche Eigenschaft wo gilt -
+und warum das arithmetische Mittel diskreter Renditen in die Irre fuehrt.
+"""
+
+import numpy as np
+import pandas as pd
+
+if__name__=="__main__":
+ kurse = np.array([100.0, 150.0, 100.0, 120.0, 90.0, 135.0])
+ tage = [f"t{i}"for i inrange(len(kurse))]
+
+ diskret = kurse[1:] / kurse[:-1] -1.0
+ logarithmisch = np.log(kurse[1:] / kurse[:-1])
+
+print("="*74)
+print(" DISKRETE UND LOGARITHMISCHE RENDITEN IM VERGLEICH")
+print("="*74)
+ tabelle = pd.DataFrame({
+"Periode": [f"{tage[i]} -> {tage[i+1]}"for i inrange(len(diskret))],
+"Kurs von": kurse[:-1],
+"Kurs bis": kurse[1:],
+"diskret R": [f"{r*100:+7.2f} %"for r in diskret],
+"log r": [f"{r:+8.4f}"for r in logarithmisch],
+ })
+print(tabelle.to_string(index=False))
+
+# --- Zeitliche Aggregation ------------------------------------------
+ gesamt_wahr = kurse[-1] / kurse[0] -1.0
+ summe_log = logarithmisch.sum()
+ aus_log_zurueck = np.exp(summe_log) -1.0
+ summe_diskret = diskret.sum()
+
+print("\n--- Aggregation ueber die Zeit ---")
+print(f" Tatsaechliche Gesamtrendite: {gesamt_wahr*100:+8.2f} %")
+print(f" Summe der Log-Renditen -> exp()-1: {aus_log_zurueck*100:+8.2f} % "
+f"{'KORREKT'ifabs(aus_log_zurueck - gesamt_wahr) <1e-9else'falsch'}")
+print(f" Summe der diskreten Renditen: {summe_diskret*100:+8.2f} % FALSCH")
+
+# --- Mittelwerte ------------------------------------------------------
+ arithmetisch = diskret.mean()
+ geometrisch = np.prod(1+ diskret) ** (1/len(diskret)) -1
+ aus_log = np.exp(logarithmisch.mean()) -1
+
+print("\n--- Welcher Mittelwert ist der richtige? ---")
+print(f" Arithmetisches Mittel (diskret): {arithmetisch*100:+8.2f} % "
+f"-> ueberschaetzt")
+print(f" Geometrisches Mittel: {geometrisch*100:+8.2f} % -> korrekt")
+print(f" exp(Mittel der Log-Renditen) - 1: {aus_log*100:+8.2f} % "
+f"-> identisch zum geometrischen")
+
+# Probe: Endkapital mit dem jeweiligen Mittelwert hochgerechnet
+ n =len(diskret)
+print(f"\n Probe - Startkapital 100 EUR ueber {n} Perioden:")
+print(f" tatsaechlich: {kurse[-1]:8.2f} EUR")
+print(f" mit arithm. Mittel hochgerechnet: {100*(1+arithmetisch)**n:8.2f} EUR")
+print(f" mit geom. Mittel hochgerechnet: {100*(1+geometrisch)**n:8.2f} EUR")
+
+print("\n--- Annualisierung (252 Handelstage) ---")
+print(f" Volatilitaet aus Log-Renditen: "
+f"{logarithmisch.std(ddof=1)*np.sqrt(252)*100:.2f} % p.a.")
+print(" (Die Wurzel-Zeit-Regel gilt nur bei unabhaengigen Renditen -")
+print(" fuer reale Maerkte ist sie eine Naeherung.)")
+print("="*74)
18.4 Das Schätzfehler-Problem
Sei \mathbf{X}\in\mathbb{R}^{T\times N} die Matrix zentrierter Renditen von N Titeln über T Handelstage. Die Stichproben-Kovarianzmatrix lautet:
🎯 Der Error-Maximizer-Effekt Ein Risikominimierer sucht die Richtungen mit der kleinsten Varianz — also genau jene Eigenrichtungen, deren Eigenwerte am stärksten nach unten verzerrt sind. Die Optimierung greift damit zielsicher in das Schätzrauschen hinein. Markowitz-Optimierung ist deshalb kein Fehlerdämpfer, sondern ein Fehlerverstärker.
-
#!/usr/bin/env python3
-
-# Schaetzrauschen_Demo.py
-"""
-Kapitel Finanzdaten: Der Error-Maximizer-Effekt im Experiment.
-
-Aufbau: Wir KENNEN die wahre Kovarianzmatrix, weil wir die Daten selbst
-erzeugen. Dann schaetzen wir sie aus endlich vielen Beobachtungen und
-vergleichen, wie gut das daraus optimierte Portfolio in der WAHREN Welt
-abschneidet - mit und ohne Shrinkage.
-
-Das ist der einzige saubere Weg, Schaetzfehler zu messen: Man braucht eine
-Wahrheit zum Vergleich, und die gibt es nur in der Simulation.
-"""
-
-import numpy as np
-from sklearn.covariance import LedoitWolf
-
-
-def erzeuge_wahre_kovarianz(n, rng):
-"""Ein-Faktor-Modell: gemeinsamer Marktfaktor plus titelspezifisches Rauschen."""
- beta = rng.uniform(0.6, 1.4, size=n) # Marktsensitivitaeten
- var_markt =0.04# Marktvarianz p.a.
- var_spezifisch = rng.uniform(0.01, 0.09, size=n) # idiosynkratische Varianz
-return np.outer(beta, beta) * var_markt + np.diag(var_spezifisch)
-
-
-def minimum_varianz_gewichte(sigma):
-"""Analytische GMV-Loesung: w = Sigma^-1 1 / (1' Sigma^-1 1). Ohne Restriktionen."""
- eins = np.ones(len(sigma))
-try:
- loesung = np.linalg.solve(sigma, eins)
-except np.linalg.LinAlgError:
- loesung = np.linalg.pinv(sigma) @ eins # falls singulaer
-return loesung / loesung.sum()
-
-
-def portfoliovolatilitaet(w, sigma):
-returnfloat(np.sqrt(w @ sigma @ w))
-
-
-if__name__=="__main__":
- rng = np.random.default_rng(2026)
- N =40# Anzahl Titel
- WIEDERHOLUNGEN =200
-
- sigma_wahr = erzeuge_wahre_kovarianz(N, rng)
- w_ideal = minimum_varianz_gewichte(sigma_wahr)
- vola_ideal = portfoliovolatilitaet(w_ideal, sigma_wahr)
-
-print("="*92)
-print(" ERROR-MAXIMIZER: WIE TEUER IST SCHAETZRAUSCHEN?")
-print("="*92)
-print(f"Aufbau: {N} Titel, wahre Kovarianz bekannt (Ein-Faktor-Modell).")
-print(f"Bestmoegliche Volatilitaet bei perfektem Wissen: "
-f"{vola_ideal*100:.2f} % p.a.\n")
-
-print(f"{'T (Tage)':>9} | {'T/N':>5} | {'Stichprobe':>22} | {'Ledoit-Wolf':>22} | "
-f"{'Shrink':>7}")
-print(f"{'':>9} | {'':>5} | {'Vola Aufschlag':>22} | "
-f"{'Vola Aufschlag':>22} | {'delta':>7}")
-print("-"*92)
-
-for T in [60, 120, 252, 504, 1260]:
- vola_stichprobe, vola_lw, deltas = [], [], []
-
-for _ inrange(WIEDERHOLUNGEN):
-# Daten aus der WAHREN Verteilung ziehen (taeglich)
- daten = rng.multivariate_normal(np.zeros(N), sigma_wahr /252, size=T)
-
-# (a) Stichproben-Kovarianz
- s_stich = np.cov(daten, rowvar=False, ddof=1) *252
- w_stich = minimum_varianz_gewichte(s_stich)
-
-# (b) Ledoit-Wolf-Shrinkage
- lw = LedoitWolf().fit(daten)
- s_lw = lw.covariance_ *252
- w_lw = minimum_varianz_gewichte(s_lw)
- deltas.append(lw.shrinkage_)
-
-# Bewertung IMMER mit der wahren Kovarianz - das ist der Punkt
- vola_stichprobe.append(portfoliovolatilitaet(w_stich, sigma_wahr))
- vola_lw.append(portfoliovolatilitaet(w_lw, sigma_wahr))
-
- m_stich, m_lw = np.mean(vola_stichprobe), np.mean(vola_lw)
-print(f"{T:>9} | {T/N:>5.1f} | {m_stich*100:>8.2f} % "
-f"{(m_stich/vola_ideal-1)*100:>+9.1f} % | "
-f"{m_lw*100:>8.2f} % {(m_lw/vola_ideal-1)*100:>+9.1f} % | "
-f"{np.mean(deltas):>7.3f}")
-
-print("-"*92)
-print("'Aufschlag' = wie viel mehr Risiko das Portfolio in der WAHREN Welt traegt,")
-print("verglichen mit dem Portfolio bei perfektem Wissen.")
-print("Lesart: Je kleiner T/N, desto teurer das Schaetzrauschen - und desto mehr")
-print("schrumpft Ledoit-Wolf (delta steigt).")
-print("="*92)
+
#!/usr/bin/env python3
+
+# Schaetzrauschen_Demo.py
+"""
+Kapitel Finanzdaten: Der Error-Maximizer-Effekt im Experiment.
+
+Aufbau: Wir KENNEN die wahre Kovarianzmatrix, weil wir die Daten selbst
+erzeugen. Dann schaetzen wir sie aus endlich vielen Beobachtungen und
+vergleichen, wie gut das daraus optimierte Portfolio in der WAHREN Welt
+abschneidet - mit und ohne Shrinkage.
+
+Das ist der einzige saubere Weg, Schaetzfehler zu messen: Man braucht eine
+Wahrheit zum Vergleich, und die gibt es nur in der Simulation.
+"""
+
+import numpy as np
+from sklearn.covariance import LedoitWolf
+
+
+def erzeuge_wahre_kovarianz(n, rng):
+"""Ein-Faktor-Modell: gemeinsamer Marktfaktor plus titelspezifisches Rauschen."""
+ beta = rng.uniform(0.6, 1.4, size=n) # Marktsensitivitaeten
+ var_markt =0.04# Marktvarianz p.a.
+ var_spezifisch = rng.uniform(0.01, 0.09, size=n) # idiosynkratische Varianz
+return np.outer(beta, beta) * var_markt + np.diag(var_spezifisch)
+
+
+def minimum_varianz_gewichte(sigma):
+"""Analytische GMV-Loesung: w = Sigma^-1 1 / (1' Sigma^-1 1). Ohne Restriktionen."""
+ eins = np.ones(len(sigma))
+try:
+ loesung = np.linalg.solve(sigma, eins)
+except np.linalg.LinAlgError:
+ loesung = np.linalg.pinv(sigma) @ eins # falls singulaer
+return loesung / loesung.sum()
+
+
+def portfoliovolatilitaet(w, sigma):
+returnfloat(np.sqrt(w @ sigma @ w))
+
+
+if__name__=="__main__":
+ rng = np.random.default_rng(2026)
+ N =40# Anzahl Titel
+ WIEDERHOLUNGEN =200
+
+ sigma_wahr = erzeuge_wahre_kovarianz(N, rng)
+ w_ideal = minimum_varianz_gewichte(sigma_wahr)
+ vola_ideal = portfoliovolatilitaet(w_ideal, sigma_wahr)
+
+print("="*92)
+print(" ERROR-MAXIMIZER: WIE TEUER IST SCHAETZRAUSCHEN?")
+print("="*92)
+print(f"Aufbau: {N} Titel, wahre Kovarianz bekannt (Ein-Faktor-Modell).")
+print(f"Bestmoegliche Volatilitaet bei perfektem Wissen: "
+f"{vola_ideal*100:.2f} % p.a.\n")
+
+print(f"{'T (Tage)':>9} | {'T/N':>5} | {'Stichprobe':>22} | {'Ledoit-Wolf':>22} | "
+f"{'Shrink':>7}")
+print(f"{'':>9} | {'':>5} | {'Vola Aufschlag':>22} | "
+f"{'Vola Aufschlag':>22} | {'delta':>7}")
+print("-"*92)
+
+for T in [60, 120, 252, 504, 1260]:
+ vola_stichprobe, vola_lw, deltas = [], [], []
+
+for _ inrange(WIEDERHOLUNGEN):
+# Daten aus der WAHREN Verteilung ziehen (taeglich)
+ daten = rng.multivariate_normal(np.zeros(N), sigma_wahr /252, size=T)
+
+# (a) Stichproben-Kovarianz
+ s_stich = np.cov(daten, rowvar=False, ddof=1) *252
+ w_stich = minimum_varianz_gewichte(s_stich)
+
+# (b) Ledoit-Wolf-Shrinkage
+ lw = LedoitWolf().fit(daten)
+ s_lw = lw.covariance_ *252
+ w_lw = minimum_varianz_gewichte(s_lw)
+ deltas.append(lw.shrinkage_)
+
+# Bewertung IMMER mit der wahren Kovarianz - das ist der Punkt
+ vola_stichprobe.append(portfoliovolatilitaet(w_stich, sigma_wahr))
+ vola_lw.append(portfoliovolatilitaet(w_lw, sigma_wahr))
+
+ m_stich, m_lw = np.mean(vola_stichprobe), np.mean(vola_lw)
+print(f"{T:>9} | {T/N:>5.1f} | {m_stich*100:>8.2f} % "
+f"{(m_stich/vola_ideal-1)*100:>+9.1f} % | "
+f"{m_lw*100:>8.2f} % {(m_lw/vola_ideal-1)*100:>+9.1f} % | "
+f"{np.mean(deltas):>7.3f}")
+
+print("-"*92)
+print("'Aufschlag' = wie viel mehr Risiko das Portfolio in der WAHREN Welt traegt,")
+print("verglichen mit dem Portfolio bei perfektem Wissen.")
+print("Lesart: Je kleiner T/N, desto teurer das Schaetzrauschen - und desto mehr")
+print("schrumpft Ledoit-Wolf (delta steigt).")
+print("="*92)
Wer danach mit Positionsindizes arbeitet, beschriftet Ergebnisse falsch und beschränkt falsche Sektoren. Die Abhilfe ist eine Zeile:
-
prices = raw["Close"][tickers].dropna() # erzwingt die eigene Reihenfolge
-assertlist(prices.columns) == tickers
+
prices = raw["Close"][tickers].dropna() # erzwingt die eigene Reihenfolge
+assertlist(prices.columns) == tickers
-
#!/usr/bin/env python3
-
-# Finanzdaten_Ledoit_Wolf.py
-"""
-Kapitel Finanzdaten: Automatisierte Finanzdaten-Pipeline und Kovarianz-Shrinkage.
-
-Eigenschaften:
- * Spaltenreihenfolge wird erzwungen und per assert geprueft
- * beide Shrinkage-Ziele werden berechnet und verglichen
- * Datenqualitaetspruefungen (Luecken, Ausreisser, Mindestlaenge)
- * Eigenwertspektrum wird ausgewiesen, nicht nur die Konditionszahl
-"""
-
-import numpy as np
-import pandas as pd
-import yfinance as yf
-from sklearn.covariance import LedoitWolf
-
-HANDELSTAGE =252
-
-
-def lade_kurse(tickers: list[str], start, ende) -> pd.DataFrame:
-"""Laedt bereinigte Schlusskurse und garantiert die Spaltenreihenfolge."""
-print(f"Lade {len(tickers)} Ticker von {start} bis {ende} ...")
- roh = yf.download(tickers, start=start, end=ende, auto_adjust=True, progress=False)
-if roh.empty:
-raiseSystemExit("Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).")
-
-ifisinstance(roh.columns, pd.MultiIndex):
-# [tickers] erzwingt die gewuenschte Spaltenreihenfolge
- kurse = roh["Close"][tickers].dropna()
-else:
- kurse = roh[["Close"]].dropna()
- kurse.columns = tickers
-
-assertlist(kurse.columns) == tickers, (
-f"Spaltenreihenfolge weicht ab!\n erwartet: {tickers}\n"
-f" erhalten: {list(kurse.columns)}")
-return kurse
-
-
-def pruefe_datenqualitaet(kurse: pd.DataFrame, mindest_tage: int=200) ->None:
-"""Faengt die haeufigsten Datenprobleme ab, bevor sie ins Modell gelangen."""
- T, N = kurse.shape
-print(f"\nDatenqualitaet: {T} Handelstage, {N} Titel (T/N = {T/N:.1f})")
-if T < mindest_tage:
-raiseSystemExit(f"Zu wenige Beobachtungen ({T} < {mindest_tage}).")
-if T < N:
-print(" WARNUNG: T < N - die Stichprobenkovarianz ist singulaer!")
-elif T <3* N:
-print(" WARNUNG: T < 3N - erhebliches Schaetzrauschen zu erwarten.")
-
- renditen = kurse.pct_change().dropna()
- extreme = (renditen.abs() >0.25).sum().sum()
-if extreme:
-print(f" Hinweis: {extreme} Tagesrenditen ueber 25 % "
-f"(Splits, Sondersituationen oder Datenfehler pruefen).")
- luecken = kurse.isna().sum().sum()
-print(f" Fehlende Werte nach dropna: {luecken}")
-
-
-def ziel_konstante_korrelation(renditen: np.ndarray) -> np.ndarray:
-"""
- Shrinkage-Ziel nach Ledoit/Wolf 2003: Einzelvarianzen behalten,
- alle Korrelationen durch ihren Mittelwert ersetzen.
- (sklearn nutzt stattdessen die skalierte Einheitsmatrix - siehe oben.)
- """
- S = np.cov(renditen, rowvar=False, ddof=1)
- d = np.sqrt(np.diag(S))
- korrelation = S / np.outer(d, d)
- n =len(S)
- r_quer = (korrelation.sum() - n) / (n * (n -1)) # Mittel ohne Diagonale
- F = r_quer * np.outer(d, d)
- np.fill_diagonal(F, np.diag(S))
-return F
-
-
-def analysiere(kurse: pd.DataFrame):
- renditen = kurse.pct_change().dropna()
- T, N = renditen.shape
-
- mu = renditen.mean().values * HANDELSTAGE
- sigma_stichprobe = renditen.cov().values * HANDELSTAGE
-
- lw = LedoitWolf(assume_centered=False).fit(renditen.values)
- sigma_lw = lw.covariance_ * HANDELSTAGE
- delta = lw.shrinkage_
-
-print("\n"+"="*84)
-print(" FINANZDATEN-PIPELINE UND MATRIX-KONDITIONIERUNG")
-print("="*84)
-print(f"Beobachtungen T: {T}")
-print(f"Titel N: {N}")
-print(f"Optimales Shrinkage delta: {delta:.4f} "
-f"({delta*100:.1f} % Gewicht auf dem strukturierten Ziel)")
-
- eig_stich = np.linalg.eigvalsh(sigma_stichprobe)
- eig_lw = np.linalg.eigvalsh(sigma_lw)
-
-print("\n--- Eigenwertspektrum (annualisiert) ---")
-print(f"{'':<14}{'kleinster':>12}{'Median':>12}{'groesster':>12} "
-f"{'Kondition':>12}")
-for name, eig in [("Stichprobe", eig_stich), ("Ledoit-Wolf", eig_lw)]:
- kondition = eig.max() /max(eig.min(), 1e-12)
-print(f"{name:<14}{eig.min():>12.6f}{np.median(eig):>12.6f} "
-f"{eig.max():>12.6f}{kondition:>12.1f}")
-
-print("\nDie Konditionszahl misst, wie stark sich kleine Datenaenderungen auf")
-print("die Inverse auswirken. Je kleiner, desto stabiler die Portfoliogewichte.")
-
-# --- Vergleich der beiden Shrinkage-Ziele ----------------------------
- S_taeglich = np.cov(renditen.values, rowvar=False, ddof=1)
- F_identitaet = np.eye(N) * np.trace(S_taeglich) / N
- F_korrelation = ziel_konstante_korrelation(renditen.values)
-
-print("\n--- Die beiden Shrinkage-Ziele im Vergleich ---")
-for name, F in [("skalierte Einheitsmatrix (sklearn)", F_identitaet),
- ("konstante Korrelation (LW 2003)", F_korrelation)]:
- gemischt = ((1- delta) * S_taeglich + delta * F) * HANDELSTAGE
- eig = np.linalg.eigvalsh(gemischt)
-print(f" {name:<36}: Kondition {eig.max()/max(eig.min(),1e-12):8.1f}, "
-f"kleinster EW {eig.min():.6f}")
-
-print("\n--- Erwartete Renditen (annualisiert) ---")
- uebersicht = pd.DataFrame({
-"Ticker": kurse.columns,
-"Rendite p.a.": [f"{r*100:+7.2f} %"for r in mu],
-"Volatilitaet p.a.": [f"{np.sqrt(sigma_lw[i, i])*100:6.2f} %"for i inrange(N)],
- })
-print(uebersicht.to_string(index=False))
-print("="*84)
-return renditen, mu, sigma_stichprobe, sigma_lw
-
-
-if__name__=="__main__":
- UNIVERSUM = ["AAPL", "MSFT", "NVDA", "AMZN", # Technologie
-"JNJ", "PFE", # Gesundheit
-"JPM", "GS", # Banken
-"XOM", "CVX"] # Energie
-
-# Relatives 2-Jahres-Fenster, damit das Beispiel nicht "altert"
- ende = pd.Timestamp.today().normalize()
- start = ende - pd.DateOffset(years=2)
-
- kurse = lade_kurse(UNIVERSUM, start.strftime("%Y-%m-%d"), ende.strftime("%Y-%m-%d"))
- pruefe_datenqualitaet(kurse)
- analysiere(kurse)
+
#!/usr/bin/env python3
+
+# Finanzdaten_Ledoit_Wolf.py
+"""
+Kapitel Finanzdaten: Automatisierte Finanzdaten-Pipeline und Kovarianz-Shrinkage.
+
+Eigenschaften:
+ * Spaltenreihenfolge wird erzwungen und per assert geprueft
+ * beide Shrinkage-Ziele werden berechnet und verglichen
+ * Datenqualitaetspruefungen (Luecken, Ausreisser, Mindestlaenge)
+ * Eigenwertspektrum wird ausgewiesen, nicht nur die Konditionszahl
+"""
+
+import numpy as np
+import pandas as pd
+import yfinance as yf
+from sklearn.covariance import LedoitWolf
+
+HANDELSTAGE =252
+
+
+def lade_kurse(tickers: list[str], start, ende) -> pd.DataFrame:
+"""Laedt bereinigte Schlusskurse und garantiert die Spaltenreihenfolge."""
+print(f"Lade {len(tickers)} Ticker von {start} bis {ende} ...")
+ roh = yf.download(tickers, start=start, end=ende, auto_adjust=True, progress=False)
+if roh.empty:
+raiseSystemExit("Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).")
+
+ifisinstance(roh.columns, pd.MultiIndex):
+# [tickers] erzwingt die gewuenschte Spaltenreihenfolge
+ kurse = roh["Close"][tickers].dropna()
+else:
+ kurse = roh[["Close"]].dropna()
+ kurse.columns = tickers
+
+assertlist(kurse.columns) == tickers, (
+f"Spaltenreihenfolge weicht ab!\n erwartet: {tickers}\n"
+f" erhalten: {list(kurse.columns)}")
+return kurse
+
+
+def pruefe_datenqualitaet(kurse: pd.DataFrame, mindest_tage: int=200) ->None:
+"""Faengt die haeufigsten Datenprobleme ab, bevor sie ins Modell gelangen."""
+ T, N = kurse.shape
+print(f"\nDatenqualitaet: {T} Handelstage, {N} Titel (T/N = {T/N:.1f})")
+if T < mindest_tage:
+raiseSystemExit(f"Zu wenige Beobachtungen ({T} < {mindest_tage}).")
+if T < N:
+print(" WARNUNG: T < N - die Stichprobenkovarianz ist singulaer!")
+elif T <3* N:
+print(" WARNUNG: T < 3N - erhebliches Schaetzrauschen zu erwarten.")
+
+ renditen = kurse.pct_change().dropna()
+ extreme = (renditen.abs() >0.25).sum().sum()
+if extreme:
+print(f" Hinweis: {extreme} Tagesrenditen ueber 25 % "
+f"(Splits, Sondersituationen oder Datenfehler pruefen).")
+ luecken = kurse.isna().sum().sum()
+print(f" Fehlende Werte nach dropna: {luecken}")
+
+
+def ziel_konstante_korrelation(renditen: np.ndarray) -> np.ndarray:
+"""
+ Shrinkage-Ziel nach Ledoit/Wolf 2003: Einzelvarianzen behalten,
+ alle Korrelationen durch ihren Mittelwert ersetzen.
+ (sklearn nutzt stattdessen die skalierte Einheitsmatrix - siehe oben.)
+ """
+ S = np.cov(renditen, rowvar=False, ddof=1)
+ d = np.sqrt(np.diag(S))
+ korrelation = S / np.outer(d, d)
+ n =len(S)
+ r_quer = (korrelation.sum() - n) / (n * (n -1)) # Mittel ohne Diagonale
+ F = r_quer * np.outer(d, d)
+ np.fill_diagonal(F, np.diag(S))
+return F
+
+
+def analysiere(kurse: pd.DataFrame):
+ renditen = kurse.pct_change().dropna()
+ T, N = renditen.shape
+
+ mu = renditen.mean().values * HANDELSTAGE
+ sigma_stichprobe = renditen.cov().values * HANDELSTAGE
+
+ lw = LedoitWolf(assume_centered=False).fit(renditen.values)
+ sigma_lw = lw.covariance_ * HANDELSTAGE
+ delta = lw.shrinkage_
+
+print("\n"+"="*84)
+print(" FINANZDATEN-PIPELINE UND MATRIX-KONDITIONIERUNG")
+print("="*84)
+print(f"Beobachtungen T: {T}")
+print(f"Titel N: {N}")
+print(f"Optimales Shrinkage delta: {delta:.4f} "
+f"({delta*100:.1f} % Gewicht auf dem strukturierten Ziel)")
+
+ eig_stich = np.linalg.eigvalsh(sigma_stichprobe)
+ eig_lw = np.linalg.eigvalsh(sigma_lw)
+
+print("\n--- Eigenwertspektrum (annualisiert) ---")
+print(f"{'':<14}{'kleinster':>12}{'Median':>12}{'groesster':>12} "
+f"{'Kondition':>12}")
+for name, eig in [("Stichprobe", eig_stich), ("Ledoit-Wolf", eig_lw)]:
+ kondition = eig.max() /max(eig.min(), 1e-12)
+print(f"{name:<14}{eig.min():>12.6f}{np.median(eig):>12.6f} "
+f"{eig.max():>12.6f}{kondition:>12.1f}")
+
+print("\nDie Konditionszahl misst, wie stark sich kleine Datenaenderungen auf")
+print("die Inverse auswirken. Je kleiner, desto stabiler die Portfoliogewichte.")
+
+# --- Vergleich der beiden Shrinkage-Ziele ----------------------------
+ S_taeglich = np.cov(renditen.values, rowvar=False, ddof=1)
+ F_identitaet = np.eye(N) * np.trace(S_taeglich) / N
+ F_korrelation = ziel_konstante_korrelation(renditen.values)
+
+print("\n--- Die beiden Shrinkage-Ziele im Vergleich ---")
+for name, F in [("skalierte Einheitsmatrix (sklearn)", F_identitaet),
+ ("konstante Korrelation (LW 2003)", F_korrelation)]:
+ gemischt = ((1- delta) * S_taeglich + delta * F) * HANDELSTAGE
+ eig = np.linalg.eigvalsh(gemischt)
+print(f" {name:<36}: Kondition {eig.max()/max(eig.min(),1e-12):8.1f}, "
+f"kleinster EW {eig.min():.6f}")
+
+print("\n--- Erwartete Renditen (annualisiert) ---")
+ uebersicht = pd.DataFrame({
+"Ticker": kurse.columns,
+"Rendite p.a.": [f"{r*100:+7.2f} %"for r in mu],
+"Volatilitaet p.a.": [f"{np.sqrt(sigma_lw[i, i])*100:6.2f} %"for i inrange(N)],
+ })
+print(uebersicht.to_string(index=False))
+print("="*84)
+return renditen, mu, sigma_stichprobe, sigma_lw
+
+
+if__name__=="__main__":
+ UNIVERSUM = ["AAPL", "MSFT", "NVDA", "AMZN", # Technologie
+"JNJ", "PFE", # Gesundheit
+"JPM", "GS", # Banken
+"XOM", "CVX"] # Energie
+
+# Relatives 2-Jahres-Fenster, damit das Beispiel nicht "altert"
+ ende = pd.Timestamp.today().normalize()
+ start = ende - pd.DateOffset(years=2)
+
+ kurse = lade_kurse(UNIVERSUM, start.strftime("%Y-%m-%d"), ende.strftime("%Y-%m-%d"))
+ pruefe_datenqualitaet(kurse)
+ analysiere(kurse)
⚠️ Zur Reproduzierbarkeit Dieses Programm lädt Live-Daten. Ihre Zahlen werden von den hier abgedruckten abweichen — je nach Abrufdatum. Das ist beabsichtigt und selbst eine Lektion: Wenn sich das optimale Portfolio zwischen zwei Abrufen deutlich ändert, obwohl nur ein paar Tage vergangen sind, sehen Sie den Schätzfehler direkt. Prüfen Sie das ruhig einmal.
Das folgende Programm führt den Fall vor. Es arbeitet mit synthetischen Daten, damit das Ergebnis reproduzierbar ist und kein Internetzugang nötig wird — mit echten Kursdaten sieht es genauso aus.
-
#!/usr/bin/env python3
-
-# Kovarianz_Falle.py
-"""
-Kapitel Finanzdaten: Warum die historische Kovarianzmatrix den Optimierer belaeugt.
-
-Ein Portfolio aus 30 Anlagen soll das Risiko minimieren. Geschaetzt wird die
-Kovarianzmatrix aus 20 Beobachtungen - in der Praxis ein voellig ueblicher
-Fall (30 Titel, ein Quartal Wochendaten).
-
-Das Ergebnis ist ein Portfolio mit einem gemessenen Risiko von 0,000 %. Der
-Optimierer haelt es fuer risikofrei. Ausserhalb des Schaetzzeitraums ist es
-das schlechteste der drei untersuchten Portfolios - schlechter sogar als
-blosse Gleichgewichtung.
-
-Der Grund ist nicht Zufall, sondern Struktur: Bei N Anlagen und T < N
-Beobachtungen hat die Stichproben-Kovarianzmatrix hoechstens Rang T-1. Es gibt
-dann ganze Richtungen im Gewichtsraum, in denen sie exakt null Varianz misst -
-Richtungen, die es in Wirklichkeit nicht gibt. Der Optimierer findet sie
-zuverlaessig, denn er sucht ja genau danach.
-
-Die Daten sind synthetisch (ein Marktfaktor plus Eigenrauschen), damit das
-Ergebnis reproduzierbar ist und kein Internetzugang noetig wird. Mit echten
-Kursdaten sieht es genauso aus.
-
-Benoetigt: numpy, cvxpy, scikit-learn
-"""
-
-from __future__ import annotations
-
-import numpy as np
-import cvxpy as cp
-from sklearn.covariance import LedoitWolf
-
-ANZAHL_ANLAGEN =30
-BEOBACHTUNGEN_SCHAETZUNG =20# weniger als Anlagen - genau darum geht es
-BEOBACHTUNGEN_TEST =2000# der "spaetere Verlauf"
-
-RNG = np.random.default_rng(11)
-# Jede Anlage reagiert unterschiedlich stark auf den Gesamtmarkt.
-BETA = RNG.uniform(0.6, 1.4, ANZAHL_ANLAGEN)
-
-
-def erzeuge_renditen(perioden: int) -> np.ndarray:
-"""Ein Marktfaktor, auf den alle reagieren, plus Eigenrauschen je Anlage."""
- markt = RNG.normal(0.0, 0.010, perioden)
- eigen = RNG.normal(0.0, 0.012, (perioden, ANZAHL_ANLAGEN))
-return np.outer(markt, BETA) + eigen
-
-
-def minimales_risiko(kovarianz: np.ndarray) ->tuple[np.ndarray, float]:
-"""Minimum-Varianz-Portfolio: Gewichte summieren sich zu 1, sonst frei.
-
- cp.psd_wrap() sagt CVXPY: 'Ich weiss, dass diese Matrix numerisch nicht
- exakt positiv semidefinit ist - rechne trotzdem.' Das ist hier bewusst so
- gewaehlt, denn genau diese Eigenschaft wollen wir vorfuehren. Im
- Produktivbetrieb waere die DCP-Fehlermeldung das Warnsignal, dem man
- nachgehen muss (siehe Denkfehler 8.1).
- """
- gewichte = cp.Variable(ANZAHL_ANLAGEN)
- problem = cp.Problem(cp.Minimize(cp.quad_form(gewichte, cp.psd_wrap(kovarianz))),
- [cp.sum(gewichte) ==1])
- problem.solve()
-return np.array(gewichte.value).ravel(), max(problem.value, 0.0)
-
-
-if__name__=="__main__":
- schaetzzeitraum = erzeuge_renditen(BEOBACHTUNGEN_SCHAETZUNG)
- spaeterer_verlauf = erzeuge_renditen(BEOBACHTUNGEN_TEST)
-
- stichprobe = np.cov(schaetzzeitraum, rowvar=False)
- ledoit_wolf = LedoitWolf().fit(schaetzzeitraum)
-
-print("="*78)
-print(" DIE KOVARIANZ-FALLE")
-print("="*78)
-print(f"{ANZAHL_ANLAGEN} Anlagen, geschaetzt aus {BEOBACHTUNGEN_SCHAETZUNG} "
-f"Beobachtungen.")
-print(f"Rang der Stichprobenmatrix: "
-f"{np.linalg.matrix_rank(stichprobe)} - noetig waeren {ANZAHL_ANLAGEN}.")
-print(f"Kleinster Eigenwert: {np.linalg.eigvalsh(stichprobe).min():.2e}")
-print()
-print("Es gibt also Richtungen, in denen diese Matrix EXAKT null Varianz")
-print("misst. Genau dort sucht ein Minimum-Varianz-Optimierer.")
-print()
-
-print(f"{'Schaetzer':<26}{'Risiko im Zeitraum':>19} "
-f"{'Risiko spaeter':>16}{'Hebel':>7}")
-print("-"*78)
-
- ergebnisse = {}
-for name, matrix in [
- ("Stichprobe (roh)", stichprobe),
- (f"Ledoit-Wolf (d={ledoit_wolf.shrinkage_:.2f})", ledoit_wolf.covariance_)]:
- gewichte, varianz_intern = minimales_risiko(matrix)
- risiko_spaeter =float(np.std(spaeterer_verlauf @ gewichte))
- hebel =float(np.abs(gewichte).sum())
- ergebnisse[name] = (risiko_spaeter, gewichte)
-print(f"{name:<26}{np.sqrt(varianz_intern) *100:>18.3f} % "
-f"{risiko_spaeter *100:>15.3f} % {hebel:>7.1f}")
-
- gleich = np.full(ANZAHL_ANLAGEN, 1.0/ ANZAHL_ANLAGEN)
-print(f"{'Gleichgewichtung 1/N':<26} "
-f"{float(np.std(schaetzzeitraum @ gleich)) *100:>18.3f} % "
-f"{float(np.std(spaeterer_verlauf @ gleich)) *100:>15.3f} % {1.0:>7.1f}")
-print("-"*78)
-
-print("\nDrei Beobachtungen:")
-print("1. Die rohe Schaetzung meldet 0,000 % Risiko - und liefert spaeter das")
-print(" SCHLECHTESTE Ergebnis der drei. Sie hat kein Portfolio optimiert,")
-print(" sondern eine Luecke in den eigenen Daten gefunden.")
-print("2. Die Gleichgewichtung, die gar nicht optimiert, schlaegt den rohen")
-print(" Optimierer deutlich. Das ist kein Zufallsbefund, sondern in der")
-print(" Literatur breit belegt.")
-print("3. Ledoit-Wolf ist im Schaetzzeitraum EHRLICHER (0,441 statt 0,000 %)")
-print(" und spaeter am besten. Der Hebel faellt von 3,8 auf 1,8 - das")
-print(" Portfolio wird auch praktisch handelbarer.")
-print()
-print("Merksatz: Ein Optimierer glaubt seinen Eingabedaten vollstaendig.")
-print("Je mehr Freiheit Sie ihm geben, desto gruendlicher findet er deren")
-print("Fehler. Das gilt fuer Kovarianzmatrizen wie fuer Lieferzeiten,")
-print("Ausfallraten und jede andere geschaetzte Groesse.")
-print("="*78)
+
#!/usr/bin/env python3
+
+# Kovarianz_Falle.py
+"""
+Kapitel Finanzdaten: Warum die historische Kovarianzmatrix den Optimierer belaeugt.
+
+Ein Portfolio aus 30 Anlagen soll das Risiko minimieren. Geschaetzt wird die
+Kovarianzmatrix aus 20 Beobachtungen - in der Praxis ein voellig ueblicher
+Fall (30 Titel, ein Quartal Wochendaten).
+
+Das Ergebnis ist ein Portfolio mit einem gemessenen Risiko von 0,000 %. Der
+Optimierer haelt es fuer risikofrei. Ausserhalb des Schaetzzeitraums ist es
+das schlechteste der drei untersuchten Portfolios - schlechter sogar als
+blosse Gleichgewichtung.
+
+Der Grund ist nicht Zufall, sondern Struktur: Bei N Anlagen und T < N
+Beobachtungen hat die Stichproben-Kovarianzmatrix hoechstens Rang T-1. Es gibt
+dann ganze Richtungen im Gewichtsraum, in denen sie exakt null Varianz misst -
+Richtungen, die es in Wirklichkeit nicht gibt. Der Optimierer findet sie
+zuverlaessig, denn er sucht ja genau danach.
+
+Die Daten sind synthetisch (ein Marktfaktor plus Eigenrauschen), damit das
+Ergebnis reproduzierbar ist und kein Internetzugang noetig wird. Mit echten
+Kursdaten sieht es genauso aus.
+
+Benoetigt: numpy, cvxpy, scikit-learn
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import cvxpy as cp
+from sklearn.covariance import LedoitWolf
+
+ANZAHL_ANLAGEN =30
+BEOBACHTUNGEN_SCHAETZUNG =20# weniger als Anlagen - genau darum geht es
+BEOBACHTUNGEN_TEST =2000# der "spaetere Verlauf"
+
+RNG = np.random.default_rng(11)
+# Jede Anlage reagiert unterschiedlich stark auf den Gesamtmarkt.
+BETA = RNG.uniform(0.6, 1.4, ANZAHL_ANLAGEN)
+
+
+def erzeuge_renditen(perioden: int) -> np.ndarray:
+"""Ein Marktfaktor, auf den alle reagieren, plus Eigenrauschen je Anlage."""
+ markt = RNG.normal(0.0, 0.010, perioden)
+ eigen = RNG.normal(0.0, 0.012, (perioden, ANZAHL_ANLAGEN))
+return np.outer(markt, BETA) + eigen
+
+
+def minimales_risiko(kovarianz: np.ndarray) ->tuple[np.ndarray, float]:
+"""Minimum-Varianz-Portfolio: Gewichte summieren sich zu 1, sonst frei.
+
+ cp.psd_wrap() sagt CVXPY: 'Ich weiss, dass diese Matrix numerisch nicht
+ exakt positiv semidefinit ist - rechne trotzdem.' Das ist hier bewusst so
+ gewaehlt, denn genau diese Eigenschaft wollen wir vorfuehren. Im
+ Produktivbetrieb waere die DCP-Fehlermeldung das Warnsignal, dem man
+ nachgehen muss (siehe Denkfehler 8.1).
+ """
+ gewichte = cp.Variable(ANZAHL_ANLAGEN)
+ problem = cp.Problem(cp.Minimize(cp.quad_form(gewichte, cp.psd_wrap(kovarianz))),
+ [cp.sum(gewichte) ==1])
+ problem.solve()
+return np.array(gewichte.value).ravel(), max(problem.value, 0.0)
+
+
+if__name__=="__main__":
+ schaetzzeitraum = erzeuge_renditen(BEOBACHTUNGEN_SCHAETZUNG)
+ spaeterer_verlauf = erzeuge_renditen(BEOBACHTUNGEN_TEST)
+
+ stichprobe = np.cov(schaetzzeitraum, rowvar=False)
+ ledoit_wolf = LedoitWolf().fit(schaetzzeitraum)
+
+print("="*78)
+print(" DIE KOVARIANZ-FALLE")
+print("="*78)
+print(f"{ANZAHL_ANLAGEN} Anlagen, geschaetzt aus {BEOBACHTUNGEN_SCHAETZUNG} "
+f"Beobachtungen.")
+print(f"Rang der Stichprobenmatrix: "
+f"{np.linalg.matrix_rank(stichprobe)} - noetig waeren {ANZAHL_ANLAGEN}.")
+print(f"Kleinster Eigenwert: {np.linalg.eigvalsh(stichprobe).min():.2e}")
+print()
+print("Es gibt also Richtungen, in denen diese Matrix EXAKT null Varianz")
+print("misst. Genau dort sucht ein Minimum-Varianz-Optimierer.")
+print()
+
+print(f"{'Schaetzer':<26}{'Risiko im Zeitraum':>19} "
+f"{'Risiko spaeter':>16}{'Hebel':>7}")
+print("-"*78)
+
+ ergebnisse = {}
+for name, matrix in [
+ ("Stichprobe (roh)", stichprobe),
+ (f"Ledoit-Wolf (d={ledoit_wolf.shrinkage_:.2f})", ledoit_wolf.covariance_)]:
+ gewichte, varianz_intern = minimales_risiko(matrix)
+ risiko_spaeter =float(np.std(spaeterer_verlauf @ gewichte))
+ hebel =float(np.abs(gewichte).sum())
+ ergebnisse[name] = (risiko_spaeter, gewichte)
+print(f"{name:<26}{np.sqrt(varianz_intern) *100:>18.3f} % "
+f"{risiko_spaeter *100:>15.3f} % {hebel:>7.1f}")
+
+ gleich = np.full(ANZAHL_ANLAGEN, 1.0/ ANZAHL_ANLAGEN)
+print(f"{'Gleichgewichtung 1/N':<26} "
+f"{float(np.std(schaetzzeitraum @ gleich)) *100:>18.3f} % "
+f"{float(np.std(spaeterer_verlauf @ gleich)) *100:>15.3f} % {1.0:>7.1f}")
+print("-"*78)
+
+print("\nDrei Beobachtungen:")
+print("1. Die rohe Schaetzung meldet 0,000 % Risiko - und liefert spaeter das")
+print(" SCHLECHTESTE Ergebnis der drei. Sie hat kein Portfolio optimiert,")
+print(" sondern eine Luecke in den eigenen Daten gefunden.")
+print("2. Die Gleichgewichtung, die gar nicht optimiert, schlaegt den rohen")
+print(" Optimierer deutlich. Das ist kein Zufallsbefund, sondern in der")
+print(" Literatur breit belegt.")
+print("3. Ledoit-Wolf ist im Schaetzzeitraum EHRLICHER (0,441 statt 0,000 %)")
+print(" und spaeter am besten. Der Hebel faellt von 3,8 auf 1,8 - das")
+print(" Portfolio wird auch praktisch handelbarer.")
+print()
+print("Merksatz: Ein Optimierer glaubt seinen Eingabedaten vollstaendig.")
+print("Je mehr Freiheit Sie ihm geben, desto gruendlicher findet er deren")
+print("Fehler. Das gilt fuer Kovarianzmatrizen wie fuer Lieferzeiten,")
+print("Ausfallraten und jede andere geschaetzte Groesse.")
+print("="*78)
Erwartete Ausgabe:
==============================================================================
DIE KOVARIANZ-FALLE
@@ -21718,24 +22086,24 @@ Ausfallraten und jede andere geschaetzte Groesse.
Statt einer Antwort rechnen wir gleich die ganze Auswahl durch — für jede Zielrendite das Portfolio mit dem geringsten Risiko:
-
import numpy as np
-import cvxpy as cp
-
-mu = np.array([0.03, 0.08, 0.055]) # erwartete Rendite
-sig = np.array([0.05, 0.18, 0.11]) # Schwankung
-R = np.array([[1.0, 0.1, 0.2], [0.1, 1.0, 0.5], [0.2, 0.5, 1.0]])
-S = np.outer(sig, sig) * R # Kovarianzmatrix
-
-w, ziel = cp.Variable(3, nonneg=True), cp.Parameter(nonneg=True)
-problem = cp.Problem(cp.Minimize(cp.quad_form(w, S)),
- [cp.sum(w) ==1, mu @ w >= ziel])
-
-for z in [0.03, 0.04, 0.05, 0.06, 0.07, 0.08]:
- ziel.value = z
- problem.solve()
- risiko = np.sqrt(problem.value)
-print(f"{z*100:5.1f}% {risiko*100:7.2f}% {(mu @ w.value)/risiko:8.2f} "
-f"{np.round(w.value, 3)}")
+
import numpy as np
+import cvxpy as cp
+
+mu = np.array([0.03, 0.08, 0.055]) # erwartete Rendite
+sig = np.array([0.05, 0.18, 0.11]) # Schwankung
+R = np.array([[1.0, 0.1, 0.2], [0.1, 1.0, 0.5], [0.2, 0.5, 1.0]])
+S = np.outer(sig, sig) * R # Kovarianzmatrix
+
+w, ziel = cp.Variable(3, nonneg=True), cp.Parameter(nonneg=True)
+problem = cp.Problem(cp.Minimize(cp.quad_form(w, S)),
+ [cp.sum(w) ==1, mu @ w >= ziel])
+
+for z in [0.03, 0.04, 0.05, 0.06, 0.07, 0.08]:
+ ziel.value = z
+ problem.solve()
+ risiko = np.sqrt(problem.value)
+print(f"{z*100:5.1f}% {risiko*100:7.2f}% {(mu @ w.value)/risiko:8.2f} "
+f"{np.round(w.value, 3)}")
Die Kehrseite: In Krisen steigen Korrelationen. Ein Portfolio, dessen Diversifikation auf \rho = 0{,}2 beruht, kann bei \rho \to 0{,}9 plötzlich fast unkorrelierte Absicherung verlieren — genau dann, wenn man sie braucht. Kapitel 20 und Kapitel 22 kommen darauf zurück.
Die Tabelle aus der Handrechnung Das Gratis-Mittagessen in Zahlen lässt sich auf zwei unabhängigen Wegen prüfen: über die analytische Formel und über eine Simulation korrelierter Renditepaare, die von der Formel nichts weiß.
-
#!/usr/bin/env python3
-
-# Diversifikation_Demo.py
-"""
-Kapitel Markowitz: Portfoliorisiko in Abhaengigkeit von der Korrelation - die
-Handrechnung zum Gratis-Mittagessen, geprueft ueber die analytische Formel und
-ueber simulierte, korrelierte Renditen.
-"""
-
-import numpy as np
-
-MU =0.08
-SIGMA =0.20
-
-
-def sigma_portfolio(rho: float, w1: float=0.5) ->float:
-"""Analytische Formel: sigma_p^2 = w1^2 s1^2 + w2^2 s2^2 + 2 w1 w2 rho s1 s2."""
- w2 =1- w1
- varianz = w1**2* SIGMA**2+ w2**2* SIGMA**2+2* w1 * w2 * rho * SIGMA**2
-returnfloat(np.sqrt(varianz))
-
-
-def simuliere(rho: float, n: int=500_000, seed: int=7) ->float:
-"""Erzeugt n korrelierte Renditepaare und misst die Portfolio-Volatilitaet empirisch."""
- rng = np.random.default_rng(seed)
- kovarianz = np.array([[SIGMA**2, rho * SIGMA**2],
- [rho * SIGMA**2, SIGMA**2]])
- renditen = rng.multivariate_normal([MU, MU], kovarianz, size=n)
- portfolio =0.5* renditen[:, 0] +0.5* renditen[:, 1]
-returnfloat(portfolio.std(ddof=1))
-
-
-if__name__=="__main__":
-print("="*78)
-print(" PORTFOLIORISIKO IN ABHAENGIGKEIT VON DER KORRELATION")
-print("="*78)
-print(f"{'rho':>6} | {'sigma_p (Formel)':>18} | {'sigma_p (Simulation)':>20} | {'Risikoreduktion':>16}")
-print("-"*78)
-for rho in [1.0, 0.5, 0.0, -0.5, -1.0]:
- formel = sigma_portfolio(rho)
- sim = simuliere(rho)
- reduktion = (1- formel / SIGMA) *100
-print(f"{rho:6.1f} | {formel*100:16.2f} % | {sim*100:18.2f} % | {reduktion:14.0f} %")
-
-print("\nDie simulierten Werte (500.000 gezogene Renditepaare je rho) bestaetigen")
-print("die Formel aus der Handrechnung bis auf statistisches Rauschen - und das,")
-print("obwohl Formel und Simulation nichts voneinander wissen.")
+
#!/usr/bin/env python3
+
+# Diversifikation_Demo.py
+"""
+Kapitel Markowitz: Portfoliorisiko in Abhaengigkeit von der Korrelation - die
+Handrechnung zum Gratis-Mittagessen, geprueft ueber die analytische Formel und
+ueber simulierte, korrelierte Renditen.
+"""
+
+import numpy as np
+
+MU =0.08
+SIGMA =0.20
+
+
+def sigma_portfolio(rho: float, w1: float=0.5) ->float:
+"""Analytische Formel: sigma_p^2 = w1^2 s1^2 + w2^2 s2^2 + 2 w1 w2 rho s1 s2."""
+ w2 =1- w1
+ varianz = w1**2* SIGMA**2+ w2**2* SIGMA**2+2* w1 * w2 * rho * SIGMA**2
+returnfloat(np.sqrt(varianz))
+
+
+def simuliere(rho: float, n: int=500_000, seed: int=7) ->float:
+"""Erzeugt n korrelierte Renditepaare und misst die Portfolio-Volatilitaet empirisch."""
+ rng = np.random.default_rng(seed)
+ kovarianz = np.array([[SIGMA**2, rho * SIGMA**2],
+ [rho * SIGMA**2, SIGMA**2]])
+ renditen = rng.multivariate_normal([MU, MU], kovarianz, size=n)
+ portfolio =0.5* renditen[:, 0] +0.5* renditen[:, 1]
+returnfloat(portfolio.std(ddof=1))
+
+
+if__name__=="__main__":
+print("="*78)
+print(" PORTFOLIORISIKO IN ABHAENGIGKEIT VON DER KORRELATION")
+print("="*78)
+print(f"{'rho':>6} | {'sigma_p (Formel)':>18} | {'sigma_p (Simulation)':>20} | {'Risikoreduktion':>16}")
+print("-"*78)
+for rho in [1.0, 0.5, 0.0, -0.5, -1.0]:
+ formel = sigma_portfolio(rho)
+ sim = simuliere(rho)
+ reduktion = (1- formel / SIGMA) *100
+print(f"{rho:6.1f} | {formel*100:16.2f} % | {sim*100:18.2f} % | {reduktion:14.0f} %")
+
+print("\nDie simulierten Werte (500.000 gezogene Renditepaare je rho) bestaetigen")
+print("die Formel aus der Handrechnung bis auf statistisches Rauschen - und das,")
+print("obwohl Formel und Simulation nichts voneinander wissen.")
Erwartete Ausgabe:
==============================================================================
PORTFOLIORISIKO IN ABHAENGIGKEIT VON DER KORRELATION
@@ -22090,279 +22458,279 @@ obwohl Formel und Simulation nichts voneinander wissen.
⚠️ Sektoren niemals über Positionsindizes definieren
Ein naiver, aber gefährlicher Ansatz definiert den Technologiesektor so:
Weil yfinance alphabetisch sortiert, trifft das tatsächlich auf AAPL, AMZN, CVX (Chevron) und GS (Goldman Sachs) zu — Microsoft und NVIDIA blieben unbeschränkt.
Robust ist die Definition über Namen:
-
SEKTOREN = {"Technologie": ["AAPL", "MSFT", "NVDA", "AMZN"], ...}
-idx = [tickers.index(t) for t in SEKTOREN["Technologie"]]
+
SEKTOREN = {"Technologie": ["AAPL", "MSFT", "NVDA", "AMZN"], ...}
+idx = [tickers.index(t) for t in SEKTOREN["Technologie"]]
Diese Schreibweise ist nicht nur sicher, sondern auch lesbar — und sie bricht laut ab, wenn ein Ticker fehlt, statt still das Falsche zu tun.
19.6 Vollimplementierung mit CVXPY
-
#!/usr/bin/env python3
-
-# Markowitz_CVXPY.py
-"""
-Kapitel Markowitz: Markowitz-Mean-Variance-Optimierung mit CVXPY.
-Enthaelt GMV, Maximum Sharpe (Korn-Transformation), Effizienzgrenze
-und institutionelle Restriktionen.
-
-Eigenschaften:
- * Spaltenreihenfolge erzwungen; Sektoren ueber NAMEN statt Indizes
- * Effizienzgrenze bis zur TATSAECHLICH erreichbaren Maximalrendite
- (nicht etwa max(mu)*0.95 - das waere unter Restriktionen oft
- unerreichbar und die Kurve wuerde stillschweigend vorzeitig abbrechen)
- * Pruefung aller Restriktionen nach dem Loesen
- * Vergleich mit Gleichgewichtung als Realitaetscheck
-"""
-
-import os
-
-import cvxpy as cp
-import numpy as np
-import pandas as pd
-import yfinance as yf
-from sklearn.covariance import LedoitWolf
-import matplotlib
-matplotlib.use("Agg")
-import matplotlib.pyplot as plt
-
-OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
-os.makedirs(OUTPUT_DIR, exist_ok=True)
-
-TICKER = ["AAPL", "MSFT", "NVDA", "AMZN", "JNJ", "PFE", "JPM", "GS", "XOM", "CVX"]
-
-# Sektoren ueber Namen definiert, nicht ueber Positionen
-SEKTOREN = {
-"Technologie": ["AAPL", "MSFT", "NVDA", "AMZN"],
-"Gesundheit": ["JNJ", "PFE"],
-"Finanzen": ["JPM", "GS"],
-"Energie": ["XOM", "CVX"],
-}
-SEKTORGRENZEN = {"Technologie": 0.35, "Gesundheit": 0.40,
-"Finanzen": 0.40, "Energie": 0.40}
-
-MAX_GEWICHT =0.20# hoechstens 20 % je Einzeltitel
-RISIKOFREI =0.03# 3 % p.a.
-HANDELSTAGE =252
-
-
-def lade_daten():
-"""Laedt Kurse und garantiert die Spaltenreihenfolge."""
- ende = pd.Timestamp.today().normalize()
- start = ende - pd.DateOffset(years=2)
- roh = yf.download(TICKER, start=start, end=ende, auto_adjust=True, progress=False)
-if roh.empty:
-raiseSystemExit("Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).")
-
-ifisinstance(roh.columns, pd.MultiIndex):
- kurse = roh["Close"][TICKER].dropna() # [TICKER] erzwingt die Reihenfolge
-else:
- kurse = roh[["Close"]].dropna()
- kurse.columns = TICKER
-
-assertlist(kurse.columns) == TICKER, "Spaltenreihenfolge weicht ab!"
- renditen = kurse.pct_change().dropna()
- mu = renditen.mean().values * HANDELSTAGE
- sigma = LedoitWolf().fit(renditen.values).covariance_ * HANDELSTAGE
-return kurse, renditen, mu, sigma
-
-
-def sektor_indizes():
-"""Uebersetzt Sektornamen einmalig in Positionsindizes - mit Pruefung."""
- ergebnis = {}
-for sektor, titel in SEKTOREN.items():
- fehlend = [t for t in titel if t notin TICKER]
-if fehlend:
-raiseValueError(f"Sektor '{sektor}': Ticker {fehlend} nicht im Universum.")
- ergebnis[sektor] = [TICKER.index(t) for t in titel]
-return ergebnis
-
-
-def basis_restriktionen(w, skala=None):
-"""
- Standardrestriktionen. Bei der Korn-Transformation muessen ALLE Grenzen
- mit kappa mitskaliert werden - dafuer dient der Parameter 'skala'.
- """
- eins =1.0if skala isNoneelse skala
- idx = sektor_indizes()
- bedingungen = [w >=0, w <= MAX_GEWICHT * eins]
-for sektor, positionen in idx.items():
- bedingungen.append(cp.sum(w[positionen]) <= SEKTORGRENZEN[sektor] * eins)
-return bedingungen
-
-
-def loese_gmv(sigma):
-"""Global Minimum Variance: minimiere Risiko, ignoriere Rendite."""
- w = cp.Variable(len(sigma))
- problem = cp.Problem(cp.Minimize(0.5* cp.quad_form(w, sigma)),
- [cp.sum(w) ==1] + basis_restriktionen(w))
- problem.solve()
-if problem.status notin ("optimal", "optimal_inaccurate"):
-raiseSystemExit(f"GMV nicht loesbar: {problem.status}")
-return w.value
-
-
-def loese_max_sharpe(mu, sigma, r_f):
-"""Maximum Sharpe Ratio ueber die Korn-Transformation."""
- n =len(mu)
- ueberrendite = mu - r_f
-if np.all(ueberrendite <=0):
-raiseSystemExit("Kein Titel schlaegt den risikofreien Zins - "
-"Max-Sharpe-Portfolio existiert nicht.")
-
- y = cp.Variable(n)
- kappa = cp.Variable(nonneg=True)
- bedingungen = ([ueberrendite @ y ==1, cp.sum(y) == kappa]
-+ basis_restriktionen(y, skala=kappa))
- problem = cp.Problem(cp.Minimize(0.5* cp.quad_form(y, sigma)), bedingungen)
- problem.solve()
-if problem.status notin ("optimal", "optimal_inaccurate"):
-raiseSystemExit(f"Max-Sharpe nicht loesbar: {problem.status}")
-return y.value / kappa.value
-
-
-def max_erreichbare_rendite(mu, sigma):
-"""
- Achtung: max(mu)*0.95 als obere Grenze der Frontier anzunehmen, ist unter
- Positions- und Sektorgrenzen oft unerreichbar; die Kurve wuerde dann
- stillschweigend abbrechen. Hier wird die tatsaechliche Obergrenze berechnet.
- """
- w = cp.Variable(len(mu))
- problem = cp.Problem(cp.Maximize(mu @ w), [cp.sum(w) ==1] + basis_restriktionen(w))
- problem.solve()
-returnfloat(problem.value)
-
-
-def berechne_frontier(mu, sigma, ret_min, ret_max, punkte=40):
-"""Effizienzgrenze durch Variation der Mindestrendite."""
- n =len(mu)
- w = cp.Variable(n)
- ziel_rendite = cp.Parameter()
- problem = cp.Problem(
- cp.Minimize(0.5* cp.quad_form(w, sigma)),
- [cp.sum(w) ==1, mu @ w >= ziel_rendite] + basis_restriktionen(w))
-
- volas, renditen, uebersprungen = [], [], 0
-for ziel in np.linspace(ret_min, ret_max, punkte):
- ziel_rendite.value = ziel
- problem.solve()
-if problem.status in ("optimal", "optimal_inaccurate"):
- volas.append(float(np.sqrt(w.value @ sigma @ w.value)))
- renditen.append(float(mu @ w.value))
-else:
- uebersprungen +=1
-if uebersprungen:
-print(f" Hinweis: {uebersprungen} Zielrenditen waren nicht erreichbar.")
-return np.array(volas), np.array(renditen)
-
-
-def pruefe_restriktionen(w, bezeichnung):
-"""Nach dem Loesen: haelt die Loesung wirklich alle Regeln ein?"""
-assertabs(w.sum() -1) <1e-6, f"{bezeichnung}: Summe != 1"
-assert w.min() >-1e-6, f"{bezeichnung}: negatives Gewicht"
-assert w.max() < MAX_GEWICHT +1e-6, f"{bezeichnung}: Positionsgrenze verletzt"
-for sektor, positionen in sektor_indizes().items():
- anteil = w[positionen].sum()
-assert anteil < SEKTORGRENZEN[sektor] +1e-6, \
-f"{bezeichnung}: Sektor {sektor} bei {anteil:.3f} ueber Grenze"
-
-
-def kennzahlen(w, mu, sigma, r_f):
- rendite =float(mu @ w)
- vola =float(np.sqrt(w @ sigma @ w))
-return rendite, vola, (rendite - r_f) / vola
-
-
-if__name__=="__main__":
- kurse, renditen, mu, sigma = lade_daten()
- n =len(TICKER)
-
- w_gmv = loese_gmv(sigma)
- w_sharpe = loese_max_sharpe(mu, sigma, RISIKOFREI)
- w_gleich = np.ones(n) / n
-
- pruefe_restriktionen(w_gmv, "GMV")
- pruefe_restriktionen(w_sharpe, "Max Sharpe")
-
-print("="*88)
-print(" ERGEBNISSE DER MEAN-VARIANCE-OPTIMIERUNG")
-print("="*88)
-print(f"Datenbasis: {len(renditen)} Handelstage, {n} Titel, "
-f"risikofreier Zins {RISIKOFREI*100:.1f} %")
-print(f"Restriktionen: max. {MAX_GEWICHT*100:.0f} % je Titel, "
-f"Sektorgrenzen {SEKTORGRENZEN}\n")
-
-print(f"{'Portfolio':<26}{'Rendite':>10}{'Volatilitaet':>13}{'Sharpe':>9}")
-print("-"*88)
-for name, w in [("Global Minimum Variance", w_gmv),
- ("Maximum Sharpe Ratio", w_sharpe),
- ("Gleichgewichtung (1/N)", w_gleich)]:
- r, v, sr = kennzahlen(w, mu, sigma, RISIKOFREI)
-print(f"{name:<26}{r*100:>9.2f} % {v*100:>12.2f} % {sr:>9.2f}")
-
-# --- Gewichte je Titel ------------------------------------------------
-print("\n--- Optimierte Portfoliogewichte ---")
- sektor_je_titel = {t: s for s, titel in SEKTOREN.items() for t in titel}
- tabelle = pd.DataFrame({
-"Ticker": TICKER,
-"Sektor": [sektor_je_titel[t] for t in TICKER],
-"Rendite p.a.": [f"{r*100:+6.1f} %"for r in mu],
-"Vola p.a.": [f"{np.sqrt(sigma[i, i])*100:5.1f} %"for i inrange(n)],
-"GMV": [f"{w*100:5.1f} %"for w in w_gmv],
-"Max Sharpe": [f"{w*100:5.1f} %"for w in w_sharpe],
- })
-print(tabelle.to_string(index=False))
-
-print("\n--- Sektoraufteilung (Kontrolle) ---")
-print(f"{'Sektor':<14}{'Grenze':>8}{'GMV':>9}{'Max Sharpe':>12}")
-for sektor, positionen in sektor_indizes().items():
-print(f"{sektor:<14}{SEKTORGRENZEN[sektor]*100:>7.0f} % "
-f"{w_gmv[positionen].sum()*100:>8.1f} % "
-f"{w_sharpe[positionen].sum()*100:>11.1f} %")
-
-# --- Effizienzgrenze --------------------------------------------------
- ret_gmv =float(mu @ w_gmv)
- ret_max = max_erreichbare_rendite(mu, sigma)
-print(f"\nEffizienzgrenze von {ret_gmv*100:.2f} % bis {ret_max*100:.2f} % "
-f"(unter Restriktionen tatsaechlich erreichbar)")
-print(f" Zum Vergleich: bester Einzeltitel {mu.max()*100:.2f} % - "
-f"durch die Grenzen nicht erreichbar.")
- volas, rets = berechne_frontier(mu, sigma, ret_gmv, ret_max)
-
-# --- Diagramm ---------------------------------------------------------
- plt.figure(figsize=(11, 6.5))
- plt.plot(volas *100, rets *100, "b-", lw=2.5, label="Effizienzgrenze (restringiert)")
-
-for w, farbe, marker, groesse, name in [
- (w_gmv, "green", "o", 150, "GMV"),
- (w_sharpe, "red", "*", 260, "Max Sharpe"),
- (w_gleich, "purple", "D", 110, "Gleichgewichtung")]:
- r, v, sr = kennzahlen(w, mu, sigma, RISIKOFREI)
- plt.scatter([v *100], [r *100], color=farbe, marker=marker, s=groesse,
- zorder=5, label=f"{name} (SR={sr:.2f})")
-
-# Kapitalmarktlinie durch den risikofreien Zins und das Tangentialportfolio
- r_s, v_s, _ = kennzahlen(w_sharpe, mu, sigma, RISIKOFREI)
- x_linie = np.array([0, v_s *1.25])
- plt.plot(x_linie *100, (RISIKOFREI + (r_s - RISIKOFREI) / v_s * x_linie) *100,
- color="orange", ls=":", lw=2, label="Kapitalmarktlinie")
-
-for i, t inenumerate(TICKER):
- plt.scatter(np.sqrt(sigma[i, i]) *100, mu[i] *100, color="gray", alpha=0.5, s=40)
- plt.annotate(t, (np.sqrt(sigma[i, i]) *100+0.4, mu[i] *100), fontsize=8)
-
- plt.title("Markowitz-Effizienzgrenze mit Sektor- und Positionsgrenzen", fontsize=12)
- plt.xlabel("Annualisierte Volatilitaet [%]")
- plt.ylabel("Annualisierte erwartete Rendite [%]")
- plt.grid(True, linestyle=":", alpha=0.6)
- plt.legend(loc="best")
- plt.tight_layout()
- ziel = os.path.join(OUTPUT_DIR, "markowitz_efficient_frontier.png")
- plt.savefig(ziel, dpi=150)
-print(f"\nDiagramm gespeichert unter '{ziel}'")
-print("="*88)
+
#!/usr/bin/env python3
+
+# Markowitz_CVXPY.py
+"""
+Kapitel Markowitz: Markowitz-Mean-Variance-Optimierung mit CVXPY.
+Enthaelt GMV, Maximum Sharpe (Korn-Transformation), Effizienzgrenze
+und institutionelle Restriktionen.
+
+Eigenschaften:
+ * Spaltenreihenfolge erzwungen; Sektoren ueber NAMEN statt Indizes
+ * Effizienzgrenze bis zur TATSAECHLICH erreichbaren Maximalrendite
+ (nicht etwa max(mu)*0.95 - das waere unter Restriktionen oft
+ unerreichbar und die Kurve wuerde stillschweigend vorzeitig abbrechen)
+ * Pruefung aller Restriktionen nach dem Loesen
+ * Vergleich mit Gleichgewichtung als Realitaetscheck
+"""
+
+import os
+
+import cvxpy as cp
+import numpy as np
+import pandas as pd
+import yfinance as yf
+from sklearn.covariance import LedoitWolf
+import matplotlib
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt
+
+OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
+os.makedirs(OUTPUT_DIR, exist_ok=True)
+
+TICKER = ["AAPL", "MSFT", "NVDA", "AMZN", "JNJ", "PFE", "JPM", "GS", "XOM", "CVX"]
+
+# Sektoren ueber Namen definiert, nicht ueber Positionen
+SEKTOREN = {
+"Technologie": ["AAPL", "MSFT", "NVDA", "AMZN"],
+"Gesundheit": ["JNJ", "PFE"],
+"Finanzen": ["JPM", "GS"],
+"Energie": ["XOM", "CVX"],
+}
+SEKTORGRENZEN = {"Technologie": 0.35, "Gesundheit": 0.40,
+"Finanzen": 0.40, "Energie": 0.40}
+
+MAX_GEWICHT =0.20# hoechstens 20 % je Einzeltitel
+RISIKOFREI =0.03# 3 % p.a.
+HANDELSTAGE =252
+
+
+def lade_daten():
+"""Laedt Kurse und garantiert die Spaltenreihenfolge."""
+ ende = pd.Timestamp.today().normalize()
+ start = ende - pd.DateOffset(years=2)
+ roh = yf.download(TICKER, start=start, end=ende, auto_adjust=True, progress=False)
+if roh.empty:
+raiseSystemExit("Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).")
+
+ifisinstance(roh.columns, pd.MultiIndex):
+ kurse = roh["Close"][TICKER].dropna() # [TICKER] erzwingt die Reihenfolge
+else:
+ kurse = roh[["Close"]].dropna()
+ kurse.columns = TICKER
+
+assertlist(kurse.columns) == TICKER, "Spaltenreihenfolge weicht ab!"
+ renditen = kurse.pct_change().dropna()
+ mu = renditen.mean().values * HANDELSTAGE
+ sigma = LedoitWolf().fit(renditen.values).covariance_ * HANDELSTAGE
+return kurse, renditen, mu, sigma
+
+
+def sektor_indizes():
+"""Uebersetzt Sektornamen einmalig in Positionsindizes - mit Pruefung."""
+ ergebnis = {}
+for sektor, titel in SEKTOREN.items():
+ fehlend = [t for t in titel if t notin TICKER]
+if fehlend:
+raiseValueError(f"Sektor '{sektor}': Ticker {fehlend} nicht im Universum.")
+ ergebnis[sektor] = [TICKER.index(t) for t in titel]
+return ergebnis
+
+
+def basis_restriktionen(w, skala=None):
+"""
+ Standardrestriktionen. Bei der Korn-Transformation muessen ALLE Grenzen
+ mit kappa mitskaliert werden - dafuer dient der Parameter 'skala'.
+ """
+ eins =1.0if skala isNoneelse skala
+ idx = sektor_indizes()
+ bedingungen = [w >=0, w <= MAX_GEWICHT * eins]
+for sektor, positionen in idx.items():
+ bedingungen.append(cp.sum(w[positionen]) <= SEKTORGRENZEN[sektor] * eins)
+return bedingungen
+
+
+def loese_gmv(sigma):
+"""Global Minimum Variance: minimiere Risiko, ignoriere Rendite."""
+ w = cp.Variable(len(sigma))
+ problem = cp.Problem(cp.Minimize(0.5* cp.quad_form(w, sigma)),
+ [cp.sum(w) ==1] + basis_restriktionen(w))
+ problem.solve()
+if problem.status notin ("optimal", "optimal_inaccurate"):
+raiseSystemExit(f"GMV nicht loesbar: {problem.status}")
+return w.value
+
+
+def loese_max_sharpe(mu, sigma, r_f):
+"""Maximum Sharpe Ratio ueber die Korn-Transformation."""
+ n =len(mu)
+ ueberrendite = mu - r_f
+if np.all(ueberrendite <=0):
+raiseSystemExit("Kein Titel schlaegt den risikofreien Zins - "
+"Max-Sharpe-Portfolio existiert nicht.")
+
+ y = cp.Variable(n)
+ kappa = cp.Variable(nonneg=True)
+ bedingungen = ([ueberrendite @ y ==1, cp.sum(y) == kappa]
++ basis_restriktionen(y, skala=kappa))
+ problem = cp.Problem(cp.Minimize(0.5* cp.quad_form(y, sigma)), bedingungen)
+ problem.solve()
+if problem.status notin ("optimal", "optimal_inaccurate"):
+raiseSystemExit(f"Max-Sharpe nicht loesbar: {problem.status}")
+return y.value / kappa.value
+
+
+def max_erreichbare_rendite(mu, sigma):
+"""
+ Achtung: max(mu)*0.95 als obere Grenze der Frontier anzunehmen, ist unter
+ Positions- und Sektorgrenzen oft unerreichbar; die Kurve wuerde dann
+ stillschweigend abbrechen. Hier wird die tatsaechliche Obergrenze berechnet.
+ """
+ w = cp.Variable(len(mu))
+ problem = cp.Problem(cp.Maximize(mu @ w), [cp.sum(w) ==1] + basis_restriktionen(w))
+ problem.solve()
+returnfloat(problem.value)
+
+
+def berechne_frontier(mu, sigma, ret_min, ret_max, punkte=40):
+"""Effizienzgrenze durch Variation der Mindestrendite."""
+ n =len(mu)
+ w = cp.Variable(n)
+ ziel_rendite = cp.Parameter()
+ problem = cp.Problem(
+ cp.Minimize(0.5* cp.quad_form(w, sigma)),
+ [cp.sum(w) ==1, mu @ w >= ziel_rendite] + basis_restriktionen(w))
+
+ volas, renditen, uebersprungen = [], [], 0
+for ziel in np.linspace(ret_min, ret_max, punkte):
+ ziel_rendite.value = ziel
+ problem.solve()
+if problem.status in ("optimal", "optimal_inaccurate"):
+ volas.append(float(np.sqrt(w.value @ sigma @ w.value)))
+ renditen.append(float(mu @ w.value))
+else:
+ uebersprungen +=1
+if uebersprungen:
+print(f" Hinweis: {uebersprungen} Zielrenditen waren nicht erreichbar.")
+return np.array(volas), np.array(renditen)
+
+
+def pruefe_restriktionen(w, bezeichnung):
+"""Nach dem Loesen: haelt die Loesung wirklich alle Regeln ein?"""
+assertabs(w.sum() -1) <1e-6, f"{bezeichnung}: Summe != 1"
+assert w.min() >-1e-6, f"{bezeichnung}: negatives Gewicht"
+assert w.max() < MAX_GEWICHT +1e-6, f"{bezeichnung}: Positionsgrenze verletzt"
+for sektor, positionen in sektor_indizes().items():
+ anteil = w[positionen].sum()
+assert anteil < SEKTORGRENZEN[sektor] +1e-6, \
+f"{bezeichnung}: Sektor {sektor} bei {anteil:.3f} ueber Grenze"
+
+
+def kennzahlen(w, mu, sigma, r_f):
+ rendite =float(mu @ w)
+ vola =float(np.sqrt(w @ sigma @ w))
+return rendite, vola, (rendite - r_f) / vola
+
+
+if__name__=="__main__":
+ kurse, renditen, mu, sigma = lade_daten()
+ n =len(TICKER)
+
+ w_gmv = loese_gmv(sigma)
+ w_sharpe = loese_max_sharpe(mu, sigma, RISIKOFREI)
+ w_gleich = np.ones(n) / n
+
+ pruefe_restriktionen(w_gmv, "GMV")
+ pruefe_restriktionen(w_sharpe, "Max Sharpe")
+
+print("="*88)
+print(" ERGEBNISSE DER MEAN-VARIANCE-OPTIMIERUNG")
+print("="*88)
+print(f"Datenbasis: {len(renditen)} Handelstage, {n} Titel, "
+f"risikofreier Zins {RISIKOFREI*100:.1f} %")
+print(f"Restriktionen: max. {MAX_GEWICHT*100:.0f} % je Titel, "
+f"Sektorgrenzen {SEKTORGRENZEN}\n")
+
+print(f"{'Portfolio':<26}{'Rendite':>10}{'Volatilitaet':>13}{'Sharpe':>9}")
+print("-"*88)
+for name, w in [("Global Minimum Variance", w_gmv),
+ ("Maximum Sharpe Ratio", w_sharpe),
+ ("Gleichgewichtung (1/N)", w_gleich)]:
+ r, v, sr = kennzahlen(w, mu, sigma, RISIKOFREI)
+print(f"{name:<26}{r*100:>9.2f} % {v*100:>12.2f} % {sr:>9.2f}")
+
+# --- Gewichte je Titel ------------------------------------------------
+print("\n--- Optimierte Portfoliogewichte ---")
+ sektor_je_titel = {t: s for s, titel in SEKTOREN.items() for t in titel}
+ tabelle = pd.DataFrame({
+"Ticker": TICKER,
+"Sektor": [sektor_je_titel[t] for t in TICKER],
+"Rendite p.a.": [f"{r*100:+6.1f} %"for r in mu],
+"Vola p.a.": [f"{np.sqrt(sigma[i, i])*100:5.1f} %"for i inrange(n)],
+"GMV": [f"{w*100:5.1f} %"for w in w_gmv],
+"Max Sharpe": [f"{w*100:5.1f} %"for w in w_sharpe],
+ })
+print(tabelle.to_string(index=False))
+
+print("\n--- Sektoraufteilung (Kontrolle) ---")
+print(f"{'Sektor':<14}{'Grenze':>8}{'GMV':>9}{'Max Sharpe':>12}")
+for sektor, positionen in sektor_indizes().items():
+print(f"{sektor:<14}{SEKTORGRENZEN[sektor]*100:>7.0f} % "
+f"{w_gmv[positionen].sum()*100:>8.1f} % "
+f"{w_sharpe[positionen].sum()*100:>11.1f} %")
+
+# --- Effizienzgrenze --------------------------------------------------
+ ret_gmv =float(mu @ w_gmv)
+ ret_max = max_erreichbare_rendite(mu, sigma)
+print(f"\nEffizienzgrenze von {ret_gmv*100:.2f} % bis {ret_max*100:.2f} % "
+f"(unter Restriktionen tatsaechlich erreichbar)")
+print(f" Zum Vergleich: bester Einzeltitel {mu.max()*100:.2f} % - "
+f"durch die Grenzen nicht erreichbar.")
+ volas, rets = berechne_frontier(mu, sigma, ret_gmv, ret_max)
+
+# --- Diagramm ---------------------------------------------------------
+ plt.figure(figsize=(11, 6.5))
+ plt.plot(volas *100, rets *100, "b-", lw=2.5, label="Effizienzgrenze (restringiert)")
+
+for w, farbe, marker, groesse, name in [
+ (w_gmv, "green", "o", 150, "GMV"),
+ (w_sharpe, "red", "*", 260, "Max Sharpe"),
+ (w_gleich, "purple", "D", 110, "Gleichgewichtung")]:
+ r, v, sr = kennzahlen(w, mu, sigma, RISIKOFREI)
+ plt.scatter([v *100], [r *100], color=farbe, marker=marker, s=groesse,
+ zorder=5, label=f"{name} (SR={sr:.2f})")
+
+# Kapitalmarktlinie durch den risikofreien Zins und das Tangentialportfolio
+ r_s, v_s, _ = kennzahlen(w_sharpe, mu, sigma, RISIKOFREI)
+ x_linie = np.array([0, v_s *1.25])
+ plt.plot(x_linie *100, (RISIKOFREI + (r_s - RISIKOFREI) / v_s * x_linie) *100,
+ color="orange", ls=":", lw=2, label="Kapitalmarktlinie")
+
+for i, t inenumerate(TICKER):
+ plt.scatter(np.sqrt(sigma[i, i]) *100, mu[i] *100, color="gray", alpha=0.5, s=40)
+ plt.annotate(t, (np.sqrt(sigma[i, i]) *100+0.4, mu[i] *100), fontsize=8)
+
+ plt.title("Markowitz-Effizienzgrenze mit Sektor- und Positionsgrenzen", fontsize=12)
+ plt.xlabel("Annualisierte Volatilitaet [%]")
+ plt.ylabel("Annualisierte erwartete Rendite [%]")
+ plt.grid(True, linestyle=":", alpha=0.6)
+ plt.legend(loc="best")
+ plt.tight_layout()
+ ziel = os.path.join(OUTPUT_DIR, "markowitz_efficient_frontier.png")
+ plt.savefig(ziel, dpi=150)
+print(f"\nDiagramm gespeichert unter '{ziel}'")
+print("="*88)
💻 Code-Durchgang: vier typische Fehlerquellen und ihre Absicherung
Ihre Aufgabe: (a) Woher kommt die Spanne von 0,24 Prozentpunkten zwischen der höchsten und der niedrigsten geschätzten Rendite, wenn doch alle Anlagen identisch sind? (b) Rechnen Sie nach, wie viele Beobachtungen nötig wären, um eine Rendite von 0,04 % bei 1,2 % Tagesschwankung überhaupt von null zu unterscheiden. (c) Warum wirkt sich derselbe Schätzfehler bei Renditen stärker aus als bei Kovarianzen? (d) Nennen Sie drei Gegenmittel — und begründen Sie, warum das erste davon das wirksamste ist.
#!/usr/bin/env python3
-
-# Renditeschaetzung_Falle.py
-"""
-Kapitel Markowitz: Warum geschaetzte Renditen noch gefaehrlicher sind als geschaetzte
-Kovarianzen.
-
-Das Kapitel Finanzdaten hat gezeigt, was eine schlecht geschaetzte Kovarianzmatrix
-anrichtet. Bei den erwarteten RENDITEN ist es schlimmer - aus einem einfachen
-statistischen Grund: Um eine Rendite von 0,04 % pro Tag von null zu
-unterscheiden, braucht man bei 1,2 % Tagesschwankung ueber tausend
-Beobachtungen. Kovarianzen konvergieren dagegen deutlich schneller.
-
-Das Experiment ist bewusst so gebaut, dass die Antwort feststeht: ALLE zwoelf
-Anlagen haben exakt dieselbe wahre Rendite und dieselbe Schwankung. Ein
-korrektes Ergebnis waere also Gleichgewichtung. Was der Optimierer stattdessen
-tut, zeigt der Lauf.
-
-Wiederholt wird 200 mal, damit das Ergebnis nicht von einer Zufallsstichprobe
-abhaengt.
-
-Benoetigt: numpy, cvxpy
-"""
-
-from __future__ import annotations
-
-import numpy as np
-import cvxpy as cp
-
-ANZAHL_ANLAGEN =12
-BEOBACHTUNGEN =250# ein Jahr Tagesdaten
-BEOBACHTUNGEN_SPAETER =2000# der "spaetere Verlauf"
-WIEDERHOLUNGEN =200
-
-# Die Wahrheit, die der Optimierer nicht kennt: alle Anlagen sind gleich.
-WAHRE_RENDITE =0.0004# 0,04 % pro Tag
-WAHRE_SCHWANKUNG =0.012# 1,2 % pro Tag
-RISIKOAVERSION =10.0
-
-RNG = np.random.default_rng(5)
-
-
-def baue_optimierer() ->tuple[cp.Problem, cp.Variable, cp.Parameter, cp.Parameter]:
-"""Baut das Markowitz-Problem EINMAL mit Parametern.
-
- Bei 200 Wiederholungen ist das der Unterschied zwischen Sekunden und
- Minuten: cp.Parameter erlaubt es, nur die Daten zu tauschen, statt den
- Ausdrucksbaum jedes Mal neu zu kompilieren (siehe Kapitel Oekosystem).
- """
- gewichte = cp.Variable(ANZAHL_ANLAGEN, nonneg=True)
- renditen = cp.Parameter(ANZAHL_ANLAGEN)
- kovarianz = cp.Parameter((ANZAHL_ANLAGEN, ANZAHL_ANLAGEN), PSD=True)
- problem = cp.Problem(
- cp.Maximize(renditen @ gewichte
-- RISIKOAVERSION * cp.quad_form(gewichte, kovarianz)),
- [cp.sum(gewichte) ==1])
-return problem, gewichte, renditen, kovarianz
-
-
-def erzeuge(perioden: int) -> np.ndarray:
-"""Renditen, bei denen alle Anlagen identisch verteilt und unabhaengig sind."""
-return RNG.normal(WAHRE_RENDITE, WAHRE_SCHWANKUNG,
- (perioden, ANZAHL_ANLAGEN))
-
-
-if__name__=="__main__":
- problem, gewichte, renditen, kovarianz = baue_optimierer()
-
- im_zeitraum, spaeter, spaeter_gleich = [], [], []
- groesstes_gewicht, schaetzspanne = [], []
-
-for _ inrange(WIEDERHOLUNGEN):
- schaetzdaten = erzeuge(BEOBACHTUNGEN)
- spaetere_daten = erzeuge(BEOBACHTUNGEN_SPAETER)
-
- geschaetzte_rendite = schaetzdaten.mean(axis=0)
- geschaetzte_kovarianz = np.cov(schaetzdaten, rowvar=False)
-
- renditen.value = geschaetzte_rendite
-# Symmetrisieren und minimal anheben: numerisches Rauschen kann die
-# Matrix sonst knapp unter die PSD-Grenze druecken (Kapitel Fundament).
- kovarianz.value = ((geschaetzte_kovarianz + geschaetzte_kovarianz.T) /2
-+1e-10* np.eye(ANZAHL_ANLAGEN))
- problem.solve()
-
- w = np.array(gewichte.value).ravel()
- im_zeitraum.append(float((schaetzdaten @ w).mean()))
- spaeter.append(float((spaetere_daten @ w).mean()))
- spaeter_gleich.append(float(spaetere_daten.mean()))
- groesstes_gewicht.append(float(w.max()))
- schaetzspanne.append(float(geschaetzte_rendite.max()
-- geschaetzte_rendite.min()))
-
- prozent =lambda werte: float(np.mean(werte)) *100
-
-print("="*78)
-print(" WENN DER OPTIMIERER GESCHAETZTE RENDITEN GLAUBT")
-print("="*78)
-print(f"{ANZAHL_ANLAGEN} Anlagen, {WIEDERHOLUNGEN} Wiederholungen, "
-f"je {BEOBACHTUNGEN} Beobachtungen zur Schaetzung.")
-print(f"In Wahrheit haben ALLE dieselbe Tagesrendite von "
-f"{WAHRE_RENDITE *100:.4f} % und dieselbe Schwankung.")
-print("Die richtige Antwort waere also: gleichgewichten.")
-print()
-print(f"Mittlere Spanne der geschaetzten Renditen: "
-f"{prozent(schaetzspanne):.4f} Prozentpunkte")
-print(f" -> Das ist das {prozent(schaetzspanne) / (WAHRE_RENDITE *100):.1f}-Fache "
-f"des wahren Werts. Aus reinem Rauschen.")
-print(f"Mittleres groesstes Einzelgewicht: "
-f"{prozent(groesstes_gewicht):.1f} % "
-f"(bei Gleichgewichtung waeren es {100/ ANZAHL_ANLAGEN:.1f} %)")
-print()
-print(f"{'':<38}{'Tagesrendite':>14}")
-print("-"*78)
-print(f"{'Wahrheit (alle Anlagen)':<38}{WAHRE_RENDITE *100:>13.4f} %")
-print(f"{'Optimierer IM Schaetzzeitraum':<38}{prozent(im_zeitraum):>13.4f} %"
-f" <- Illusion")
-print(f"{'Optimierer AUSSERHALB':<38}{prozent(spaeter):>13.4f} %")
-print(f"{'Gleichgewichtung AUSSERHALB':<38}{prozent(spaeter_gleich):>13.4f} %")
-print("-"*78)
-
- faktor = prozent(im_zeitraum) / (WAHRE_RENDITE *100)
- vorsprung = prozent(spaeter) - prozent(spaeter_gleich)
-print(f"\nIm Schaetzzeitraum verspricht der Optimierer das "
-f"{faktor:.1f}-Fache der wahren Rendite.")
-print(f"Ausserhalb bleibt davon nichts: Er liegt {abs(vorsprung):.4f} "
-f"Prozentpunkte")
-print(f"{'schlechter'if vorsprung <0else'besser'} als blosse Gleichgewichtung.")
-print()
-print("Der Grund ist statistisch, nicht finanzwirtschaftlich: Um eine")
-print(f"Rendite von {WAHRE_RENDITE*100:.2f} % taeglich von null zu unterscheiden,")
-print(f"braucht man bei {WAHRE_SCHWANKUNG*100:.1f} % Schwankung ueber tausend")
-print("Beobachtungen. Mit 250 misst man ueberwiegend Rauschen - und der")
-print("Optimierer nimmt jedes Rauschen fuer bare Muenze.")
-print()
-print("Drei praktische Konsequenzen:")
-print(" 1. Verzichten Sie auf Renditeschaetzungen, wo es geht. Das")
-print(" Minimum-Varianz-Portfolio braucht gar keine.")
-print(" 2. Wenn Sie welche brauchen: schrumpfen Sie sie stark zur Mitte")
-print(" (James-Stein, Black-Litterman) - viel staerker als Kovarianzen.")
-print(" 3. Begrenzen Sie Einzelgewichte. Was der Optimierer nicht darf,")
-print(" kann er auch nicht auf Rauschen setzen.")
-print("="*78)
+
#!/usr/bin/env python3
+
+# Renditeschaetzung_Falle.py
+"""
+Kapitel Markowitz: Warum geschaetzte Renditen noch gefaehrlicher sind als geschaetzte
+Kovarianzen.
+
+Das Kapitel Finanzdaten hat gezeigt, was eine schlecht geschaetzte Kovarianzmatrix
+anrichtet. Bei den erwarteten RENDITEN ist es schlimmer - aus einem einfachen
+statistischen Grund: Um eine Rendite von 0,04 % pro Tag von null zu
+unterscheiden, braucht man bei 1,2 % Tagesschwankung ueber tausend
+Beobachtungen. Kovarianzen konvergieren dagegen deutlich schneller.
+
+Das Experiment ist bewusst so gebaut, dass die Antwort feststeht: ALLE zwoelf
+Anlagen haben exakt dieselbe wahre Rendite und dieselbe Schwankung. Ein
+korrektes Ergebnis waere also Gleichgewichtung. Was der Optimierer stattdessen
+tut, zeigt der Lauf.
+
+Wiederholt wird 200 mal, damit das Ergebnis nicht von einer Zufallsstichprobe
+abhaengt.
+
+Benoetigt: numpy, cvxpy
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import cvxpy as cp
+
+ANZAHL_ANLAGEN =12
+BEOBACHTUNGEN =250# ein Jahr Tagesdaten
+BEOBACHTUNGEN_SPAETER =2000# der "spaetere Verlauf"
+WIEDERHOLUNGEN =200
+
+# Die Wahrheit, die der Optimierer nicht kennt: alle Anlagen sind gleich.
+WAHRE_RENDITE =0.0004# 0,04 % pro Tag
+WAHRE_SCHWANKUNG =0.012# 1,2 % pro Tag
+RISIKOAVERSION =10.0
+
+RNG = np.random.default_rng(5)
+
+
+def baue_optimierer() ->tuple[cp.Problem, cp.Variable, cp.Parameter, cp.Parameter]:
+"""Baut das Markowitz-Problem EINMAL mit Parametern.
+
+ Bei 200 Wiederholungen ist das der Unterschied zwischen Sekunden und
+ Minuten: cp.Parameter erlaubt es, nur die Daten zu tauschen, statt den
+ Ausdrucksbaum jedes Mal neu zu kompilieren (siehe Kapitel Oekosystem).
+ """
+ gewichte = cp.Variable(ANZAHL_ANLAGEN, nonneg=True)
+ renditen = cp.Parameter(ANZAHL_ANLAGEN)
+ kovarianz = cp.Parameter((ANZAHL_ANLAGEN, ANZAHL_ANLAGEN), PSD=True)
+ problem = cp.Problem(
+ cp.Maximize(renditen @ gewichte
+- RISIKOAVERSION * cp.quad_form(gewichte, kovarianz)),
+ [cp.sum(gewichte) ==1])
+return problem, gewichte, renditen, kovarianz
+
+
+def erzeuge(perioden: int) -> np.ndarray:
+"""Renditen, bei denen alle Anlagen identisch verteilt und unabhaengig sind."""
+return RNG.normal(WAHRE_RENDITE, WAHRE_SCHWANKUNG,
+ (perioden, ANZAHL_ANLAGEN))
+
+
+if__name__=="__main__":
+ problem, gewichte, renditen, kovarianz = baue_optimierer()
+
+ im_zeitraum, spaeter, spaeter_gleich = [], [], []
+ groesstes_gewicht, schaetzspanne = [], []
+
+for _ inrange(WIEDERHOLUNGEN):
+ schaetzdaten = erzeuge(BEOBACHTUNGEN)
+ spaetere_daten = erzeuge(BEOBACHTUNGEN_SPAETER)
+
+ geschaetzte_rendite = schaetzdaten.mean(axis=0)
+ geschaetzte_kovarianz = np.cov(schaetzdaten, rowvar=False)
+
+ renditen.value = geschaetzte_rendite
+# Symmetrisieren und minimal anheben: numerisches Rauschen kann die
+# Matrix sonst knapp unter die PSD-Grenze druecken (Kapitel Fundament).
+ kovarianz.value = ((geschaetzte_kovarianz + geschaetzte_kovarianz.T) /2
++1e-10* np.eye(ANZAHL_ANLAGEN))
+ problem.solve()
+
+ w = np.array(gewichte.value).ravel()
+ im_zeitraum.append(float((schaetzdaten @ w).mean()))
+ spaeter.append(float((spaetere_daten @ w).mean()))
+ spaeter_gleich.append(float(spaetere_daten.mean()))
+ groesstes_gewicht.append(float(w.max()))
+ schaetzspanne.append(float(geschaetzte_rendite.max()
+- geschaetzte_rendite.min()))
+
+ prozent =lambda werte: float(np.mean(werte)) *100
+
+print("="*78)
+print(" WENN DER OPTIMIERER GESCHAETZTE RENDITEN GLAUBT")
+print("="*78)
+print(f"{ANZAHL_ANLAGEN} Anlagen, {WIEDERHOLUNGEN} Wiederholungen, "
+f"je {BEOBACHTUNGEN} Beobachtungen zur Schaetzung.")
+print(f"In Wahrheit haben ALLE dieselbe Tagesrendite von "
+f"{WAHRE_RENDITE *100:.4f} % und dieselbe Schwankung.")
+print("Die richtige Antwort waere also: gleichgewichten.")
+print()
+print(f"Mittlere Spanne der geschaetzten Renditen: "
+f"{prozent(schaetzspanne):.4f} Prozentpunkte")
+print(f" -> Das ist das {prozent(schaetzspanne) / (WAHRE_RENDITE *100):.1f}-Fache "
+f"des wahren Werts. Aus reinem Rauschen.")
+print(f"Mittleres groesstes Einzelgewicht: "
+f"{prozent(groesstes_gewicht):.1f} % "
+f"(bei Gleichgewichtung waeren es {100/ ANZAHL_ANLAGEN:.1f} %)")
+print()
+print(f"{'':<38}{'Tagesrendite':>14}")
+print("-"*78)
+print(f"{'Wahrheit (alle Anlagen)':<38}{WAHRE_RENDITE *100:>13.4f} %")
+print(f"{'Optimierer IM Schaetzzeitraum':<38}{prozent(im_zeitraum):>13.4f} %"
+f" <- Illusion")
+print(f"{'Optimierer AUSSERHALB':<38}{prozent(spaeter):>13.4f} %")
+print(f"{'Gleichgewichtung AUSSERHALB':<38}{prozent(spaeter_gleich):>13.4f} %")
+print("-"*78)
+
+ faktor = prozent(im_zeitraum) / (WAHRE_RENDITE *100)
+ vorsprung = prozent(spaeter) - prozent(spaeter_gleich)
+print(f"\nIm Schaetzzeitraum verspricht der Optimierer das "
+f"{faktor:.1f}-Fache der wahren Rendite.")
+print(f"Ausserhalb bleibt davon nichts: Er liegt {abs(vorsprung):.4f} "
+f"Prozentpunkte")
+print(f"{'schlechter'if vorsprung <0else'besser'} als blosse Gleichgewichtung.")
+print()
+print("Der Grund ist statistisch, nicht finanzwirtschaftlich: Um eine")
+print(f"Rendite von {WAHRE_RENDITE*100:.2f} % taeglich von null zu unterscheiden,")
+print(f"braucht man bei {WAHRE_SCHWANKUNG*100:.1f} % Schwankung ueber tausend")
+print("Beobachtungen. Mit 250 misst man ueberwiegend Rauschen - und der")
+print("Optimierer nimmt jedes Rauschen fuer bare Muenze.")
+print()
+print("Drei praktische Konsequenzen:")
+print(" 1. Verzichten Sie auf Renditeschaetzungen, wo es geht. Das")
+print(" Minimum-Varianz-Portfolio braucht gar keine.")
+print(" 2. Wenn Sie welche brauchen: schrumpfen Sie sie stark zur Mitte")
+print(" (James-Stein, Black-Litterman) - viel staerker als Kovarianzen.")
+print(" 3. Begrenzen Sie Einzelgewichte. Was der Optimierer nicht darf,")
+print(" kann er auch nicht auf Rauschen setzen.")
+print("="*78)
Erwartete Ausgabe:
==============================================================================
WENN DER OPTIMIERER GESCHAETZTE RENDITEN GLAUBT
@@ -22673,17 +23041,17 @@ Drei praktische Konsequenzen:
Anlage A: sechsmal −3 %.
Anlage B: fünfmal −3 % — und einmal −40 %.
-
import numpy as np
-
-gut = np.full(94, 0.5)
-A = np.concatenate([gut, np.full(6, -3.0)])
-B = np.concatenate([gut, np.full(5, -3.0), [-40.0]])
-
-for name, r in [("Anlage A", A), ("Anlage B", B)]:
- schwelle = np.percentile(r, 5) # 5-%-Quantil
- var =-schwelle # Value at Risk
- cvar =-r[r <= schwelle].mean() # Mittel der schlimmsten 5 %
-print(f"{name}: VaR(95) {var:5.2f} % CVaR(95) {cvar:5.2f} %")
+
import numpy as np
+
+gut = np.full(94, 0.5)
+A = np.concatenate([gut, np.full(6, -3.0)])
+B = np.concatenate([gut, np.full(5, -3.0), [-40.0]])
+
+for name, r in [("Anlage A", A), ("Anlage B", B)]:
+ schwelle = np.percentile(r, 5) # 5-%-Quantil
+ var =-schwelle # Value at Risk
+ cvar =-r[r <= schwelle].mean() # Mittel der schlimmsten 5 %
+print(f"{name}: VaR(95) {var:5.2f} % CVaR(95) {cvar:5.2f} %")
Erstens: die Normalverteilungs-Illusion. Die Varianz behandelt Aufwärts- und Abwärtsschwankungen gleich und unterstellt implizit symmetrische, dünn auslaufende Verteilungen. Reale Marktrenditen haben aber fette Ränder (fat tails) und negative Schiefe: Extreme Verluste treten deutlich häufiger auf, als eine Normalverteilung vorhersagt.
Zweitens: Reibungsblindheit. Ein ungedämpftes Mean-Variance-Modell schichtet bei minimalen Schätzänderungen das gesamte Portfolio um. Ohne Berücksichtigung von Gebühren, Spreads und Steuern frisst der Umschlag (turnover) den theoretischen Mehrertrag auf.
-
#!/usr/bin/env python3
-
-# VaR_CVaR_Demo.py
-"""
-Kapitel CVaR: Fat Tails, VaR und CVaR anschaulich.
-
-Teil 1: Wie oft treten "unmoegliche" Tage wirklich auf?
-Teil 2: Warum ist der VaR nicht subadditiv - ein Gegenbeispiel zum Nachrechnen.
-"""
-
-import numpy as np
-from scipy import stats
-
-
-def var_quantil(verluste: np.ndarray, alpha: float=0.95) ->float:
-"""VaR = Quantil der Verlustverteilung (Verluste positiv, Gewinne negativ)."""
-returnfloat(np.quantile(verluste, alpha))
-
-
-def cvar_rockafellar(verluste: np.ndarray, alpha: float=0.95) ->float:
-"""
- CVaR ueber die Rockafellar-Uryasev-Formel:
- CVaR = min_gamma { gamma + 1/(1-alpha) * E[max(Verlust - gamma, 0)] }
-
- WICHTIG: Der naheliegende Weg "Mittelwert aller Werte >= VaR" ist FALSCH,
- sobald die Verteilung Atome hat (z. B. genau zwei moegliche Verluste).
- Dann liegt der VaR selbst auf einem Atom, und der Vergleich '>=' erfasst
- zu viel Wahrscheinlichkeitsmasse. Die Formel unten behandelt das korrekt -
- und ist zugleich genau der Ausdruck, den wir im Abschnitt 'Value at Risk
- und Conditional Value at Risk' optimieren.
- """
- kandidaten = np.unique(verluste) # Optimum liegt immer auf einem Datenpunkt
-returnfloat(min(g + np.mean(np.maximum(verluste - g, 0.0)) / (1.0- alpha)
-for g in kandidaten))
-
-
-if__name__=="__main__":
- rng = np.random.default_rng(2026)
-
-# --- Teil 1: Fat Tails (analytisch, nicht simuliert) ------------------
-print("="*88)
-print(" TEIL 1: WIE OFT TRITT DAS 'UNMOEGLICHE' EIN?")
-print("="*88)
-print("Vergleich: Normalverteilung gegen t-Verteilung mit 3 Freiheitsgraden")
-print("(beide auf Standardabweichung 1 normiert).\n")
-
- t_verteilung = stats.t(df=3)
- skalierung = t_verteilung.std() # auf Varianz 1 bringen
-
-print(f"{'Ereignis':<22}{'Normal':>14}{'t (df=3)':>14}{'Faktor':>11} "
-f"{'Normal: 1 Tag in':>18}")
-print("-"*88)
-for k in [3, 4, 5, 6]:
- p_normal =2* stats.norm.sf(k) # beidseitig
- p_t =2* t_verteilung.sf(k * skalierung)
-print(f"Abweichung > {k} Sigma {p_normal*100:>13.6f} % {p_t*100:>13.6f} % "
-f"{p_t/p_normal:>10.1f}x {1/p_normal/252:>15,.0f} Jahre")
-
-print("\nDeutung: Ein 5-Sigma-Tag ist unter Normalverteilung ein Ereignis von")
-print("etwa einmal in 6.900 Jahren. Reale Aktienmaerkte liefern solche Tage")
-print("mehrfach pro Jahrzehnt. Wer allein mit Varianz steuert, plant fuer")
-print("eine Welt, in der Crashs praktisch nicht vorkommen.")
-
-# --- Teil 2: VaR ist nicht subadditiv ---------------------------------
-print("\n"+"="*88)
-print(" TEIL 2: WARUM DER VaR KEIN KOHAERENTES RISIKOMASS IST")
-print("="*88)
-print("Zwei unabhaengige Anleihen, je 100 EUR Nominal.")
-print("Jede faellt mit 4 % Wahrscheinlichkeit aus (Verlust 100),")
-print("sonst zahlt sie 2 EUR Kupon (Verlust -2).\n")
-
- ziehungen =2_000_000
- verlust_a = np.where(rng.random(ziehungen) <0.04, 100.0, -2.0)
- verlust_b = np.where(rng.random(ziehungen) <0.04, 100.0, -2.0)
- verlust_ab = verlust_a + verlust_b
-
-print(f"{'':<28}{'VaR 95%':>12}{'CVaR 95%':>12}")
-print("-"*88)
- werte = {}
-for name, v in [("Anleihe A allein", verlust_a),
- ("Anleihe B allein", verlust_b),
- ("Portfolio A+B", verlust_ab)]:
- werte[name] = (var_quantil(v), cvar_rockafellar(v))
-print(f"{name:<28}{werte[name][0]:>12.2f}{werte[name][1]:>12.2f}")
-
- var_summe = werte["Anleihe A allein"][0] + werte["Anleihe B allein"][0]
- cvar_summe = werte["Anleihe A allein"][1] + werte["Anleihe B allein"][1]
-print(f"{'Summe der Einzelwerte':<28}{var_summe:>12.2f}{cvar_summe:>12.2f}")
-
- var_port, cvar_port = werte["Portfolio A+B"]
-print("-"*88)
-print(f"VaR: Portfolio {var_port:7.2f} vs. Summe {var_summe:7.2f} -> "
-f"{'VERLETZT die Subadditivitaet!'if var_port > var_summe else'subadditiv'}")
-print(f"CVaR: Portfolio {cvar_port:7.2f} vs. Summe {cvar_summe:7.2f} -> "
-f"{'subadditiv (kohaerent)'if cvar_port <= cvar_summe +1e-6else'verletzt'}")
-
-print("\nDeutung: Einzeln betrachtet meldet der VaR fuer jede Anleihe einen")
-print("GEWINN von 2 EUR - denn mit 96 % Wahrscheinlichkeit passiert nichts,")
-print("und 4 % liegen unterhalb der 5-%-Schwelle. Im Portfolio steigt die")
-print("Wahrscheinlichkeit mindestens eines Ausfalls auf 7,8 % und damit UEBER")
-print("die Schwelle - der VaR springt auf 98. Er behauptet also, Streuung")
-print("habe das Risiko erhoeht. Das ist oekonomisch unsinnig und der Grund,")
-print("warum die Bankenaufsicht mit Basel III auf den Expected Shortfall")
-print("umgestellt hat.")
-print("="*88)
+
#!/usr/bin/env python3
+
+# VaR_CVaR_Demo.py
+"""
+Kapitel CVaR: Fat Tails, VaR und CVaR anschaulich.
+
+Teil 1: Wie oft treten "unmoegliche" Tage wirklich auf?
+Teil 2: Warum ist der VaR nicht subadditiv - ein Gegenbeispiel zum Nachrechnen.
+"""
+
+import numpy as np
+from scipy import stats
+
+
+def var_quantil(verluste: np.ndarray, alpha: float=0.95) ->float:
+"""VaR = Quantil der Verlustverteilung (Verluste positiv, Gewinne negativ)."""
+returnfloat(np.quantile(verluste, alpha))
+
+
+def cvar_rockafellar(verluste: np.ndarray, alpha: float=0.95) ->float:
+"""
+ CVaR ueber die Rockafellar-Uryasev-Formel:
+ CVaR = min_gamma { gamma + 1/(1-alpha) * E[max(Verlust - gamma, 0)] }
+
+ WICHTIG: Der naheliegende Weg "Mittelwert aller Werte >= VaR" ist FALSCH,
+ sobald die Verteilung Atome hat (z. B. genau zwei moegliche Verluste).
+ Dann liegt der VaR selbst auf einem Atom, und der Vergleich '>=' erfasst
+ zu viel Wahrscheinlichkeitsmasse. Die Formel unten behandelt das korrekt -
+ und ist zugleich genau der Ausdruck, den wir im Abschnitt 'Value at Risk
+ und Conditional Value at Risk' optimieren.
+ """
+ kandidaten = np.unique(verluste) # Optimum liegt immer auf einem Datenpunkt
+returnfloat(min(g + np.mean(np.maximum(verluste - g, 0.0)) / (1.0- alpha)
+for g in kandidaten))
+
+
+if__name__=="__main__":
+ rng = np.random.default_rng(2026)
+
+# --- Teil 1: Fat Tails (analytisch, nicht simuliert) ------------------
+print("="*88)
+print(" TEIL 1: WIE OFT TRITT DAS 'UNMOEGLICHE' EIN?")
+print("="*88)
+print("Vergleich: Normalverteilung gegen t-Verteilung mit 3 Freiheitsgraden")
+print("(beide auf Standardabweichung 1 normiert).\n")
+
+ t_verteilung = stats.t(df=3)
+ skalierung = t_verteilung.std() # auf Varianz 1 bringen
+
+print(f"{'Ereignis':<22}{'Normal':>14}{'t (df=3)':>14}{'Faktor':>11} "
+f"{'Normal: 1 Tag in':>18}")
+print("-"*88)
+for k in [3, 4, 5, 6]:
+ p_normal =2* stats.norm.sf(k) # beidseitig
+ p_t =2* t_verteilung.sf(k * skalierung)
+print(f"Abweichung > {k} Sigma {p_normal*100:>13.6f} % {p_t*100:>13.6f} % "
+f"{p_t/p_normal:>10.1f}x {1/p_normal/252:>15,.0f} Jahre")
+
+print("\nDeutung: Ein 5-Sigma-Tag ist unter Normalverteilung ein Ereignis von")
+print("etwa einmal in 6.900 Jahren. Reale Aktienmaerkte liefern solche Tage")
+print("mehrfach pro Jahrzehnt. Wer allein mit Varianz steuert, plant fuer")
+print("eine Welt, in der Crashs praktisch nicht vorkommen.")
+
+# --- Teil 2: VaR ist nicht subadditiv ---------------------------------
+print("\n"+"="*88)
+print(" TEIL 2: WARUM DER VaR KEIN KOHAERENTES RISIKOMASS IST")
+print("="*88)
+print("Zwei unabhaengige Anleihen, je 100 EUR Nominal.")
+print("Jede faellt mit 4 % Wahrscheinlichkeit aus (Verlust 100),")
+print("sonst zahlt sie 2 EUR Kupon (Verlust -2).\n")
+
+ ziehungen =2_000_000
+ verlust_a = np.where(rng.random(ziehungen) <0.04, 100.0, -2.0)
+ verlust_b = np.where(rng.random(ziehungen) <0.04, 100.0, -2.0)
+ verlust_ab = verlust_a + verlust_b
+
+print(f"{'':<28}{'VaR 95%':>12}{'CVaR 95%':>12}")
+print("-"*88)
+ werte = {}
+for name, v in [("Anleihe A allein", verlust_a),
+ ("Anleihe B allein", verlust_b),
+ ("Portfolio A+B", verlust_ab)]:
+ werte[name] = (var_quantil(v), cvar_rockafellar(v))
+print(f"{name:<28}{werte[name][0]:>12.2f}{werte[name][1]:>12.2f}")
+
+ var_summe = werte["Anleihe A allein"][0] + werte["Anleihe B allein"][0]
+ cvar_summe = werte["Anleihe A allein"][1] + werte["Anleihe B allein"][1]
+print(f"{'Summe der Einzelwerte':<28}{var_summe:>12.2f}{cvar_summe:>12.2f}")
+
+ var_port, cvar_port = werte["Portfolio A+B"]
+print("-"*88)
+print(f"VaR: Portfolio {var_port:7.2f} vs. Summe {var_summe:7.2f} -> "
+f"{'VERLETZT die Subadditivitaet!'if var_port > var_summe else'subadditiv'}")
+print(f"CVaR: Portfolio {cvar_port:7.2f} vs. Summe {cvar_summe:7.2f} -> "
+f"{'subadditiv (kohaerent)'if cvar_port <= cvar_summe +1e-6else'verletzt'}")
+
+print("\nDeutung: Einzeln betrachtet meldet der VaR fuer jede Anleihe einen")
+print("GEWINN von 2 EUR - denn mit 96 % Wahrscheinlichkeit passiert nichts,")
+print("und 4 % liegen unterhalb der 5-%-Schwelle. Im Portfolio steigt die")
+print("Wahrscheinlichkeit mindestens eines Ausfalls auf 7,8 % und damit UEBER")
+print("die Schwelle - der VaR springt auf 98. Er behauptet also, Streuung")
+print("habe das Risiko erhoeht. Das ist oekonomisch unsinnig und der Grund,")
+print("warum die Bankenaufsicht mit Basel III auf den Expected Shortfall")
+print("umgestellt hat.")
+print("="*88)
Erwartete Ausgabe (gekürzt):
========================================================================================
TEIL 1: WIE OFT TRITT DAS 'UNMOEGLICHE' EIN?
@@ -23017,215 +23385,215 @@ CVaR: Portfolio 101.33 vs. Summe 159.24 -> subadditiv (kohaerent)
⚠️ Einheiten konsistent halten
Ein häufiger Fehler verrechnet eine annualisierte Rendite gegen einen täglichen CVaR:
Der Risikoterm ist dadurch faktisch um Faktor 252 zu schwach gewichtet: lambda_risk = 1.5 bedeutet dann effektiv eine Risikoaversion von 1{,}5/252 \approx 0{,}006. Das Modell ist nicht falsch im Sinne von unlösbar — aber der Parameter bedeutet nicht, was er zu bedeuten scheint, und lässt sich daher nicht sinnvoll einstellen.
Dieses Programm rechnet deshalb durchgehend auf Tagesbasis und annualisiert erst in der Ausgabe.
-
#!/usr/bin/env python3
-
-# CVaR_Portfolio.py
-"""
-Kapitel CVaR: CVaR-Optimierung mit L1-Transaktionskosten via CVXPY.
-
-Eigenschaften:
- * Spaltenreihenfolge erzwungen
- * Einheiten konsistent (alles taeglich, Annualisierung nur in der Ausgabe)
- * Szenario-Nebenbedingungen VEKTORISIERT statt in einer Python-Schleife
- (eine Matrixbedingung statt S einzelner Constraints - deutlich schneller)
- * Vergleich CVaR- gegen Varianz-Optimierung
- * Nachrechnung von VaR/CVaR aus den realisierten Szenarien
-"""
-
-import time
-
-import cvxpy as cp
-import numpy as np
-import pandas as pd
-import yfinance as yf
-
-TICKER = ["AAPL", "MSFT", "NVDA", "AMZN", "JNJ", "PFE", "JPM", "GS", "XOM", "CVX"]
-ALPHA =0.95# Konfidenzniveau: schlechteste 5 % der Tage
-MAX_GEWICHT =0.25
-GEBUEHRENSATZ =0.002# 0,2 % je Einheit Turnover (Spread + Brokerage)
-RISIKOAVERSION =1.5# bezogen auf TAEGLICHE Groessen
-HANDELSTAGE =252
-
-
-def lade_renditen():
- ende = pd.Timestamp.today().normalize()
- start = ende - pd.DateOffset(years=2)
- roh = yf.download(TICKER, start=start, end=ende, auto_adjust=True, progress=False)
-if roh.empty:
-raiseSystemExit("Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).")
-
-ifisinstance(roh.columns, pd.MultiIndex):
- kurse = roh["Close"][TICKER].dropna() # erzwingt eigene Spaltenreihenfolge
-else:
- kurse = roh[["Close"]].dropna()
- kurse.columns = TICKER
-assertlist(kurse.columns) == TICKER, "Spaltenreihenfolge weicht ab!"
-return kurse.pct_change().dropna()
-
-
-def optimiere_cvar(R, w_alt, vektorisiert=True):
-"""
- Maximiere: taegliche Rendite - lambda * CVaR - Transaktionskosten
- Alle Groessen TAEGLICH.
- """
- S, N = R.shape
- mu_taeglich = R.mean(axis=0)
-
- w = cp.Variable(N, nonneg=True)
- gamma = cp.Variable() # wird im Optimum zum VaR
- u = cp.Variable(S, nonneg=True) # Ueberschuss ueber die Schwelle
-
- cvar = gamma + (1.0/ (S * (1.0- ALPHA))) * cp.sum(u)
- turnover = cp.norm1(w - w_alt)
- kosten = GEBUEHRENSATZ * turnover
-
- ziel = cp.Maximize(mu_taeglich @ w - RISIKOAVERSION * cvar - kosten)
-
- bedingungen = [cp.sum(w) ==1, w <= MAX_GEWICHT]
-if vektorisiert:
-# EINE Matrixbedingung statt S einzelner - deutlich schneller
- bedingungen.append(u >=-(R @ w) - gamma)
-else:
-for s inrange(S): # Alternative: S einzelne Constraints (langsamer)
- bedingungen.append(u[s] >=-R[s] @ w - gamma)
-
- problem = cp.Problem(ziel, bedingungen)
- problem.solve()
-if problem.status notin ("optimal", "optimal_inaccurate"):
-raiseSystemExit(f"CVaR-Problem nicht loesbar: {problem.status}")
-return w.value, float(gamma.value), float(cvar.value), float(turnover.value)
-
-
-def optimiere_varianz(R, w_alt):
-"""Klassisches Mean-Variance zum Vergleich - ebenfalls taeglich gerechnet.
-
- ACHTUNG, DCP-Falle: Die Standardabweichung ist hier NICHT als
- cp.sqrt(cp.quad_form(w, sigma)) formulierbar. cp.sqrt ist konkav und
- verlangt ein konkaves Argument; quad_form ist konvex - CVXPY lehnt den
- Ausdruck mit einem DCPError ab, und zwar voellig zu Recht (Anhang
- Fehlerdiagnose). cp.psd_wrap() hilft dagegen nicht: Es behebt eine
- NUMERISCHE Beanstandung an sigma, keine Regelverletzung im Aufbau.
-
- Der Standardweg ist die Cholesky-Zerlegung sigma = L L^T. Damit gilt
- w' sigma w = ||L^T w||^2, also ist die Standardabweichung die 2-Norm
- eines AFFINEN Ausdrucks - konvex und damit regelkonform.
- """
- N = R.shape[1]
- mu_taeglich = R.mean(axis=0)
- sigma = np.cov(R, rowvar=False, ddof=1)
-# Der winzige Diagonalzuschlag faengt den Fall ab, dass sigma numerisch
-# nur halbdefinit ist (mehr Titel als Handelstage, doppelte Spalten).
- L = np.linalg.cholesky(sigma +1e-12* np.eye(N))
-
- w = cp.Variable(N, nonneg=True)
- ziel = cp.Maximize(mu_taeglich @ w
-- RISIKOAVERSION * cp.norm2(L.T @ w)
-- GEBUEHRENSATZ * cp.norm1(w - w_alt))
- problem = cp.Problem(ziel, [cp.sum(w) ==1, w <= MAX_GEWICHT])
- problem.solve()
-if problem.status notin ("optimal", "optimal_inaccurate"):
-raiseSystemExit(f"Varianz-Problem nicht loesbar: {problem.status}")
-return w.value
-
-
-def realisierte_kennzahlen(w, R):
-"""VaR und CVaR direkt aus den Szenarien - unabhaengige Gegenprobe."""
- portfoliorenditen = R @ w
- verluste =-portfoliorenditen
- var =float(np.quantile(verluste, ALPHA))
- cvar =float(verluste[verluste >= var].mean())
-return var, cvar, float(portfoliorenditen.mean()), float(portfoliorenditen.std(ddof=1))
-
-
-if__name__=="__main__":
- renditen = lade_renditen()
- R = renditen.values
- S, N = R.shape
- w_alt = np.ones(N) / N # Ausgangslage: Gleichgewichtung
-
- t0 = time.perf_counter()
- w_cvar, var_modell, cvar_modell, turnover = optimiere_cvar(R, w_alt, vektorisiert=True)
- dauer_vektor = time.perf_counter() - t0
-
- w_var = optimiere_varianz(R, w_alt)
-
-print("="*90)
-print(" CVaR-PORTFOLIO-OPTIMIERUNG MIT TRANSAKTIONSKOSTEN")
-print("="*90)
-print(f"Datenbasis: {S} Handelstage, {N} Titel | Konfidenzniveau "
-f"{ALPHA*100:.0f} % | Loesungszeit {dauer_vektor:.2f} s\n")
-
-# --- Gegenprobe: Modellwerte gegen realisierte Szenariowerte ---------
- var_real, cvar_real, mu_real, sd_real = realisierte_kennzahlen(w_cvar, R)
-print("--- Gegenprobe: stimmen Modell und Szenarien ueberein? ---")
-print(f" VaR aus dem Modell (gamma): {var_modell*100:7.4f} % | "
-f"aus den Szenarien: {var_real*100:7.4f} %")
-print(f" CVaR aus dem Modell: {cvar_modell*100:7.4f} % | "
-f"aus den Szenarien: {cvar_real*100:7.4f} %")
-assertabs(cvar_modell - cvar_real) <1e-4, "CVaR stimmt nicht mit den Szenarien!"
-print(" -> Der Rockafellar-Uryasev-Trick liefert exakt den empirischen CVaR.")
-
-# --- Kennzahlen beider Portfolios ------------------------------------
-print(f"\n{'Portfolio':<24}{'Rendite p.a.':>13}{'Vola p.a.':>11} "
-f"{'VaR 95% (Tag)':>15}{'CVaR 95% (Tag)':>16}{'Turnover':>10}")
-print("-"*90)
-for name, w in [("CVaR-optimiert", w_cvar), ("Varianz-optimiert", w_var),
- ("Gleichgewichtung", w_alt)]:
- v, c, m, s = realisierte_kennzahlen(w, R)
- to =float(np.abs(w - w_alt).sum())
-print(f"{name:<24}{m*HANDELSTAGE*100:>12.2f} % "
-f"{s*np.sqrt(HANDELSTAGE)*100:>10.2f} % {v*100:>14.3f} % "
-f"{c*100:>15.3f} % {to*100:>9.1f} %")
-
-print("\nHinweis zur Annualisierung: Renditen werden mit 252 skaliert,")
-print("Volatilitaeten mit sqrt(252). Fuer VaR/CVaR ist eine solche Skalierung")
-print("nur unter starken Annahmen (Unabhaengigkeit, kein Drift) zulaessig -")
-print("sie werden hier deshalb bewusst als TAGESwerte ausgewiesen.")
-
-# --- Allokationstabelle ------------------------------------------------
-print("\n--- Allokation ---")
- tabelle = pd.DataFrame({
-"Ticker": TICKER,
-"vorher": [f"{v*100:5.1f} %"for v in w_alt],
-"CVaR-opt.": [f"{v*100:5.1f} %"for v in w_cvar],
-"Handel": [f"{(w_cvar[i]-w_alt[i])*100:+6.1f} %"for i inrange(N)],
-"Varianz-opt.": [f"{v*100:5.1f} %"for v in w_var],
- })
-print(tabelle.to_string(index=False))
-print(f"\nTurnover {turnover*100:.1f} % -> Transaktionskosten "
-f"{GEBUEHRENSATZ*turnover*100:.3f} % des Portfoliowerts")
-
-# --- Laufzeitvergleich vektorisiert vs. Schleife ----------------------
-if S <=600: # bei sehr vielen Szenarien zu langsam
- t0 = time.perf_counter()
- optimiere_cvar(R, w_alt, vektorisiert=False)
- dauer_schleife = time.perf_counter() - t0
-print(f"\n--- Laufzeit: {S} Nebenbedingungen aufbauen ---")
-print(f" vektorisiert (u >= -(R @ w) - gamma): {dauer_vektor:6.2f} s")
-print(f" Schleife ueber Szenarien (V01): {dauer_schleife:6.2f} s "
-f"({dauer_schleife/dauer_vektor:.1f}x langsamer)")
-print("="*90)
+
#!/usr/bin/env python3
+
+# CVaR_Portfolio.py
+"""
+Kapitel CVaR: CVaR-Optimierung mit L1-Transaktionskosten via CVXPY.
+
+Eigenschaften:
+ * Spaltenreihenfolge erzwungen
+ * Einheiten konsistent (alles taeglich, Annualisierung nur in der Ausgabe)
+ * Szenario-Nebenbedingungen VEKTORISIERT statt in einer Python-Schleife
+ (eine Matrixbedingung statt S einzelner Constraints - deutlich schneller)
+ * Vergleich CVaR- gegen Varianz-Optimierung
+ * Nachrechnung von VaR/CVaR aus den realisierten Szenarien
+"""
+
+import time
+
+import cvxpy as cp
+import numpy as np
+import pandas as pd
+import yfinance as yf
+
+TICKER = ["AAPL", "MSFT", "NVDA", "AMZN", "JNJ", "PFE", "JPM", "GS", "XOM", "CVX"]
+ALPHA =0.95# Konfidenzniveau: schlechteste 5 % der Tage
+MAX_GEWICHT =0.25
+GEBUEHRENSATZ =0.002# 0,2 % je Einheit Turnover (Spread + Brokerage)
+RISIKOAVERSION =1.5# bezogen auf TAEGLICHE Groessen
+HANDELSTAGE =252
+
+
+def lade_renditen():
+ ende = pd.Timestamp.today().normalize()
+ start = ende - pd.DateOffset(years=2)
+ roh = yf.download(TICKER, start=start, end=ende, auto_adjust=True, progress=False)
+if roh.empty:
+raiseSystemExit("Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).")
+
+ifisinstance(roh.columns, pd.MultiIndex):
+ kurse = roh["Close"][TICKER].dropna() # erzwingt eigene Spaltenreihenfolge
+else:
+ kurse = roh[["Close"]].dropna()
+ kurse.columns = TICKER
+assertlist(kurse.columns) == TICKER, "Spaltenreihenfolge weicht ab!"
+return kurse.pct_change().dropna()
+
+
+def optimiere_cvar(R, w_alt, vektorisiert=True):
+"""
+ Maximiere: taegliche Rendite - lambda * CVaR - Transaktionskosten
+ Alle Groessen TAEGLICH.
+ """
+ S, N = R.shape
+ mu_taeglich = R.mean(axis=0)
+
+ w = cp.Variable(N, nonneg=True)
+ gamma = cp.Variable() # wird im Optimum zum VaR
+ u = cp.Variable(S, nonneg=True) # Ueberschuss ueber die Schwelle
+
+ cvar = gamma + (1.0/ (S * (1.0- ALPHA))) * cp.sum(u)
+ turnover = cp.norm1(w - w_alt)
+ kosten = GEBUEHRENSATZ * turnover
+
+ ziel = cp.Maximize(mu_taeglich @ w - RISIKOAVERSION * cvar - kosten)
+
+ bedingungen = [cp.sum(w) ==1, w <= MAX_GEWICHT]
+if vektorisiert:
+# EINE Matrixbedingung statt S einzelner - deutlich schneller
+ bedingungen.append(u >=-(R @ w) - gamma)
+else:
+for s inrange(S): # Alternative: S einzelne Constraints (langsamer)
+ bedingungen.append(u[s] >=-R[s] @ w - gamma)
+
+ problem = cp.Problem(ziel, bedingungen)
+ problem.solve()
+if problem.status notin ("optimal", "optimal_inaccurate"):
+raiseSystemExit(f"CVaR-Problem nicht loesbar: {problem.status}")
+return w.value, float(gamma.value), float(cvar.value), float(turnover.value)
+
+
+def optimiere_varianz(R, w_alt):
+"""Klassisches Mean-Variance zum Vergleich - ebenfalls taeglich gerechnet.
+
+ ACHTUNG, DCP-Falle: Die Standardabweichung ist hier NICHT als
+ cp.sqrt(cp.quad_form(w, sigma)) formulierbar. cp.sqrt ist konkav und
+ verlangt ein konkaves Argument; quad_form ist konvex - CVXPY lehnt den
+ Ausdruck mit einem DCPError ab, und zwar voellig zu Recht (Anhang
+ Fehlerdiagnose). cp.psd_wrap() hilft dagegen nicht: Es behebt eine
+ NUMERISCHE Beanstandung an sigma, keine Regelverletzung im Aufbau.
+
+ Der Standardweg ist die Cholesky-Zerlegung sigma = L L^T. Damit gilt
+ w' sigma w = ||L^T w||^2, also ist die Standardabweichung die 2-Norm
+ eines AFFINEN Ausdrucks - konvex und damit regelkonform.
+ """
+ N = R.shape[1]
+ mu_taeglich = R.mean(axis=0)
+ sigma = np.cov(R, rowvar=False, ddof=1)
+# Der winzige Diagonalzuschlag faengt den Fall ab, dass sigma numerisch
+# nur halbdefinit ist (mehr Titel als Handelstage, doppelte Spalten).
+ L = np.linalg.cholesky(sigma +1e-12* np.eye(N))
+
+ w = cp.Variable(N, nonneg=True)
+ ziel = cp.Maximize(mu_taeglich @ w
+- RISIKOAVERSION * cp.norm2(L.T @ w)
+- GEBUEHRENSATZ * cp.norm1(w - w_alt))
+ problem = cp.Problem(ziel, [cp.sum(w) ==1, w <= MAX_GEWICHT])
+ problem.solve()
+if problem.status notin ("optimal", "optimal_inaccurate"):
+raiseSystemExit(f"Varianz-Problem nicht loesbar: {problem.status}")
+return w.value
+
+
+def realisierte_kennzahlen(w, R):
+"""VaR und CVaR direkt aus den Szenarien - unabhaengige Gegenprobe."""
+ portfoliorenditen = R @ w
+ verluste =-portfoliorenditen
+ var =float(np.quantile(verluste, ALPHA))
+ cvar =float(verluste[verluste >= var].mean())
+return var, cvar, float(portfoliorenditen.mean()), float(portfoliorenditen.std(ddof=1))
+
+
+if__name__=="__main__":
+ renditen = lade_renditen()
+ R = renditen.values
+ S, N = R.shape
+ w_alt = np.ones(N) / N # Ausgangslage: Gleichgewichtung
+
+ t0 = time.perf_counter()
+ w_cvar, var_modell, cvar_modell, turnover = optimiere_cvar(R, w_alt, vektorisiert=True)
+ dauer_vektor = time.perf_counter() - t0
+
+ w_var = optimiere_varianz(R, w_alt)
+
+print("="*90)
+print(" CVaR-PORTFOLIO-OPTIMIERUNG MIT TRANSAKTIONSKOSTEN")
+print("="*90)
+print(f"Datenbasis: {S} Handelstage, {N} Titel | Konfidenzniveau "
+f"{ALPHA*100:.0f} % | Loesungszeit {dauer_vektor:.2f} s\n")
+
+# --- Gegenprobe: Modellwerte gegen realisierte Szenariowerte ---------
+ var_real, cvar_real, mu_real, sd_real = realisierte_kennzahlen(w_cvar, R)
+print("--- Gegenprobe: stimmen Modell und Szenarien ueberein? ---")
+print(f" VaR aus dem Modell (gamma): {var_modell*100:7.4f} % | "
+f"aus den Szenarien: {var_real*100:7.4f} %")
+print(f" CVaR aus dem Modell: {cvar_modell*100:7.4f} % | "
+f"aus den Szenarien: {cvar_real*100:7.4f} %")
+assertabs(cvar_modell - cvar_real) <1e-4, "CVaR stimmt nicht mit den Szenarien!"
+print(" -> Der Rockafellar-Uryasev-Trick liefert exakt den empirischen CVaR.")
+
+# --- Kennzahlen beider Portfolios ------------------------------------
+print(f"\n{'Portfolio':<24}{'Rendite p.a.':>13}{'Vola p.a.':>11} "
+f"{'VaR 95% (Tag)':>15}{'CVaR 95% (Tag)':>16}{'Turnover':>10}")
+print("-"*90)
+for name, w in [("CVaR-optimiert", w_cvar), ("Varianz-optimiert", w_var),
+ ("Gleichgewichtung", w_alt)]:
+ v, c, m, s = realisierte_kennzahlen(w, R)
+ to =float(np.abs(w - w_alt).sum())
+print(f"{name:<24}{m*HANDELSTAGE*100:>12.2f} % "
+f"{s*np.sqrt(HANDELSTAGE)*100:>10.2f} % {v*100:>14.3f} % "
+f"{c*100:>15.3f} % {to*100:>9.1f} %")
+
+print("\nHinweis zur Annualisierung: Renditen werden mit 252 skaliert,")
+print("Volatilitaeten mit sqrt(252). Fuer VaR/CVaR ist eine solche Skalierung")
+print("nur unter starken Annahmen (Unabhaengigkeit, kein Drift) zulaessig -")
+print("sie werden hier deshalb bewusst als TAGESwerte ausgewiesen.")
+
+# --- Allokationstabelle ------------------------------------------------
+print("\n--- Allokation ---")
+ tabelle = pd.DataFrame({
+"Ticker": TICKER,
+"vorher": [f"{v*100:5.1f} %"for v in w_alt],
+"CVaR-opt.": [f"{v*100:5.1f} %"for v in w_cvar],
+"Handel": [f"{(w_cvar[i]-w_alt[i])*100:+6.1f} %"for i inrange(N)],
+"Varianz-opt.": [f"{v*100:5.1f} %"for v in w_var],
+ })
+print(tabelle.to_string(index=False))
+print(f"\nTurnover {turnover*100:.1f} % -> Transaktionskosten "
+f"{GEBUEHRENSATZ*turnover*100:.3f} % des Portfoliowerts")
+
+# --- Laufzeitvergleich vektorisiert vs. Schleife ----------------------
+if S <=600: # bei sehr vielen Szenarien zu langsam
+ t0 = time.perf_counter()
+ optimiere_cvar(R, w_alt, vektorisiert=False)
+ dauer_schleife = time.perf_counter() - t0
+print(f"\n--- Laufzeit: {S} Nebenbedingungen aufbauen ---")
+print(f" vektorisiert (u >= -(R @ w) - gamma): {dauer_vektor:6.2f} s")
+print(f" Schleife ueber Szenarien (V01): {dauer_schleife:6.2f} s "
+f"({dauer_schleife/dauer_vektor:.1f}x langsamer)")
+print("="*90)
💻 Code-Durchgang: Vektorisierung
Die entscheidende Zeile ist
-
bedingungen.append(u >=-(R @ w) - gamma) # EINE Matrixbedingung
+
bedingungen.append(u >=-(R @ w) - gamma) # EINE Matrixbedingung
statt
-
for s inrange(S):
- bedingungen.append(u[s] >=-R[s] @ w - gamma) # S einzelne Bedingungen
+
for s inrange(S):
+ bedingungen.append(u[s] >=-R[s] @ w - gamma) # S einzelne Bedingungen
Beide beschreiben dasselbe Modell. Der Unterschied liegt im Aufbau: CVXPY muss im zweiten Fall 500 einzelne Ausdrucksbäume erzeugen, prüfen und zusammensetzen. Bei einem Backtest mit 60 Rebalancings (Kapitel 21) summiert sich das erheblich.
Die allgemeine Regel: Wenn Sie in einer Modellierungssprache eine Python-Schleife über Datenzeilen schreiben, prüfen Sie, ob sich dasselbe als Matrixoperation ausdrücken lässt. Der Solver rechnet ohnehin mit Matrizen — der Umweg über einzelne Ausdrücke kostet nur Aufbauzeit.
⚠️ Die Standardabweichung ist in CVXPY keine Wurzel
Im Vergleichsmodell optimiere_varianz() steht die Standardabweichung als
und nicht als das Naheliegende, cp.sqrt(cp.quad_form(w, sigma)). Der naheliegende Ausdruck ist nicht DCP und wird von CVXPY mit einem DCPError abgelehnt: cp.sqrt ist konkav und verlangt deshalb ein konkaves Argument, quad_form ist aber konvex. Die Regel wird verletzt, obwohl die Funktion als ganze mathematisch völlig harmlos ist — \sqrt{w^\top \Sigma w} ist konvex, DCP kann es nur nicht sehen.
Wichtig ist die Fehlerdiagnose dahinter, weil sie leicht in die Irre geht: cp.psd_wrap(sigma) sieht nach der Lösung aus und ist keine. Es unterdrückt die numerische Beanstandung, dass \Sigma nicht als positiv semidefinit erkannt wird — gegen die strukturelle Regelverletzung im Aufbau hilft es nicht.
Der Ausweg ist immer derselbe: Zerlegen Sie \Sigma = LL^\top (Cholesky). Dann ist w^\top \Sigma w = \lVert L^\top w\rVert_2^2, und die Standardabweichung wird zur 2-Norm eines affinen Ausdrucks — konvex, regelkonform und für den Solver sogar die bessere Formulierung, weil sie direkt ein Kegelproblem ist. Der winzige Diagonalzuschlag 10^{-12} fängt den Fall ab, dass \Sigma numerisch nur halbdefinit ist (mehr Titel als Handelstage, duplizierte Spalten). Die vollständige Fehlertabelle steht in Anhang C.
🚀 In 5 Minuten gelöst: Dieselben Tage, ein anderes Schicksal
Zwei Strategien über zehn Jahre. Sie bestehen aus exakt denselben Tagesrenditen — nur in anderer Reihenfolge. Bei der einen sind die 250 schlechtesten Tage über den ganzen Zeitraum verstreut, bei der anderen liegen sie am Stück.
resample("MS") erzeugt Kalender-Monatsanfänge — den 1. Januar, den 1. Februar und so fort. Der Kursindex enthält aber nur Handelstage. Fällt der Monatserste auf ein Wochenende oder einen Feiertag, ist er kein Element des Index, die Bedingung wird nie wahr, und das Rebalancing entfällt kommentarlos.
Messung auf einem reinen Wochentagskalender über fünf Jahre:
Mit echten Börsenfeiertagen fallen weitere Termine aus. Der Backtest testete also eine andere Strategie als die beschriebene — und lieferte trotzdem plausibel aussehende Kennzahlen.
Die Korrektur:
-
monat =self.kurse.index.to_period("M")
-self.rebalancing_termine =self.kurse.index[~monat.duplicated()] # erster HANDELStag
-assertself.rebalancing_termine.isin(self.kurse.index).all()
+
monat =self.kurse.index.to_period("M")
+self.rebalancing_termine =self.kurse.index[~monat.duplicated()] # erster HANDELStag
+assertself.rebalancing_termine.isin(self.kurse.index).all()
~monat.duplicated() markiert den jeweils ersten vorhandenen Tag jedes Monats — also garantiert einen Handelstag.
Die Gewichte werden am Tag t aus Daten bis t bestimmt und auf die Rendite von t+1 angewendet. Diese eine Indexverschiebung ist der Unterschied zwischen einem ehrlichen und einem geschönten Backtest.
3. Der Lookahead-Selbsttest. Er verdreifacht künstlich alle Kurse nach dem Stichtag und prüft, ob sich das Signal von vor dem Stichtag dadurch ändert. Ändert es sich, greift der Code auf die Zukunft zu. Dieser Test kostet zehn Zeilen und findet eine Fehlerklasse, die sonst praktisch unsichtbar bleibt. Übernehmen Sie ihn in jedes Backtest-Projekt.
Zwischen zwei Rebalancings verändern sich die Gewichte von selbst — gestiegene Titel bekommen mehr Gewicht. Wer das weglässt, unterstellt implizit tägliches kostenloses Rebalancing und überschätzt die Strategie.
Das folgende Programm macht den Effekt messbar. Es testet Strategien, die nachweislich keinerlei Vorteil haben — ihre Tagesergebnisse sind Zufallszahlen mit Erwartungswert exakt null — und zeigt, wie gut die jeweils beste davon aussieht, allein als Funktion der Anzahl der Versuche.
-
#!/usr/bin/env python3
-
-# Data_Snooping.py
-"""
-Kapitel Handelsmaschine: Die Selbsttaeuschung, die man einem Backtest nicht ansieht.
-
-Backtest_Fallen.py zeigt drei Fehler, die IM Backtest stecken: Lookahead,
-Survivorship, vergessene Kosten. Alle drei kann man einem Programm ansehen,
-wenn man es liest.
-
-Die beiden anderen - Overfitting und Data Snooping - entstehen nicht im
-Backtest, sondern in der ARBEITSWEISE davor. Sie hinterlassen im Code keine
-Spur. Ein Backtest, der zwanzigmal angepasst wurde, sieht am Ende genauso
-sauber aus wie einer, der beim ersten Versuch funktioniert hat.
-
-Dieses Programm macht den Effekt messbar. Es testet Strategien, die
-NACHWEISLICH keinerlei Prognosekraft haben (ihre taeglichen Ergebnisse sind
-Zufallszahlen mit Erwartungswert exakt null), und zeigt, wie gut die jeweils
-BESTE davon aussieht - allein als Funktion der Anzahl der Versuche.
-
-Benoetigt: numpy, scipy
-"""
-
-from __future__ import annotations
-
-import numpy as np
-from scipy import stats
-
-HANDELSTAGE =1260# fuenf Jahre
-TAGESSCHWANKUNG =0.010# 1 % taeglich
-WIEDERHOLUNGEN =300# damit die Tabelle nicht vom Zufall abhaengt
-NIVEAU =0.05
-
-RNG = np.random.default_rng(42)
-
-
-def bewerte(ergebnisse: np.ndarray) ->tuple[np.ndarray, np.ndarray]:
-"""Sharpe-Kennzahl und einseitiger p-Wert je Strategie.
-
- ergebnisse hat die Form (Strategien, Handelstage). Der p-Wert prueft die
- Nullhypothese 'mittleres Tagesergebnis ist null' - also genau die Frage,
- die ein Backtest beantworten soll.
- """
- mittel = ergebnisse.mean(axis=1)
- streuung = ergebnisse.std(axis=1, ddof=1)
- sharpe = mittel / streuung * np.sqrt(252)
- t_wert = mittel / (streuung / np.sqrt(ergebnisse.shape[1]))
-return sharpe, stats.t.sf(t_wert, ergebnisse.shape[1] -1)
-
-
-def probiere(anzahl_strategien: int) ->tuple[float, float, float, float]:
-"""Liefert (beste Sharpe, ihr p-Wert, Anteil scheinbar signifikanter,
- Anteil, der auch die Bonferroni-Huerde nimmt) - gemittelt ueber
- WIEDERHOLUNGEN unabhaengige Durchgaenge."""
- beste_sharpe, beste_p, anteil_signifikant, ueberlebt_bonferroni = [], [], [], []
-
-for _ inrange(WIEDERHOLUNGEN):
-# Der springende Punkt: Erwartungswert exakt 0. Keine dieser
-# Strategien hat einen echten Vorteil - per Konstruktion.
- ergebnisse = RNG.normal(0.0, TAGESSCHWANKUNG,
- (anzahl_strategien, HANDELSTAGE))
- sharpe, p_werte = bewerte(ergebnisse)
-
- beste =int(sharpe.argmax())
- beste_sharpe.append(float(sharpe[beste]))
- beste_p.append(float(p_werte[beste]))
- anteil_signifikant.append(float((p_werte < NIVEAU).mean()))
-# Bonferroni: Wer n Tests macht, muss die Huerde durch n teilen.
- ueberlebt_bonferroni.append(
-float((p_werte < NIVEAU / anzahl_strategien).mean()))
-
-return (float(np.mean(beste_sharpe)), float(np.mean(beste_p)),
-float(np.mean(anteil_signifikant)),
-float(np.mean(ueberlebt_bonferroni)))
-
-
-if__name__=="__main__":
-print("="*78)
-print(" WIE GUT SIEHT DIE BESTE VON N STRATEGIEN AUS,")
-print(" WENN KEINE EINZIGE ETWAS TAUGT?")
-print("="*78)
-print(f"{HANDELSTAGE} Handelstage, taegliche Ergebnisse mit Erwartungswert "
-f"EXAKT null.")
-print(f"Jede Zeile ist der Mittelwert aus {WIEDERHOLUNGEN} unabhaengigen "
-f"Durchgaengen.\n")
-
-print(f"{'getestet':>9}{'beste Sharpe':>14}{'ihr p-Wert':>12} "
-f"{'scheinbar sign.':>16}")
-print("-"*78)
- ergebnisse = {}
-for anzahl in (1, 10, 100, 1000):
- sharpe, p_wert, anteil, _ = probiere(anzahl)
- ergebnisse[anzahl] = (sharpe, p_wert, anteil)
-print(f"{anzahl:9d}{sharpe:14.2f}{p_wert:12.4f}{anteil *100:15.1f} %")
-print("-"*78)
-
- sharpe_100, p_100, _ = ergebnisse[100]
- sharpe_1000, p_1000, _ = ergebnisse[1000]
-
-print("\nLesen Sie die dritte Spalte von unten nach oben:")
-print(f" Wer {1000} Varianten durchprobiert, findet eine mit p = {p_1000:.4f} -")
-print(" ein Wert, den jede Zeitschrift als 'hochsignifikant' druckt.")
-print(f" Wer {100} probiert, findet p = {p_100:.4f} und eine Sharpe-Kennzahl")
-print(f" von {sharpe_100:.2f} - damit geht man zum Vorgesetzten.")
-print(" Und keine dieser Strategien hat auch nur den Hauch eines Vorteils.")
-print()
-print("Die LETZTE Spalte bleibt dagegen konstant bei rund 5 %. Das ist kein")
-print("Widerspruch, sondern die Definition: Ein Test zum 5-%-Niveau irrt in")
-print("5 % der Faelle. Was sich aendert, ist nicht der Anteil, sondern die")
-print("ANZAHL - und weil man nur die beste Strategie praesentiert, sieht man")
-print("von allen Fehlversuchen genau einen: den erfolgreichsten.")
-
-print("\n"+"="*78)
-print(" WAS MAN DAGEGEN TUN KANN")
-print("="*78)
-print("1. ZAEHLEN. Notieren Sie, wie viele Varianten Sie ausprobiert haben -")
-print(" auch die verworfenen, auch die 'nur mal schnell'. Ohne diese Zahl")
-print(" ist kein p-Wert und keine Sharpe-Kennzahl interpretierbar.")
-print()
-print("2. KORRIGIEREN. Die Bonferroni-Korrektur teilt die Huerde durch die")
-print(" Zahl der Versuche:")
-for anzahl in (10, 100, 1000):
-print(f" bei {anzahl:5d} Versuchen: p < {NIVEAU / anzahl:.5f} statt "
-f"p < {NIVEAU:.2f}")
-print(" Sie ist konservativ, aber sie ist ehrlich. Fuer Handelsstrategien")
-print(" gibt es verfeinerte Varianten (Deflated Sharpe Ratio nach Bailey")
-print(" und Lopez de Prado), die dasselbe Prinzip verfolgen.")
-print()
-print("3. ZURUECKHALTEN. Legen Sie einen Zeitraum beiseite, BEVOR Sie")
-print(" anfangen, und ruehren Sie ihn bis zum Schluss nicht an. Er ist")
-print(" der einzige Test, den Sie nicht durch Probieren verderben koennen -")
-print(" und er ist genau einmal verwendbar.")
-print("="*78)
+
#!/usr/bin/env python3
+
+# Data_Snooping.py
+"""
+Kapitel Handelsmaschine: Die Selbsttaeuschung, die man einem Backtest nicht ansieht.
+
+Backtest_Fallen.py zeigt drei Fehler, die IM Backtest stecken: Lookahead,
+Survivorship, vergessene Kosten. Alle drei kann man einem Programm ansehen,
+wenn man es liest.
+
+Die beiden anderen - Overfitting und Data Snooping - entstehen nicht im
+Backtest, sondern in der ARBEITSWEISE davor. Sie hinterlassen im Code keine
+Spur. Ein Backtest, der zwanzigmal angepasst wurde, sieht am Ende genauso
+sauber aus wie einer, der beim ersten Versuch funktioniert hat.
+
+Dieses Programm macht den Effekt messbar. Es testet Strategien, die
+NACHWEISLICH keinerlei Prognosekraft haben (ihre taeglichen Ergebnisse sind
+Zufallszahlen mit Erwartungswert exakt null), und zeigt, wie gut die jeweils
+BESTE davon aussieht - allein als Funktion der Anzahl der Versuche.
+
+Benoetigt: numpy, scipy
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from scipy import stats
+
+HANDELSTAGE =1260# fuenf Jahre
+TAGESSCHWANKUNG =0.010# 1 % taeglich
+WIEDERHOLUNGEN =300# damit die Tabelle nicht vom Zufall abhaengt
+NIVEAU =0.05
+
+RNG = np.random.default_rng(42)
+
+
+def bewerte(ergebnisse: np.ndarray) ->tuple[np.ndarray, np.ndarray]:
+"""Sharpe-Kennzahl und einseitiger p-Wert je Strategie.
+
+ ergebnisse hat die Form (Strategien, Handelstage). Der p-Wert prueft die
+ Nullhypothese 'mittleres Tagesergebnis ist null' - also genau die Frage,
+ die ein Backtest beantworten soll.
+ """
+ mittel = ergebnisse.mean(axis=1)
+ streuung = ergebnisse.std(axis=1, ddof=1)
+ sharpe = mittel / streuung * np.sqrt(252)
+ t_wert = mittel / (streuung / np.sqrt(ergebnisse.shape[1]))
+return sharpe, stats.t.sf(t_wert, ergebnisse.shape[1] -1)
+
+
+def probiere(anzahl_strategien: int) ->tuple[float, float, float, float]:
+"""Liefert (beste Sharpe, ihr p-Wert, Anteil scheinbar signifikanter,
+ Anteil, der auch die Bonferroni-Huerde nimmt) - gemittelt ueber
+ WIEDERHOLUNGEN unabhaengige Durchgaenge."""
+ beste_sharpe, beste_p, anteil_signifikant, ueberlebt_bonferroni = [], [], [], []
+
+for _ inrange(WIEDERHOLUNGEN):
+# Der springende Punkt: Erwartungswert exakt 0. Keine dieser
+# Strategien hat einen echten Vorteil - per Konstruktion.
+ ergebnisse = RNG.normal(0.0, TAGESSCHWANKUNG,
+ (anzahl_strategien, HANDELSTAGE))
+ sharpe, p_werte = bewerte(ergebnisse)
+
+ beste =int(sharpe.argmax())
+ beste_sharpe.append(float(sharpe[beste]))
+ beste_p.append(float(p_werte[beste]))
+ anteil_signifikant.append(float((p_werte < NIVEAU).mean()))
+# Bonferroni: Wer n Tests macht, muss die Huerde durch n teilen.
+ ueberlebt_bonferroni.append(
+float((p_werte < NIVEAU / anzahl_strategien).mean()))
+
+return (float(np.mean(beste_sharpe)), float(np.mean(beste_p)),
+float(np.mean(anteil_signifikant)),
+float(np.mean(ueberlebt_bonferroni)))
+
+
+if__name__=="__main__":
+print("="*78)
+print(" WIE GUT SIEHT DIE BESTE VON N STRATEGIEN AUS,")
+print(" WENN KEINE EINZIGE ETWAS TAUGT?")
+print("="*78)
+print(f"{HANDELSTAGE} Handelstage, taegliche Ergebnisse mit Erwartungswert "
+f"EXAKT null.")
+print(f"Jede Zeile ist der Mittelwert aus {WIEDERHOLUNGEN} unabhaengigen "
+f"Durchgaengen.\n")
+
+print(f"{'getestet':>9}{'beste Sharpe':>14}{'ihr p-Wert':>12} "
+f"{'scheinbar sign.':>16}")
+print("-"*78)
+ ergebnisse = {}
+for anzahl in (1, 10, 100, 1000):
+ sharpe, p_wert, anteil, _ = probiere(anzahl)
+ ergebnisse[anzahl] = (sharpe, p_wert, anteil)
+print(f"{anzahl:9d}{sharpe:14.2f}{p_wert:12.4f}{anteil *100:15.1f} %")
+print("-"*78)
+
+ sharpe_100, p_100, _ = ergebnisse[100]
+ sharpe_1000, p_1000, _ = ergebnisse[1000]
+
+print("\nLesen Sie die dritte Spalte von unten nach oben:")
+print(f" Wer {1000} Varianten durchprobiert, findet eine mit p = {p_1000:.4f} -")
+print(" ein Wert, den jede Zeitschrift als 'hochsignifikant' druckt.")
+print(f" Wer {100} probiert, findet p = {p_100:.4f} und eine Sharpe-Kennzahl")
+print(f" von {sharpe_100:.2f} - damit geht man zum Vorgesetzten.")
+print(" Und keine dieser Strategien hat auch nur den Hauch eines Vorteils.")
+print()
+print("Die LETZTE Spalte bleibt dagegen konstant bei rund 5 %. Das ist kein")
+print("Widerspruch, sondern die Definition: Ein Test zum 5-%-Niveau irrt in")
+print("5 % der Faelle. Was sich aendert, ist nicht der Anteil, sondern die")
+print("ANZAHL - und weil man nur die beste Strategie praesentiert, sieht man")
+print("von allen Fehlversuchen genau einen: den erfolgreichsten.")
+
+print("\n"+"="*78)
+print(" WAS MAN DAGEGEN TUN KANN")
+print("="*78)
+print("1. ZAEHLEN. Notieren Sie, wie viele Varianten Sie ausprobiert haben -")
+print(" auch die verworfenen, auch die 'nur mal schnell'. Ohne diese Zahl")
+print(" ist kein p-Wert und keine Sharpe-Kennzahl interpretierbar.")
+print()
+print("2. KORRIGIEREN. Die Bonferroni-Korrektur teilt die Huerde durch die")
+print(" Zahl der Versuche:")
+for anzahl in (10, 100, 1000):
+print(f" bei {anzahl:5d} Versuchen: p < {NIVEAU / anzahl:.5f} statt "
+f"p < {NIVEAU:.2f}")
+print(" Sie ist konservativ, aber sie ist ehrlich. Fuer Handelsstrategien")
+print(" gibt es verfeinerte Varianten (Deflated Sharpe Ratio nach Bailey")
+print(" und Lopez de Prado), die dasselbe Prinzip verfolgen.")
+print()
+print("3. ZURUECKHALTEN. Legen Sie einen Zeitraum beiseite, BEVOR Sie")
+print(" anfangen, und ruehren Sie ihn bis zum Schluss nicht an. Er ist")
+print(" der einzige Test, den Sie nicht durch Probieren verderben koennen -")
+print(" und er ist genau einmal verwendbar.")
+print("="*78)
Erwartete Ausgabe:
==============================================================================
WIE GUT SIEHT DIE BESTE VON N STRATEGIEN AUS,
@@ -24288,25 +24656,25 @@ von allen Fehlversuchen genau einen: den erfolgreichsten.
Die vorangegangenen vierzehn Kapitel enden fast alle mit derselben Empfehlung: Prüfen Sie das Ergebnis gegen die Wirklichkeit. Dieses Kapitel beginnt damit, diese Prüfung als wiederverwendbaren Baustein hinzuschreiben.
🚀 In 5 Minuten gelöst: Die Abnahmeprüfung, die in jedes Modell gehört
-
def pruefe_loesung(x, A, b, kosten, zielwert, ganzzahlig=(), toleranz=1e-6):
-"""Prueft eine Loesung gegen die Anforderungen - OHNE den Solver zu fragen."""
- beanstandungen = []
-
- verbrauch = A @ x # 1. Werden Grenzen eingehalten?
-for i, (ist, grenze) inenumerate(zip(verbrauch, b)):
-if ist > grenze + toleranz:
- beanstandungen.append(f"Bedingung {i}: {ist:.4f} > {grenze:.4f}")
-
-for j in ganzzahlig: # 2. Ist ganzzahlig auch ganzzahlig?
-ifabs(x[j] -round(x[j])) > toleranz:
- beanstandungen.append(f"x[{j}] = {x[j]!r} ist nicht ganzzahlig")
-
- nachgerechnet =float(kosten @ x) # 3. Stimmt der Zielwert?
-ifabs(nachgerechnet - zielwert) > toleranz *max(1.0, abs(nachgerechnet)):
- beanstandungen.append(
-f"Zielwert {zielwert:.4f} passt nicht zu den Daten ({nachgerechnet:.4f})")
-
-return beanstandungen
+
def pruefe_loesung(x, A, b, kosten, zielwert, ganzzahlig=(), toleranz=1e-6):
+"""Prueft eine Loesung gegen die Anforderungen - OHNE den Solver zu fragen."""
+ beanstandungen = []
+
+ verbrauch = A @ x # 1. Werden Grenzen eingehalten?
+for i, (ist, grenze) inenumerate(zip(verbrauch, b)):
+if ist > grenze + toleranz:
+ beanstandungen.append(f"Bedingung {i}: {ist:.4f} > {grenze:.4f}")
+
+for j in ganzzahlig: # 2. Ist ganzzahlig auch ganzzahlig?
+ifabs(x[j] -round(x[j])) > toleranz:
+ beanstandungen.append(f"x[{j}] = {x[j]!r} ist nicht ganzzahlig")
+
+ nachgerechnet =float(kosten @ x) # 3. Stimmt der Zielwert?
+ifabs(nachgerechnet - zielwert) > toleranz *max(1.0, abs(nachgerechnet)):
+ beanstandungen.append(
+f"Zielwert {zielwert:.4f} passt nicht zu den Daten ({nachgerechnet:.4f})")
+
+return beanstandungen
Zwölf Zeilen, drei Prüfungen — und jede davon hätte einen der Denkfehler dieses Buches gefunden:
@@ -24375,118 +24743,118 @@ von allen Fehlversuchen genau einen: den erfolgreichsten.
Falsch: Das System stürzt ab oder zeigt „Fehler“. Der Disponent steht ohne Plan da.
Richtig:Hierarchische Relaxation. Jede harte Bedingung, die im Notfall gebrochen werden darf, erhält eine teure Schlupfvariable. So liefert der Solver immer einen Plan — und zeigt zugleich präzise, wo es klemmt.
-
#!/usr/bin/env python3
-
-# Infeasibility_Diagnose.py
-"""
-Kapitel Praxisfallen: Aus INFEASIBLE einen Notfallplan machen.
-
-Demonstriert die hierarchische Relaxation an einem Dienstplan, der in der
-harten Fassung unloesbar ist, und zeigt, wie der Solver die Ursache benennt.
-"""
-
-from ortools.sat.python import cp_model
-
-SLOTS = ["Mo frueh", "Mo spaet", "Di frueh", "Di spaet"]
-FAECHER = ["Mathematik", "Physik", "Mathematik", "Chemie"] # Chemie kann niemand!
-PERSONAL = ["Alice", "Bob", "Carla"]
-QUALIFIKATION = {
-"Alice": {"Mathematik", "Physik"},
-"Bob": {"Mathematik"},
-"Carla": {"Physik", "Mathematik"},
-}
-MAX_PRO_PERSON =2
-STRAFE_UNBESETZT =10_000# sehr teuer, aber nicht unmoeglich
-STRAFE_UEBERLAST =500# teuer, aber billiger als Ausfall
-
-
-def plane(harte_fassung: bool):
-"""harte_fassung=True: klassisch (kann INFEASIBLE werden).
- harte_fassung=False: mit Schlupfvariablen (immer loesbar)."""
- modell = cp_model.CpModel()
- x = {(p, s): modell.NewBoolVar(f"x_{p}_{s}")
-for p in PERSONAL for s inrange(len(SLOTS))}
-
- strafen = []
- unbesetzt = {}
- ueberlast = {}
-
-for s inrange(len(SLOTS)):
-if harte_fassung:
- modell.AddExactlyOne(x[p, s] for p in PERSONAL)
-else:
-# Schlupfvariable: der Slot DARF unbesetzt bleiben - gegen hohe Strafe
- unbesetzt[s] = modell.NewBoolVar(f"unbesetzt_{s}")
- modell.AddExactlyOne([x[p, s] for p in PERSONAL] + [unbesetzt[s]])
- strafen.append(unbesetzt[s] * STRAFE_UNBESETZT)
-
-# Qualifikation bleibt IMMER hart - fachfremder Unterricht ist keine Option
-for s, fach inenumerate(FAECHER):
-for p in PERSONAL:
-if fach notin QUALIFIKATION[p]:
- modell.Add(x[p, s] ==0)
-
-for p in PERSONAL:
- last =sum(x[p, s] for s inrange(len(SLOTS)))
-if harte_fassung:
- modell.Add(last <= MAX_PRO_PERSON)
-else:
-# Ueberlast erlaubt - aber teuer
- ueberlast[p] = modell.NewIntVar(0, len(SLOTS), f"ueberlast_{p}")
- modell.Add(last <= MAX_PRO_PERSON + ueberlast[p])
- strafen.append(ueberlast[p] * STRAFE_UEBERLAST)
-
-if strafen:
- modell.Minimize(sum(strafen))
-
- loeser = cp_model.CpSolver()
- loeser.parameters.max_time_in_seconds =5.0
- status = loeser.Solve(modell)
-return loeser, status, x, unbesetzt, ueberlast
-
-
-if__name__=="__main__":
-print("="*78)
-print(" VARIANTE A: KLASSISCH MIT LAUTER HARTEN BEDINGUNGEN")
-print("="*78)
- loeser, status, *_ = plane(harte_fassung=True)
-print(f"Solver-Status: {loeser.StatusName(status)}")
-if status == cp_model.INFEASIBLE:
-print("Das System kann keinen Plan liefern. Der Anwender erfaehrt NICHT,")
-print("welche Regel das Problem verursacht - nur, dass es nicht geht.\n")
-
-print("="*78)
-print(" VARIANTE B: MIT HIERARCHISCHER RELAXATION")
-print("="*78)
- loeser, status, x, unbesetzt, ueberlast = plane(harte_fassung=False)
-print(f"Solver-Status: {loeser.StatusName(status)} | "
-f"Strafkosten: {loeser.ObjectiveValue():.0f}\n")
-
-print(f"{'Slot':<12}{'Fach':<12}{'Zuweisung':<18}{'Bemerkung'}")
-print("-"*78)
-for s, slot inenumerate(SLOTS):
-if loeser.Value(unbesetzt[s]):
-print(f"{slot:<12}{FAECHER[s]:<12}{'-- UNBESETZT --':<18} "
-f"Kein qualifiziertes Personal verfuegbar")
-else:
- person =next(p for p in PERSONAL if loeser.Value(x[p, s]))
-print(f"{slot:<12}{FAECHER[s]:<12}{person:<18}")
-
-print("\n--- Diagnose ---")
-for s, slot inenumerate(SLOTS):
-if loeser.Value(unbesetzt[s]):
- fach = FAECHER[s]
- qualifiziert = [p for p in PERSONAL if fach in QUALIFIKATION[p]]
-print(f" '{slot}' ({fach}): {len(qualifiziert)} qualifizierte Personen "
-f"{qualifiziert if qualifiziert else'-> URSACHE: niemand kann dieses Fach'}")
-for p in PERSONAL:
-if loeser.Value(ueberlast[p]):
-print(f" {p} arbeitet {loeser.Value(ueberlast[p])} Stunden ueber der Grenze.")
-
-print("\nDer Anwender bekommt jetzt einen Plan PLUS eine konkrete Ursache -")
-print("und kann handeln: Vertretung von aussen holen, Stunde verlegen,")
-print("oder die Klasse zusammenlegen.")
-print("="*78)
+
#!/usr/bin/env python3
+
+# Infeasibility_Diagnose.py
+"""
+Kapitel Praxisfallen: Aus INFEASIBLE einen Notfallplan machen.
+
+Demonstriert die hierarchische Relaxation an einem Dienstplan, der in der
+harten Fassung unloesbar ist, und zeigt, wie der Solver die Ursache benennt.
+"""
+
+from ortools.sat.python import cp_model
+
+SLOTS = ["Mo frueh", "Mo spaet", "Di frueh", "Di spaet"]
+FAECHER = ["Mathematik", "Physik", "Mathematik", "Chemie"] # Chemie kann niemand!
+PERSONAL = ["Alice", "Bob", "Carla"]
+QUALIFIKATION = {
+"Alice": {"Mathematik", "Physik"},
+"Bob": {"Mathematik"},
+"Carla": {"Physik", "Mathematik"},
+}
+MAX_PRO_PERSON =2
+STRAFE_UNBESETZT =10_000# sehr teuer, aber nicht unmoeglich
+STRAFE_UEBERLAST =500# teuer, aber billiger als Ausfall
+
+
+def plane(harte_fassung: bool):
+"""harte_fassung=True: klassisch (kann INFEASIBLE werden).
+ harte_fassung=False: mit Schlupfvariablen (immer loesbar)."""
+ modell = cp_model.CpModel()
+ x = {(p, s): modell.NewBoolVar(f"x_{p}_{s}")
+for p in PERSONAL for s inrange(len(SLOTS))}
+
+ strafen = []
+ unbesetzt = {}
+ ueberlast = {}
+
+for s inrange(len(SLOTS)):
+if harte_fassung:
+ modell.AddExactlyOne(x[p, s] for p in PERSONAL)
+else:
+# Schlupfvariable: der Slot DARF unbesetzt bleiben - gegen hohe Strafe
+ unbesetzt[s] = modell.NewBoolVar(f"unbesetzt_{s}")
+ modell.AddExactlyOne([x[p, s] for p in PERSONAL] + [unbesetzt[s]])
+ strafen.append(unbesetzt[s] * STRAFE_UNBESETZT)
+
+# Qualifikation bleibt IMMER hart - fachfremder Unterricht ist keine Option
+for s, fach inenumerate(FAECHER):
+for p in PERSONAL:
+if fach notin QUALIFIKATION[p]:
+ modell.Add(x[p, s] ==0)
+
+for p in PERSONAL:
+ last =sum(x[p, s] for s inrange(len(SLOTS)))
+if harte_fassung:
+ modell.Add(last <= MAX_PRO_PERSON)
+else:
+# Ueberlast erlaubt - aber teuer
+ ueberlast[p] = modell.NewIntVar(0, len(SLOTS), f"ueberlast_{p}")
+ modell.Add(last <= MAX_PRO_PERSON + ueberlast[p])
+ strafen.append(ueberlast[p] * STRAFE_UEBERLAST)
+
+if strafen:
+ modell.Minimize(sum(strafen))
+
+ loeser = cp_model.CpSolver()
+ loeser.parameters.max_time_in_seconds =5.0
+ status = loeser.Solve(modell)
+return loeser, status, x, unbesetzt, ueberlast
+
+
+if__name__=="__main__":
+print("="*78)
+print(" VARIANTE A: KLASSISCH MIT LAUTER HARTEN BEDINGUNGEN")
+print("="*78)
+ loeser, status, *_ = plane(harte_fassung=True)
+print(f"Solver-Status: {loeser.StatusName(status)}")
+if status == cp_model.INFEASIBLE:
+print("Das System kann keinen Plan liefern. Der Anwender erfaehrt NICHT,")
+print("welche Regel das Problem verursacht - nur, dass es nicht geht.\n")
+
+print("="*78)
+print(" VARIANTE B: MIT HIERARCHISCHER RELAXATION")
+print("="*78)
+ loeser, status, x, unbesetzt, ueberlast = plane(harte_fassung=False)
+print(f"Solver-Status: {loeser.StatusName(status)} | "
+f"Strafkosten: {loeser.ObjectiveValue():.0f}\n")
+
+print(f"{'Slot':<12}{'Fach':<12}{'Zuweisung':<18}{'Bemerkung'}")
+print("-"*78)
+for s, slot inenumerate(SLOTS):
+if loeser.Value(unbesetzt[s]):
+print(f"{slot:<12}{FAECHER[s]:<12}{'-- UNBESETZT --':<18} "
+f"Kein qualifiziertes Personal verfuegbar")
+else:
+ person =next(p for p in PERSONAL if loeser.Value(x[p, s]))
+print(f"{slot:<12}{FAECHER[s]:<12}{person:<18}")
+
+print("\n--- Diagnose ---")
+for s, slot inenumerate(SLOTS):
+if loeser.Value(unbesetzt[s]):
+ fach = FAECHER[s]
+ qualifiziert = [p for p in PERSONAL if fach in QUALIFIKATION[p]]
+print(f" '{slot}' ({fach}): {len(qualifiziert)} qualifizierte Personen "
+f"{qualifiziert if qualifiziert else'-> URSACHE: niemand kann dieses Fach'}")
+for p in PERSONAL:
+if loeser.Value(ueberlast[p]):
+print(f" {p} arbeitet {loeser.Value(ueberlast[p])} Stunden ueber der Grenze.")
+
+print("\nDer Anwender bekommt jetzt einen Plan PLUS eine konkrete Ursache -")
+print("und kann handeln: Vertretung von aussen holen, Stunde verlegen,")
+print("oder die Klasse zusammenlegen.")
+print("="*78)
Falle 2 — Der Black-Box-Effekt
Ein Planer oder Trader lehnt ein System ab, wenn er die Zuweisungen nicht nachvollziehen kann. Akzeptanz ist keine Nebensache, sondern Voraussetzung für den Betrieb.
Jede Entscheidung braucht einen Constraint-Trace:
@@ -24499,91 +24867,91 @@ von allen Fehlversuchen genau einen: den erfolgreichsten.
Kapitel 5 liefert für Frage 3 das Werkzeug. Nutzen Sie es — Schattenpreise sind in Managementgesprächen oft wertvoller als die Lösung selbst.
Ein vollständiger Constraint-Trace beantwortet alle drei Fragen der Faustregel für eine einzelne Zuweisung — inklusive einer Unterscheidung, die in der Praxis oft übersehen wird: eine Bedingung kann blockieren, ohne etwas zu kosten.
-
#!/usr/bin/env python3
-
-# Erklaerbarkeit.py
-"""
-Kapitel Praxisfallen: Ein Constraint-Trace, der die drei Fragen der Faustregel fuer
-Erklaerbarkeit (Abschnitt 'Die fuenf typischen Praxisfallen') fuer eine
-einzelne Zuweisung beantwortet:
-Warum diese Loesung? Warum nicht die Alternative? Was wuerde sie verbessern?
-"""
-
-from ortools.sat.python import cp_model
-
-PERSONAL = ["Anna", "Ben", "Clara"]
-BEREITS_STUNDEN = {"Anna": 1, "Ben": 2, "Clara": 0} # diese Woche schon geleistet
-MAX_STUNDEN =2# gesetzliche Obergrenze
-STRAFE_VORBELASTUNG =50# pro bereits geleisteter Stunde
-STRAFKOSTEN_WUNSCH = {"Anna": 0, "Ben": 30, "Clara": 100}
-
-
-def loese(max_stunden: int):
- modell = cp_model.CpModel()
- x = {p: modell.NewBoolVar(f"x_{p}") for p in PERSONAL}
- modell.AddExactlyOne(x.values())
-
- kosten = []
-for p in PERSONAL:
-if BEREITS_STUNDEN[p] +1> max_stunden:
- modell.Add(x[p] ==0) # hartes Ausschlusskriterium
- strafe = BEREITS_STUNDEN[p] * STRAFE_VORBELASTUNG + STRAFKOSTEN_WUNSCH[p]
- kosten.append(strafe * x[p])
-
- modell.Minimize(sum(kosten))
- loeser = cp_model.CpSolver()
- status = loeser.Solve(modell)
-if status notin (cp_model.OPTIMAL, cp_model.FEASIBLE):
-returnNone
- gewinner =next(p for p in PERSONAL if loeser.Value(x[p]))
-return gewinner, loeser.ObjectiveValue()
-
-
-def gesamtkosten(p: str) ->int:
-return BEREITS_STUNDEN[p] * STRAFE_VORBELASTUNG + STRAFKOSTEN_WUNSCH[p]
-
-
-if__name__=="__main__":
- gewinner, kosten_opt = loese(MAX_STUNDEN)
-
-print("="*78)
-print(" CONSTRAINT-TRACE: Warum bekommt", gewinner, "die Vertretungsstunde?")
-print("="*78)
-
-print("\n1. WARUM DIESE LOESUNG?")
- v, w = BEREITS_STUNDEN[gewinner] * STRAFE_VORBELASTUNG, STRAFKOSTEN_WUNSCH[gewinner]
-print(f" {gewinner}: Vorbelastung {v} Strafpunkte + Wunsch {w} Strafpunkte "
-f"= {v + w} Strafpunkte insgesamt (Minimum).")
-
-print("\n2. WARUM NICHT DIE NAHELIEGENDEN ALTERNATIVEN?")
-for p in PERSONAL:
-if p == gewinner:
-continue
-if BEREITS_STUNDEN[p] +1> MAX_STUNDEN:
-print(f" {p}: AUSGESCHLOSSEN - hat bereits {BEREITS_STUNDEN[p]} Stunden, "
-f"eine dritte würde die Obergrenze von {MAX_STUNDEN} Stunden verletzen "
-f"(harte Bedingung, nicht verhandelbar).")
-else:
- differenz = gesamtkosten(p) - kosten_opt
-print(f" {p}: zulässig, aber {differenz:.0f} Strafpunkte teurer als "
-f"{gewinner} ({gesamtkosten(p):.0f} statt {kosten_opt:.0f}).")
-
-print("\n3. WAS WUERDE DIE LOESUNG VERBESSERN?")
- gewinner_gelockert, kosten_gelockert = loese(MAX_STUNDEN +1)
-if kosten_gelockert < kosten_opt:
-print(f" Obergrenze auf {MAX_STUNDEN +1} Stunden gelockert: {gewinner_gelockert} "
-f"uebernimmt jetzt zu {kosten_gelockert:.0f} Strafpunkten "
-f"({kosten_opt - kosten_gelockert:.0f} weniger als heute).")
-print(" -> Diese Bedingung hat tatsaechlich einen Preis.")
-else:
-print(f" Obergrenze auf {MAX_STUNDEN +1} Stunden gelockert: Ergebnis bleibt bei "
-f"{gewinner} mit {kosten_gelockert:.0f} Strafpunkten (unveraendert).")
- blockiert =", ".join(p for p in PERSONAL
-if BEREITS_STUNDEN[p] +1> MAX_STUNDEN and p != gewinner)
-print(f" -> Die Obergrenze blockiert zwar {blockiert}, kostet aber gerade NICHTS:")
-print(" Selbst ohne sie waere die Loesung dieselbe.")
-print(" Blockierend und kostenrelevant sind zwei verschiedene Dinge.")
-print("="*78)
+
#!/usr/bin/env python3
+
+# Erklaerbarkeit.py
+"""
+Kapitel Praxisfallen: Ein Constraint-Trace, der die drei Fragen der Faustregel fuer
+Erklaerbarkeit (Abschnitt 'Die fuenf typischen Praxisfallen') fuer eine
+einzelne Zuweisung beantwortet:
+Warum diese Loesung? Warum nicht die Alternative? Was wuerde sie verbessern?
+"""
+
+from ortools.sat.python import cp_model
+
+PERSONAL = ["Anna", "Ben", "Clara"]
+BEREITS_STUNDEN = {"Anna": 1, "Ben": 2, "Clara": 0} # diese Woche schon geleistet
+MAX_STUNDEN =2# gesetzliche Obergrenze
+STRAFE_VORBELASTUNG =50# pro bereits geleisteter Stunde
+STRAFKOSTEN_WUNSCH = {"Anna": 0, "Ben": 30, "Clara": 100}
+
+
+def loese(max_stunden: int):
+ modell = cp_model.CpModel()
+ x = {p: modell.NewBoolVar(f"x_{p}") for p in PERSONAL}
+ modell.AddExactlyOne(x.values())
+
+ kosten = []
+for p in PERSONAL:
+if BEREITS_STUNDEN[p] +1> max_stunden:
+ modell.Add(x[p] ==0) # hartes Ausschlusskriterium
+ strafe = BEREITS_STUNDEN[p] * STRAFE_VORBELASTUNG + STRAFKOSTEN_WUNSCH[p]
+ kosten.append(strafe * x[p])
+
+ modell.Minimize(sum(kosten))
+ loeser = cp_model.CpSolver()
+ status = loeser.Solve(modell)
+if status notin (cp_model.OPTIMAL, cp_model.FEASIBLE):
+returnNone
+ gewinner =next(p for p in PERSONAL if loeser.Value(x[p]))
+return gewinner, loeser.ObjectiveValue()
+
+
+def gesamtkosten(p: str) ->int:
+return BEREITS_STUNDEN[p] * STRAFE_VORBELASTUNG + STRAFKOSTEN_WUNSCH[p]
+
+
+if__name__=="__main__":
+ gewinner, kosten_opt = loese(MAX_STUNDEN)
+
+print("="*78)
+print(" CONSTRAINT-TRACE: Warum bekommt", gewinner, "die Vertretungsstunde?")
+print("="*78)
+
+print("\n1. WARUM DIESE LOESUNG?")
+ v, w = BEREITS_STUNDEN[gewinner] * STRAFE_VORBELASTUNG, STRAFKOSTEN_WUNSCH[gewinner]
+print(f" {gewinner}: Vorbelastung {v} Strafpunkte + Wunsch {w} Strafpunkte "
+f"= {v + w} Strafpunkte insgesamt (Minimum).")
+
+print("\n2. WARUM NICHT DIE NAHELIEGENDEN ALTERNATIVEN?")
+for p in PERSONAL:
+if p == gewinner:
+continue
+if BEREITS_STUNDEN[p] +1> MAX_STUNDEN:
+print(f" {p}: AUSGESCHLOSSEN - hat bereits {BEREITS_STUNDEN[p]} Stunden, "
+f"eine dritte würde die Obergrenze von {MAX_STUNDEN} Stunden verletzen "
+f"(harte Bedingung, nicht verhandelbar).")
+else:
+ differenz = gesamtkosten(p) - kosten_opt
+print(f" {p}: zulässig, aber {differenz:.0f} Strafpunkte teurer als "
+f"{gewinner} ({gesamtkosten(p):.0f} statt {kosten_opt:.0f}).")
+
+print("\n3. WAS WUERDE DIE LOESUNG VERBESSERN?")
+ gewinner_gelockert, kosten_gelockert = loese(MAX_STUNDEN +1)
+if kosten_gelockert < kosten_opt:
+print(f" Obergrenze auf {MAX_STUNDEN +1} Stunden gelockert: {gewinner_gelockert} "
+f"uebernimmt jetzt zu {kosten_gelockert:.0f} Strafpunkten "
+f"({kosten_opt - kosten_gelockert:.0f} weniger als heute).")
+print(" -> Diese Bedingung hat tatsaechlich einen Preis.")
+else:
+print(f" Obergrenze auf {MAX_STUNDEN +1} Stunden gelockert: Ergebnis bleibt bei "
+f"{gewinner} mit {kosten_gelockert:.0f} Strafpunkten (unveraendert).")
+ blockiert =", ".join(p for p in PERSONAL
+if BEREITS_STUNDEN[p] +1> MAX_STUNDEN and p != gewinner)
+print(f" -> Die Obergrenze blockiert zwar {blockiert}, kostet aber gerade NICHTS:")
+print(" Selbst ohne sie waere die Loesung dieselbe.")
+print(" Blockierend und kostenrelevant sind zwei verschiedene Dinge.")
+print("="*78)
Erwartete Ausgabe:
==============================================================================
CONSTRAINT-TRACE: Warum bekommt Anna die Vertretungsstunde?
@@ -24831,510 +25199,510 @@ von allen Fehlversuchen genau einen: den erfolgreichsten.
⚠️ Eine Besonderheit beim Import
or_kern.py importiert von sich aus keine Solverbibliothek. Die Statusübersetzer laden ihre Bibliothek erst beim Aufruf. Das ist kein Stilmittel, sondern notwendig: ortools und highspy vertragen sich nicht im selben Prozess (Abschnitt 3.5). Ein Modul, das beide importierte, wäre mit keinem von beiden benutzbar.
-
#!/usr/bin/env python3
-
-# or_kern.py
-"""
-Kapitel Praxisfallen: Der gemeinsame Unterbau fuer produktionsreife OR-Modelle.
-
-Alle Beispiele dieses Buchs loesen dieselben vier Aufgaben immer wieder:
-Daten einlesen und pruefen, ein Modell bauen, den Solverstatus auswerten, die
-Loesung gegen die Wirklichkeit kontrollieren. Dieses Modul zieht diese vier
-Aufgaben aus den Einzelprogrammen heraus.
-
- Rohdaten (Excel/CSV)
- -> Domaenenmodell (Pydantic, geprueft)
- -> Modellbauer (solverabhaengig)
- -> Loesung (DTO, solverunabhaengig)
- -> Abnahmepruefung
-
-Der Sinn dieser Trennung: Die Domaenenschicht weiss nichts von Solvern, die
-Loesungsschicht weiss nichts vom Modellaufbau. Wer den Solver wechselt, tauscht
-genau EINEN Baustein aus - der Rest bleibt.
-
-WICHTIG ZUM IMPORT: Dieses Modul importiert von sich aus KEINE Solver-
-bibliothek. Der Grund steht im Kapitel Oekosystem: ortools und highspy vertragen sich
-nicht im selben Prozess. Die Statusuebersetzer laden ihre Bibliothek erst,
-wenn sie aufgerufen werden - so bleibt or_kern.py mit jedem Solver benutzbar.
-
-Benoetigt: pydantic (v2), numpy; optional pandas und openpyxl fuer Excel.
-"""
-
-from __future__ import annotations
-
-from enum import Enum
-from typing import Annotated, Any, Sequence
-
-import numpy as np
-from pydantic import BaseModel, Field, model_validator
-
-# Wiederverwendbare Feldtypen. Sie tragen die Pruefung im Typ, nicht im Code -
-# damit gilt sie ueberall, wo der Typ verwendet wird.
-PositiveZahl = Annotated[float, Field(gt=0)]
-NichtNegativ = Annotated[float, Field(ge=0)]
-
-
-# --- 1. Solverstatus: eine Sprache fuer fuenf Bibliotheken -------------------
-
-class SolverStatus(str, Enum):
-"""Was ein Solverlauf ergeben hat - unabhaengig davon, wer gerechnet hat.
-
- Die fuenf im Buch verwendeten Bibliotheken benennen dasselbe Ergebnis
- unterschiedlich: 'Optimal', 'OPTIMAL', 2, 'optimal'. Wer darauf direkt
- prueft, bindet seinen Auswertungscode an eine Bibliothek.
- """
- OPTIMAL ="optimal"# beweisbar bestmoeglich
- ZULAESSIG ="zulaessig"# brauchbare Loesung, Beweis fehlt
- UNZULAESSIG ="unzulaessig"# es gibt keine Loesung (Aussage ueber das Modell)
- UNBESCHRAENKT ="unbeschraenkt"# Ziel waechst ins Unendliche
- ZEITLIMIT ="zeitlimit"# abgebrochen, nichts Brauchbares gefunden
- FEHLERHAFT ="fehlerhaft"# das Modell ist kein gueltiges Modell
- UNBEKANNT ="unbekannt"# alles Uebrige
-
-@property
-def brauchbar(self) ->bool:
-"""Habe ich etwas in der Hand, das ich ausfuehren kann?"""
-returnselfin (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG)
-
-@property
-def modellfehler(self) ->bool:
-"""Liegt die Ursache im Modell (und nicht in der Rechenzeit)?"""
-returnselfin (SolverStatus.UNZULAESSIG, SolverStatus.UNBESCHRAENKT,
- SolverStatus.FEHLERHAFT)
-
-
-def status_von_pywraplp(rohstatus: int) -> SolverStatus:
-"""OR-Tools linear_solver (GLOP, SCIP, CBC)."""
-from ortools.linear_solver import pywraplp
- zuordnung = {
- pywraplp.Solver.OPTIMAL: SolverStatus.OPTIMAL,
- pywraplp.Solver.FEASIBLE: SolverStatus.ZULAESSIG,
- pywraplp.Solver.INFEASIBLE: SolverStatus.UNZULAESSIG,
- pywraplp.Solver.UNBOUNDED: SolverStatus.UNBESCHRAENKT,
- pywraplp.Solver.ABNORMAL: SolverStatus.FEHLERHAFT,
- pywraplp.Solver.NOT_SOLVED: SolverStatus.ZEITLIMIT,
- }
-return zuordnung.get(rohstatus, SolverStatus.UNBEKANNT)
-
-
-def status_von_cpsat(rohstatus: int) -> SolverStatus:
-"""OR-Tools CP-SAT (siehe Kapitel CP-SAT, alle fuenf Faelle)."""
-from ortools.sat.python import cp_model
- zuordnung = {
- cp_model.OPTIMAL: SolverStatus.OPTIMAL,
- cp_model.FEASIBLE: SolverStatus.ZULAESSIG,
- cp_model.INFEASIBLE: SolverStatus.UNZULAESSIG,
- cp_model.MODEL_INVALID: SolverStatus.FEHLERHAFT,
- cp_model.UNKNOWN: SolverStatus.ZEITLIMIT,
- }
-return zuordnung.get(rohstatus, SolverStatus.UNBEKANNT)
-
-
-def status_von_highs(statustext: str) -> SolverStatus:
-"""HiGHS ueber highspy - hier kommt der Status als Text."""
- zuordnung = {
-"Optimal": SolverStatus.OPTIMAL,
-"Time limit reached": SolverStatus.ZULAESSIG, # meist mit Loesung
-"Solution limit reached": SolverStatus.ZULAESSIG,
-"Infeasible": SolverStatus.UNZULAESSIG,
-"Unbounded": SolverStatus.UNBESCHRAENKT,
-"Primal infeasible or unbounded": SolverStatus.UNZULAESSIG,
-"Model error": SolverStatus.FEHLERHAFT,
- }
-return zuordnung.get(statustext, SolverStatus.UNBEKANNT)
-
-
-def status_von_scipy(ergebnis: Any) -> SolverStatus:
-"""scipy.optimize.linprog und milp liefern ein Ergebnisobjekt mit .status."""
- zuordnung = {
-0: SolverStatus.OPTIMAL,
-1: SolverStatus.ZEITLIMIT, # Iterations-/Zeitgrenze
-2: SolverStatus.UNZULAESSIG,
-3: SolverStatus.UNBESCHRAENKT,
-4: SolverStatus.FEHLERHAFT, # numerische Schwierigkeiten
- }
-return zuordnung.get(int(ergebnis.status), SolverStatus.UNBEKANNT)
-
-
-def status_von_cvxpy(statustext: str) -> SolverStatus:
-"""CVXPY - Statuszeichenketten wie 'optimal' oder 'infeasible'."""
- zuordnung = {
-"optimal": SolverStatus.OPTIMAL,
-"optimal_inaccurate": SolverStatus.ZULAESSIG,
-"infeasible": SolverStatus.UNZULAESSIG,
-"infeasible_inaccurate": SolverStatus.UNZULAESSIG,
-"unbounded": SolverStatus.UNBESCHRAENKT,
-"unbounded_inaccurate": SolverStatus.UNBESCHRAENKT,
- }
-return zuordnung.get(statustext, SolverStatus.UNBEKANNT)
-
-
-# --- 2. Die Loesung als solverunabhaengiges Datenobjekt ----------------------
-
-class Loesung(BaseModel):
-"""Das Ergebnis eines Solverlaufs, so wie es weiterverarbeitet wird.
-
- Bewusst OHNE Referenz auf Solver, Modell oder Variablen: Ein Bericht, ein
- Test oder eine Weiterverarbeitung soll nicht wissen muessen, womit
- gerechnet wurde.
- """
- status: SolverStatus
- werte: dict[str, float] = Field(default_factory=dict)
- zielwert: float|None=None
- schranke: float|None=None
- laufzeit: float|None=None
-# Schattenpreise je Nebenbedingung, sofern der Solver sie liefert
-# (nur bei kontinuierlichen Problemen, siehe Kapitel LP). Auch das ist
-# ein solverunabhaengiger Begriff und gehoert deshalb hierher.
- schattenpreise: dict[str, float] = Field(default_factory=dict)
-
-@property
-def gap(self) ->float|None:
-"""Relativer Abstand zwischen gefundener Loesung und bewiesener Schranke.
-
- Das ist die Zahl fuer den Bericht: keine Schaetzung, sondern eine
- Zusage - schlechter als das kann die Loesung nicht sein.
- """
-ifself.zielwert isNoneorself.schranke isNone:
-returnNone
- nenner =max(abs(self.zielwert), 1e-9)
-returnabs(self.zielwert -self.schranke) / nenner
-
-def als_bericht(self) ->str:
-"""Eine Zeile fuers Betriebsprotokoll - siehe Betriebsueberwachung.py."""
- teile = [f"Status: {self.status.value}"]
-ifself.zielwert isnotNone:
- teile.append(f"Zielwert: {self.zielwert:,.2f}")
-ifself.gap isnotNone:
- teile.append(f"Gap: {self.gap:.2%}")
-ifself.laufzeit isnotNone:
- teile.append(f"Zeit: {self.laufzeit:.2f}s")
-return" | ".join(teile)
-
-
-# --- 3. Domaenenmodell: die Fakten, bevor ein Solver sie sieht ---------------
-
-class Produkt(BaseModel):
-"""Ein Produkt mit Deckungsbeitrag und Ressourcenverbrauch."""
- name: str= Field(min_length=1)
- deckungsbeitrag: float
- verbrauch: dict[str, NichtNegativ]
-
- model_config = {"frozen": True} # Stammdaten aendern sich nicht im Lauf
-
-
-class Produktionsproblem(BaseModel):
-"""Produktionsprogrammplanung - das Modell aus Kapitel Einfuehrung.
-
- Die Pruefungen stehen hier und nicht im Solvercode. Damit gelten sie
- unabhaengig davon, mit welcher Bibliothek spaeter gerechnet wird - und sie
- schlagen beim EINLESEN zu, wo der Fehler noch zuzuordnen ist.
- """
- produkte: list[Produkt] = Field(min_length=1)
- kapazitaeten: dict[str, PositiveZahl]
-
-@model_validator(mode="after")
-def pruefe_ressourcen(self) ->"Produktionsproblem":
- namen = [p.name for p inself.produkte]
-iflen(set(namen)) !=len(namen):
- doppelt =sorted({n for n in namen if namen.count(n) >1})
-raiseValueError(f"Produktnamen kommen mehrfach vor: {doppelt}")
-
-for produkt inself.produkte:
- unbekannt =set(produkt.verbrauch) -set(self.kapazitaeten)
-if unbekannt:
-raiseValueError(
-f"Produkt '{produkt.name}' verbraucht Ressourcen ohne "
-f"Kapazitaetsangabe: {sorted(unbekannt)}")
-returnself
-
-@property
-def ressourcen(self) ->list[str]:
-"""Feste Reihenfolge - siehe die Spaltenfalle in Kapitel Finanzdaten."""
-returnsorted(self.kapazitaeten)
-
-def verbrauchsmatrix(self) -> np.ndarray:
-"""Zeilen = Ressourcen, Spalten = Produkte (Matrixform, Kapitel Fundament)."""
-return np.array([[p.verbrauch.get(r, 0.0) for p inself.produkte]
-for r inself.ressourcen])
-
-def kapazitaetsvektor(self) -> np.ndarray:
-return np.array([self.kapazitaeten[r] for r inself.ressourcen])
-
-def deckungsbeitragsvektor(self) -> np.ndarray:
-return np.array([p.deckungsbeitrag for p inself.produkte])
-
-
-# --- 4. Abnahmepruefung: Loesung gegen Anforderung, ohne Solver --------------
-
-def pruefe_loesung(problem: Produktionsproblem, loesung: Loesung,
- ganzzahlig: Sequence[str] = (),
- toleranz: float=1e-6) ->list[str]:
-"""Prueft eine Loesung gegen die Anforderungen - OHNE den Solver zu fragen.
-
- Diese Funktion darf ausdruecklich KEIN Solverobjekt und keine
- Modellvariable benutzen. Eine Pruefung aus denselben Bausteinen wie das
- Modell prueft das Modell gegen sich selbst und findet nichts (siehe die
- Denkfehler in den Kapiteln Graphen und MILP).
-
- Liefert eine Liste von Beanstandungen; leer heisst bestanden.
- """
- beanstandungen: list[str] = []
-
-ifnot loesung.status.brauchbar:
-return [f"Kein verwertbares Ergebnis (Status: {loesung.status.value})"]
-
- fehlend = [p.name for p in problem.produkte if p.name notin loesung.werte]
-if fehlend:
-return [f"Loesung enthaelt keine Werte fuer: {fehlend}"]
-
- mengen = np.array([loesung.werte[p.name] for p in problem.produkte])
-
-if (mengen <-toleranz).any():
- negativ = [p.name for p, m inzip(problem.produkte, mengen) if m <-toleranz]
- beanstandungen.append(f"negative Mengen bei: {negativ}")
-
- verbrauch = problem.verbrauchsmatrix() @ mengen
-for ressource, ist, grenze inzip(problem.ressourcen, verbrauch,
- problem.kapazitaetsvektor()):
-if ist > grenze + toleranz:
- beanstandungen.append(
-f"{ressource}: Verbrauch {ist:,.3f} ueber Kapazitaet {grenze:,.3f}")
-
-for name in ganzzahlig:
- wert = loesung.werte[name]
-ifabs(wert -round(wert)) > toleranz:
- beanstandungen.append(f"'{name}' = {wert!r} ist nicht ganzzahlig")
-
-if loesung.zielwert isnotNone:
- nachgerechnet =float(problem.deckungsbeitragsvektor() @ mengen)
-ifabs(nachgerechnet - loesung.zielwert) > toleranz *max(1.0, abs(nachgerechnet)):
- beanstandungen.append(
-f"Zielwert {loesung.zielwert:,.4f} passt nicht zu den Mengen "
-f"(nachgerechnet {nachgerechnet:,.4f})")
-
-return beanstandungen
-
-
-# --- 5. Ein Modellbauer je Solver - austauschbar ----------------------------
-
-def loese_mit_glop(problem: Produktionsproblem) -> Loesung:
-"""Modellbauer fuer OR-Tools GLOP (kontinuierlich)."""
-import time
-from ortools.linear_solver import pywraplp
-
- solver = pywraplp.Solver.CreateSolver("GLOP")
- menge = {p.name: solver.NumVar(0, solver.infinity(), p.name)
-for p in problem.produkte}
-# Die Nebenbedingung wird gemerkt - ohne die Referenz gibt es spaeter
-# keinen Schattenpreis (siehe Kapitel LP).
- bedingung = {}
-for ressource in problem.ressourcen:
- bedingung[ressource] = solver.Add(
-sum(menge[p.name] * p.verbrauch.get(ressource, 0.0)
-for p in problem.produkte)
-<= problem.kapazitaeten[ressource], name=ressource)
- solver.Maximize(sum(menge[p.name] * p.deckungsbeitrag for p in problem.produkte))
-
- t0 = time.perf_counter()
- rohstatus = solver.Solve()
- laufzeit = time.perf_counter() - t0
-
- status = status_von_pywraplp(rohstatus)
-ifnot status.brauchbar:
-return Loesung(status=status, laufzeit=laufzeit)
-return Loesung(status=status,
- werte={name: v.solution_value() for name, v in menge.items()},
- zielwert=solver.Objective().Value(),
- schranke=solver.Objective().Value(),
- schattenpreise={r: b.dual_value() for r, b in bedingung.items()},
- laufzeit=laufzeit)
-
-
-def loese_mit_scipy(problem: Produktionsproblem) -> Loesung:
-"""Derselbe Fall mit scipy.optimize.linprog - anderer Solver, gleiche Loesung.
-
- Beachten Sie, wie wenig hier steht: Die Daten kommen fertig geprueft aus
- dem Domaenenmodell, die Auswertung geht ans gemeinsame Loesung-Objekt.
- Genau das ist der Ertrag der Trennung.
- """
-import time
-from scipy.optimize import linprog
-
- t0 = time.perf_counter()
- ergebnis = linprog(c=-problem.deckungsbeitragsvektor(), # linprog minimiert
- A_ub=problem.verbrauchsmatrix(),
- b_ub=problem.kapazitaetsvektor(),
- bounds=(0, None), method="highs")
- laufzeit = time.perf_counter() - t0
-
- status = status_von_scipy(ergebnis)
-ifnot status.brauchbar:
-return Loesung(status=status, laufzeit=laufzeit)
-# linprog rechnet minimierend mit negierten Kosten - die Dualwerte
-# muessen entsprechend zurueckgedreht werden.
-return Loesung(status=status,
- werte={p.name: float(x)
-for p, x inzip(problem.produkte, ergebnis.x)},
- zielwert=float(-ergebnis.fun),
- schranke=float(-ergebnis.fun),
- schattenpreise={r: float(-m) for r, m in
-zip(problem.ressourcen,
- ergebnis.ineqlin.marginals)},
- laufzeit=laufzeit)
-
-
-# --- 6. Ein- und Ausgabe: Tabellen sind die Wirklichkeit ---------------------
-#
-# In den meisten Betrieben liegen die Zahlen in einer Tabellenkalkulation
-# (Kapitel Einfuehrung). Diese beiden Funktionen sind die einzige Stelle im
-# Modul, die das weiss - alles andere arbeitet mit dem Domaenenmodell.
-#
-# pandas und openpyxl werden bewusst erst hier importiert: Wer or_kern nur
-# fuer Statusauswertung und Pruefung benutzt, soll sie nicht installieren
-# muessen.
-
-STAMMSPALTEN = ("Produkt", "Deckungsbeitrag")
-
-
-def lade_produktionsproblem(pfad: str, blatt_produkte: str="Produkte",
- blatt_kapazitaeten: str="Kapazitaeten"
- ) -> Produktionsproblem:
-"""Liest eine Excel-Mappe und liefert ein geprueftes Domaenenmodell.
-
- Alle Spalten ausser 'Produkt' und 'Deckungsbeitrag' gelten als
- Verbrauchsspalten - eine neue Ressource ist damit eine neue SPALTE in der
- Mappe und erfordert keine Codeaenderung.
-
- Die eigentliche Pruefung passiert nicht hier, sondern im Konstruktor von
- Produktionsproblem. Diese Funktion uebersetzt nur Tabelle in Objekt; was
- ein gueltiges Problem ist, steht an genau einer Stelle.
- """
-import pandas as pd
-
- produkte_tabelle = pd.read_excel(pfad, sheet_name=blatt_produkte)
- kapazitaeten_tabelle = pd.read_excel(pfad, sheet_name=blatt_kapazitaeten)
-
- fehlende = [s for s in STAMMSPALTEN if s notin produkte_tabelle.columns]
-if fehlende:
-raiseValueError(f"Blatt '{blatt_produkte}': Spalten fehlen: {fehlende}")
-if produkte_tabelle.isna().any().any():
- zeilen = produkte_tabelle[produkte_tabelle.isna().any(axis=1)].index.tolist()
-raiseValueError(f"Blatt '{blatt_produkte}': leere Zellen in Zeile(n) {zeilen}")
-
- ressourcen = [s for s in produkte_tabelle.columns if s notin STAMMSPALTEN]
- produkte = [
- Produkt(name=str(zeile.Produkt),
- deckungsbeitrag=float(zeile.Deckungsbeitrag),
- verbrauch={r: float(getattr(zeile, r)) for r in ressourcen})
-for zeile in produkte_tabelle.itertuples()
- ]
- kapazitaeten = {str(z.Ressource): float(z.Verfuegbar)
-for z in kapazitaeten_tabelle.itertuples()}
-
-return Produktionsproblem(produkte=produkte, kapazitaeten=kapazitaeten)
-
-
-def schreibe_ergebnis(pfad: str, problem: Produktionsproblem,
- loesung: Loesung) ->None:
-"""Schreibt Plan und Kennzahlen als zwei Blaetter zurueck nach Excel.
-
- Die Fachabteilung bekommt das Ergebnis im Format, das sie ohnehin benutzt.
- Akzeptanz entscheidet ueber Projekterfolg (Kapitel Praxisfallen).
- """
-import pandas as pd
-
-ifnot loesung.status.brauchbar:
-raiseValueError(f"Nichts zu schreiben - Status: {loesung.status.value}")
-
- mengen = np.array([loesung.werte[p.name] for p in problem.produkte])
- plan = pd.DataFrame({
-"Produkt": [p.name for p in problem.produkte],
-"Menge": mengen,
-"Deckungsbeitrag": mengen * problem.deckungsbeitragsvektor(),
- })
-
- verbrauch = problem.verbrauchsmatrix() @ mengen
- kennzahlen: dict[str, float|str] = {
-"Status": loesung.status.value,
-"Gesamtdeckungsbeitrag": loesung.zielwert or0.0,
- }
-for ressource, ist, grenze inzip(problem.ressourcen, verbrauch,
- problem.kapazitaetsvektor()):
- kennzahlen[f"{ressource}: Verbrauch"] =float(ist)
- kennzahlen[f"{ressource}: Auslastung %"] =float(100.0* ist / grenze)
-if ressource in loesung.schattenpreise:
- kennzahlen[f"{ressource}: Schattenpreis"] =\
- loesung.schattenpreise[ressource]
-
- kennzahlen_tabelle = pd.DataFrame({
-"Kennzahl": list(kennzahlen),
-"Wert": [round(w, 2) ifisinstance(w, float) else w
-for w in kennzahlen.values()],
- })
-
-with pd.ExcelWriter(pfad, engine="openpyxl") as mappe:
- plan.round(2).to_excel(mappe, sheet_name="Plan", index=False)
- kennzahlen_tabelle.to_excel(mappe, sheet_name="Kennzahlen", index=False)
-
-
-if__name__=="__main__":
-# Die Schreinerei aus Kapitel Einfuehrung - jetzt als Domaenenmodell.
- schreinerei = Produktionsproblem(
- produkte=[
- Produkt(name="Tisch", deckungsbeitrag=240.0,
- verbrauch={"Montagestunden": 3.0, "Plattenmaterial": 6.0}),
- Produkt(name="Stuhl", deckungsbeitrag=60.0,
- verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0}),
- ],
- kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0},
- )
-
-print("="*78)
-print(" DERSELBE FALL, ZWEI SOLVER, EIN AUSWERTUNGSCODE")
-print("="*78)
-
-for name, bauer in [("OR-Tools GLOP", loese_mit_glop),
- ("scipy / HiGHS", loese_mit_scipy)]:
- loesung = bauer(schreinerei)
- beanstandungen = pruefe_loesung(schreinerei, loesung)
-print(f"\n{name}")
-print(f" {loesung.als_bericht()}")
-for produkt, menge in loesung.werte.items():
-print(f" {produkt:<10}{menge:8.2f}")
-print(f" Abnahmepruefung: "
-f"{'bestanden'ifnot beanstandungen else beanstandungen}")
-
-print("\n"+"="*78)
-print(" WAS DIE PRUEFUNGEN ABFANGEN")
-print("="*78)
-
- faelle = [
- ("Kapazitaet 0", dict(
- produkte=schreinerei.produkte,
- kapazitaeten={"Montagestunden": 0.0, "Plattenmaterial": 240.0})),
- ("Ressource ohne Kapazitaet", dict(
- produkte=[Produkt(name="Regal", deckungsbeitrag=130.0,
- verbrauch={"Lackieren": 2.0})],
- kapazitaeten={"Montagestunden": 150.0})),
- ("doppelter Produktname", dict(
- produkte=[schreinerei.produkte[0], schreinerei.produkte[0]],
- kapazitaeten=schreinerei.kapazitaeten)),
- ]
-for beschreibung, daten in faelle:
-try:
- Produktionsproblem(**daten)
-print(f" {beschreibung:<28} NICHT erkannt (!)")
-exceptExceptionas fehler:
- meldung =str(fehler).splitlines()
- kern =next((z.strip() for z in meldung
-if"Value error"in z or"greater than"in z), meldung[-1])
-print(f" {beschreibung:<28} abgefangen: {kern[:44]}")
-
-print("\nAlle drei scheitern beim EINLESEN - nicht erst beim Loesen und")
-print("schon gar nicht erst im Bericht. Das ist der ganze Zweck der")
-print("Domaenenschicht.")
-print("="*78)
+
#!/usr/bin/env python3
+
+# or_kern.py
+"""
+Kapitel Praxisfallen: Der gemeinsame Unterbau fuer produktionsreife OR-Modelle.
+
+Alle Beispiele dieses Buchs loesen dieselben vier Aufgaben immer wieder:
+Daten einlesen und pruefen, ein Modell bauen, den Solverstatus auswerten, die
+Loesung gegen die Wirklichkeit kontrollieren. Dieses Modul zieht diese vier
+Aufgaben aus den Einzelprogrammen heraus.
+
+ Rohdaten (Excel/CSV)
+ -> Domaenenmodell (Pydantic, geprueft)
+ -> Modellbauer (solverabhaengig)
+ -> Loesung (DTO, solverunabhaengig)
+ -> Abnahmepruefung
+
+Der Sinn dieser Trennung: Die Domaenenschicht weiss nichts von Solvern, die
+Loesungsschicht weiss nichts vom Modellaufbau. Wer den Solver wechselt, tauscht
+genau EINEN Baustein aus - der Rest bleibt.
+
+WICHTIG ZUM IMPORT: Dieses Modul importiert von sich aus KEINE Solver-
+bibliothek. Der Grund steht im Kapitel Oekosystem: ortools und highspy vertragen sich
+nicht im selben Prozess. Die Statusuebersetzer laden ihre Bibliothek erst,
+wenn sie aufgerufen werden - so bleibt or_kern.py mit jedem Solver benutzbar.
+
+Benoetigt: pydantic (v2), numpy; optional pandas und openpyxl fuer Excel.
+"""
+
+from __future__ import annotations
+
+from enum import Enum
+from typing import Annotated, Any, Sequence
+
+import numpy as np
+from pydantic import BaseModel, Field, model_validator
+
+# Wiederverwendbare Feldtypen. Sie tragen die Pruefung im Typ, nicht im Code -
+# damit gilt sie ueberall, wo der Typ verwendet wird.
+PositiveZahl = Annotated[float, Field(gt=0)]
+NichtNegativ = Annotated[float, Field(ge=0)]
+
+
+# --- 1. Solverstatus: eine Sprache fuer fuenf Bibliotheken -------------------
+
+class SolverStatus(str, Enum):
+"""Was ein Solverlauf ergeben hat - unabhaengig davon, wer gerechnet hat.
+
+ Die fuenf im Buch verwendeten Bibliotheken benennen dasselbe Ergebnis
+ unterschiedlich: 'Optimal', 'OPTIMAL', 2, 'optimal'. Wer darauf direkt
+ prueft, bindet seinen Auswertungscode an eine Bibliothek.
+ """
+ OPTIMAL ="optimal"# beweisbar bestmoeglich
+ ZULAESSIG ="zulaessig"# brauchbare Loesung, Beweis fehlt
+ UNZULAESSIG ="unzulaessig"# es gibt keine Loesung (Aussage ueber das Modell)
+ UNBESCHRAENKT ="unbeschraenkt"# Ziel waechst ins Unendliche
+ ZEITLIMIT ="zeitlimit"# abgebrochen, nichts Brauchbares gefunden
+ FEHLERHAFT ="fehlerhaft"# das Modell ist kein gueltiges Modell
+ UNBEKANNT ="unbekannt"# alles Uebrige
+
+@property
+def brauchbar(self) ->bool:
+"""Habe ich etwas in der Hand, das ich ausfuehren kann?"""
+returnselfin (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG)
+
+@property
+def modellfehler(self) ->bool:
+"""Liegt die Ursache im Modell (und nicht in der Rechenzeit)?"""
+returnselfin (SolverStatus.UNZULAESSIG, SolverStatus.UNBESCHRAENKT,
+ SolverStatus.FEHLERHAFT)
+
+
+def status_von_pywraplp(rohstatus: int) -> SolverStatus:
+"""OR-Tools linear_solver (GLOP, SCIP, CBC)."""
+from ortools.linear_solver import pywraplp
+ zuordnung = {
+ pywraplp.Solver.OPTIMAL: SolverStatus.OPTIMAL,
+ pywraplp.Solver.FEASIBLE: SolverStatus.ZULAESSIG,
+ pywraplp.Solver.INFEASIBLE: SolverStatus.UNZULAESSIG,
+ pywraplp.Solver.UNBOUNDED: SolverStatus.UNBESCHRAENKT,
+ pywraplp.Solver.ABNORMAL: SolverStatus.FEHLERHAFT,
+ pywraplp.Solver.NOT_SOLVED: SolverStatus.ZEITLIMIT,
+ }
+return zuordnung.get(rohstatus, SolverStatus.UNBEKANNT)
+
+
+def status_von_cpsat(rohstatus: int) -> SolverStatus:
+"""OR-Tools CP-SAT (siehe Kapitel CP-SAT, alle fuenf Faelle)."""
+from ortools.sat.python import cp_model
+ zuordnung = {
+ cp_model.OPTIMAL: SolverStatus.OPTIMAL,
+ cp_model.FEASIBLE: SolverStatus.ZULAESSIG,
+ cp_model.INFEASIBLE: SolverStatus.UNZULAESSIG,
+ cp_model.MODEL_INVALID: SolverStatus.FEHLERHAFT,
+ cp_model.UNKNOWN: SolverStatus.ZEITLIMIT,
+ }
+return zuordnung.get(rohstatus, SolverStatus.UNBEKANNT)
+
+
+def status_von_highs(statustext: str) -> SolverStatus:
+"""HiGHS ueber highspy - hier kommt der Status als Text."""
+ zuordnung = {
+"Optimal": SolverStatus.OPTIMAL,
+"Time limit reached": SolverStatus.ZULAESSIG, # meist mit Loesung
+"Solution limit reached": SolverStatus.ZULAESSIG,
+"Infeasible": SolverStatus.UNZULAESSIG,
+"Unbounded": SolverStatus.UNBESCHRAENKT,
+"Primal infeasible or unbounded": SolverStatus.UNZULAESSIG,
+"Model error": SolverStatus.FEHLERHAFT,
+ }
+return zuordnung.get(statustext, SolverStatus.UNBEKANNT)
+
+
+def status_von_scipy(ergebnis: Any) -> SolverStatus:
+"""scipy.optimize.linprog und milp liefern ein Ergebnisobjekt mit .status."""
+ zuordnung = {
+0: SolverStatus.OPTIMAL,
+1: SolverStatus.ZEITLIMIT, # Iterations-/Zeitgrenze
+2: SolverStatus.UNZULAESSIG,
+3: SolverStatus.UNBESCHRAENKT,
+4: SolverStatus.FEHLERHAFT, # numerische Schwierigkeiten
+ }
+return zuordnung.get(int(ergebnis.status), SolverStatus.UNBEKANNT)
+
+
+def status_von_cvxpy(statustext: str) -> SolverStatus:
+"""CVXPY - Statuszeichenketten wie 'optimal' oder 'infeasible'."""
+ zuordnung = {
+"optimal": SolverStatus.OPTIMAL,
+"optimal_inaccurate": SolverStatus.ZULAESSIG,
+"infeasible": SolverStatus.UNZULAESSIG,
+"infeasible_inaccurate": SolverStatus.UNZULAESSIG,
+"unbounded": SolverStatus.UNBESCHRAENKT,
+"unbounded_inaccurate": SolverStatus.UNBESCHRAENKT,
+ }
+return zuordnung.get(statustext, SolverStatus.UNBEKANNT)
+
+
+# --- 2. Die Loesung als solverunabhaengiges Datenobjekt ----------------------
+
+class Loesung(BaseModel):
+"""Das Ergebnis eines Solverlaufs, so wie es weiterverarbeitet wird.
+
+ Bewusst OHNE Referenz auf Solver, Modell oder Variablen: Ein Bericht, ein
+ Test oder eine Weiterverarbeitung soll nicht wissen muessen, womit
+ gerechnet wurde.
+ """
+ status: SolverStatus
+ werte: dict[str, float] = Field(default_factory=dict)
+ zielwert: float|None=None
+ schranke: float|None=None
+ laufzeit: float|None=None
+# Schattenpreise je Nebenbedingung, sofern der Solver sie liefert
+# (nur bei kontinuierlichen Problemen, siehe Kapitel LP). Auch das ist
+# ein solverunabhaengiger Begriff und gehoert deshalb hierher.
+ schattenpreise: dict[str, float] = Field(default_factory=dict)
+
+@property
+def gap(self) ->float|None:
+"""Relativer Abstand zwischen gefundener Loesung und bewiesener Schranke.
+
+ Das ist die Zahl fuer den Bericht: keine Schaetzung, sondern eine
+ Zusage - schlechter als das kann die Loesung nicht sein.
+ """
+ifself.zielwert isNoneorself.schranke isNone:
+returnNone
+ nenner =max(abs(self.zielwert), 1e-9)
+returnabs(self.zielwert -self.schranke) / nenner
+
+def als_bericht(self) ->str:
+"""Eine Zeile fuers Betriebsprotokoll - siehe Betriebsueberwachung.py."""
+ teile = [f"Status: {self.status.value}"]
+ifself.zielwert isnotNone:
+ teile.append(f"Zielwert: {self.zielwert:,.2f}")
+ifself.gap isnotNone:
+ teile.append(f"Gap: {self.gap:.2%}")
+ifself.laufzeit isnotNone:
+ teile.append(f"Zeit: {self.laufzeit:.2f}s")
+return" | ".join(teile)
+
+
+# --- 3. Domaenenmodell: die Fakten, bevor ein Solver sie sieht ---------------
+
+class Produkt(BaseModel):
+"""Ein Produkt mit Deckungsbeitrag und Ressourcenverbrauch."""
+ name: str= Field(min_length=1)
+ deckungsbeitrag: float
+ verbrauch: dict[str, NichtNegativ]
+
+ model_config = {"frozen": True} # Stammdaten aendern sich nicht im Lauf
+
+
+class Produktionsproblem(BaseModel):
+"""Produktionsprogrammplanung - das Modell aus Kapitel Einfuehrung.
+
+ Die Pruefungen stehen hier und nicht im Solvercode. Damit gelten sie
+ unabhaengig davon, mit welcher Bibliothek spaeter gerechnet wird - und sie
+ schlagen beim EINLESEN zu, wo der Fehler noch zuzuordnen ist.
+ """
+ produkte: list[Produkt] = Field(min_length=1)
+ kapazitaeten: dict[str, PositiveZahl]
+
+@model_validator(mode="after")
+def pruefe_ressourcen(self) ->"Produktionsproblem":
+ namen = [p.name for p inself.produkte]
+iflen(set(namen)) !=len(namen):
+ doppelt =sorted({n for n in namen if namen.count(n) >1})
+raiseValueError(f"Produktnamen kommen mehrfach vor: {doppelt}")
+
+for produkt inself.produkte:
+ unbekannt =set(produkt.verbrauch) -set(self.kapazitaeten)
+if unbekannt:
+raiseValueError(
+f"Produkt '{produkt.name}' verbraucht Ressourcen ohne "
+f"Kapazitaetsangabe: {sorted(unbekannt)}")
+returnself
+
+@property
+def ressourcen(self) ->list[str]:
+"""Feste Reihenfolge - siehe die Spaltenfalle in Kapitel Finanzdaten."""
+returnsorted(self.kapazitaeten)
+
+def verbrauchsmatrix(self) -> np.ndarray:
+"""Zeilen = Ressourcen, Spalten = Produkte (Matrixform, Kapitel Fundament)."""
+return np.array([[p.verbrauch.get(r, 0.0) for p inself.produkte]
+for r inself.ressourcen])
+
+def kapazitaetsvektor(self) -> np.ndarray:
+return np.array([self.kapazitaeten[r] for r inself.ressourcen])
+
+def deckungsbeitragsvektor(self) -> np.ndarray:
+return np.array([p.deckungsbeitrag for p inself.produkte])
+
+
+# --- 4. Abnahmepruefung: Loesung gegen Anforderung, ohne Solver --------------
+
+def pruefe_loesung(problem: Produktionsproblem, loesung: Loesung,
+ ganzzahlig: Sequence[str] = (),
+ toleranz: float=1e-6) ->list[str]:
+"""Prueft eine Loesung gegen die Anforderungen - OHNE den Solver zu fragen.
+
+ Diese Funktion darf ausdruecklich KEIN Solverobjekt und keine
+ Modellvariable benutzen. Eine Pruefung aus denselben Bausteinen wie das
+ Modell prueft das Modell gegen sich selbst und findet nichts (siehe die
+ Denkfehler in den Kapiteln Graphen und MILP).
+
+ Liefert eine Liste von Beanstandungen; leer heisst bestanden.
+ """
+ beanstandungen: list[str] = []
+
+ifnot loesung.status.brauchbar:
+return [f"Kein verwertbares Ergebnis (Status: {loesung.status.value})"]
+
+ fehlend = [p.name for p in problem.produkte if p.name notin loesung.werte]
+if fehlend:
+return [f"Loesung enthaelt keine Werte fuer: {fehlend}"]
+
+ mengen = np.array([loesung.werte[p.name] for p in problem.produkte])
+
+if (mengen <-toleranz).any():
+ negativ = [p.name for p, m inzip(problem.produkte, mengen) if m <-toleranz]
+ beanstandungen.append(f"negative Mengen bei: {negativ}")
+
+ verbrauch = problem.verbrauchsmatrix() @ mengen
+for ressource, ist, grenze inzip(problem.ressourcen, verbrauch,
+ problem.kapazitaetsvektor()):
+if ist > grenze + toleranz:
+ beanstandungen.append(
+f"{ressource}: Verbrauch {ist:,.3f} ueber Kapazitaet {grenze:,.3f}")
+
+for name in ganzzahlig:
+ wert = loesung.werte[name]
+ifabs(wert -round(wert)) > toleranz:
+ beanstandungen.append(f"'{name}' = {wert!r} ist nicht ganzzahlig")
+
+if loesung.zielwert isnotNone:
+ nachgerechnet =float(problem.deckungsbeitragsvektor() @ mengen)
+ifabs(nachgerechnet - loesung.zielwert) > toleranz *max(1.0, abs(nachgerechnet)):
+ beanstandungen.append(
+f"Zielwert {loesung.zielwert:,.4f} passt nicht zu den Mengen "
+f"(nachgerechnet {nachgerechnet:,.4f})")
+
+return beanstandungen
+
+
+# --- 5. Ein Modellbauer je Solver - austauschbar ----------------------------
+
+def loese_mit_glop(problem: Produktionsproblem) -> Loesung:
+"""Modellbauer fuer OR-Tools GLOP (kontinuierlich)."""
+import time
+from ortools.linear_solver import pywraplp
+
+ solver = pywraplp.Solver.CreateSolver("GLOP")
+ menge = {p.name: solver.NumVar(0, solver.infinity(), p.name)
+for p in problem.produkte}
+# Die Nebenbedingung wird gemerkt - ohne die Referenz gibt es spaeter
+# keinen Schattenpreis (siehe Kapitel LP).
+ bedingung = {}
+for ressource in problem.ressourcen:
+ bedingung[ressource] = solver.Add(
+sum(menge[p.name] * p.verbrauch.get(ressource, 0.0)
+for p in problem.produkte)
+<= problem.kapazitaeten[ressource], name=ressource)
+ solver.Maximize(sum(menge[p.name] * p.deckungsbeitrag for p in problem.produkte))
+
+ t0 = time.perf_counter()
+ rohstatus = solver.Solve()
+ laufzeit = time.perf_counter() - t0
+
+ status = status_von_pywraplp(rohstatus)
+ifnot status.brauchbar:
+return Loesung(status=status, laufzeit=laufzeit)
+return Loesung(status=status,
+ werte={name: v.solution_value() for name, v in menge.items()},
+ zielwert=solver.Objective().Value(),
+ schranke=solver.Objective().Value(),
+ schattenpreise={r: b.dual_value() for r, b in bedingung.items()},
+ laufzeit=laufzeit)
+
+
+def loese_mit_scipy(problem: Produktionsproblem) -> Loesung:
+"""Derselbe Fall mit scipy.optimize.linprog - anderer Solver, gleiche Loesung.
+
+ Beachten Sie, wie wenig hier steht: Die Daten kommen fertig geprueft aus
+ dem Domaenenmodell, die Auswertung geht ans gemeinsame Loesung-Objekt.
+ Genau das ist der Ertrag der Trennung.
+ """
+import time
+from scipy.optimize import linprog
+
+ t0 = time.perf_counter()
+ ergebnis = linprog(c=-problem.deckungsbeitragsvektor(), # linprog minimiert
+ A_ub=problem.verbrauchsmatrix(),
+ b_ub=problem.kapazitaetsvektor(),
+ bounds=(0, None), method="highs")
+ laufzeit = time.perf_counter() - t0
+
+ status = status_von_scipy(ergebnis)
+ifnot status.brauchbar:
+return Loesung(status=status, laufzeit=laufzeit)
+# linprog rechnet minimierend mit negierten Kosten - die Dualwerte
+# muessen entsprechend zurueckgedreht werden.
+return Loesung(status=status,
+ werte={p.name: float(x)
+for p, x inzip(problem.produkte, ergebnis.x)},
+ zielwert=float(-ergebnis.fun),
+ schranke=float(-ergebnis.fun),
+ schattenpreise={r: float(-m) for r, m in
+zip(problem.ressourcen,
+ ergebnis.ineqlin.marginals)},
+ laufzeit=laufzeit)
+
+
+# --- 6. Ein- und Ausgabe: Tabellen sind die Wirklichkeit ---------------------
+#
+# In den meisten Betrieben liegen die Zahlen in einer Tabellenkalkulation
+# (Kapitel Einfuehrung). Diese beiden Funktionen sind die einzige Stelle im
+# Modul, die das weiss - alles andere arbeitet mit dem Domaenenmodell.
+#
+# pandas und openpyxl werden bewusst erst hier importiert: Wer or_kern nur
+# fuer Statusauswertung und Pruefung benutzt, soll sie nicht installieren
+# muessen.
+
+STAMMSPALTEN = ("Produkt", "Deckungsbeitrag")
+
+
+def lade_produktionsproblem(pfad: str, blatt_produkte: str="Produkte",
+ blatt_kapazitaeten: str="Kapazitaeten"
+ ) -> Produktionsproblem:
+"""Liest eine Excel-Mappe und liefert ein geprueftes Domaenenmodell.
+
+ Alle Spalten ausser 'Produkt' und 'Deckungsbeitrag' gelten als
+ Verbrauchsspalten - eine neue Ressource ist damit eine neue SPALTE in der
+ Mappe und erfordert keine Codeaenderung.
+
+ Die eigentliche Pruefung passiert nicht hier, sondern im Konstruktor von
+ Produktionsproblem. Diese Funktion uebersetzt nur Tabelle in Objekt; was
+ ein gueltiges Problem ist, steht an genau einer Stelle.
+ """
+import pandas as pd
+
+ produkte_tabelle = pd.read_excel(pfad, sheet_name=blatt_produkte)
+ kapazitaeten_tabelle = pd.read_excel(pfad, sheet_name=blatt_kapazitaeten)
+
+ fehlende = [s for s in STAMMSPALTEN if s notin produkte_tabelle.columns]
+if fehlende:
+raiseValueError(f"Blatt '{blatt_produkte}': Spalten fehlen: {fehlende}")
+if produkte_tabelle.isna().any().any():
+ zeilen = produkte_tabelle[produkte_tabelle.isna().any(axis=1)].index.tolist()
+raiseValueError(f"Blatt '{blatt_produkte}': leere Zellen in Zeile(n) {zeilen}")
+
+ ressourcen = [s for s in produkte_tabelle.columns if s notin STAMMSPALTEN]
+ produkte = [
+ Produkt(name=str(zeile.Produkt),
+ deckungsbeitrag=float(zeile.Deckungsbeitrag),
+ verbrauch={r: float(getattr(zeile, r)) for r in ressourcen})
+for zeile in produkte_tabelle.itertuples()
+ ]
+ kapazitaeten = {str(z.Ressource): float(z.Verfuegbar)
+for z in kapazitaeten_tabelle.itertuples()}
+
+return Produktionsproblem(produkte=produkte, kapazitaeten=kapazitaeten)
+
+
+def schreibe_ergebnis(pfad: str, problem: Produktionsproblem,
+ loesung: Loesung) ->None:
+"""Schreibt Plan und Kennzahlen als zwei Blaetter zurueck nach Excel.
+
+ Die Fachabteilung bekommt das Ergebnis im Format, das sie ohnehin benutzt.
+ Akzeptanz entscheidet ueber Projekterfolg (Kapitel Praxisfallen).
+ """
+import pandas as pd
+
+ifnot loesung.status.brauchbar:
+raiseValueError(f"Nichts zu schreiben - Status: {loesung.status.value}")
+
+ mengen = np.array([loesung.werte[p.name] for p in problem.produkte])
+ plan = pd.DataFrame({
+"Produkt": [p.name for p in problem.produkte],
+"Menge": mengen,
+"Deckungsbeitrag": mengen * problem.deckungsbeitragsvektor(),
+ })
+
+ verbrauch = problem.verbrauchsmatrix() @ mengen
+ kennzahlen: dict[str, float|str] = {
+"Status": loesung.status.value,
+"Gesamtdeckungsbeitrag": loesung.zielwert or0.0,
+ }
+for ressource, ist, grenze inzip(problem.ressourcen, verbrauch,
+ problem.kapazitaetsvektor()):
+ kennzahlen[f"{ressource}: Verbrauch"] =float(ist)
+ kennzahlen[f"{ressource}: Auslastung %"] =float(100.0* ist / grenze)
+if ressource in loesung.schattenpreise:
+ kennzahlen[f"{ressource}: Schattenpreis"] =\
+ loesung.schattenpreise[ressource]
+
+ kennzahlen_tabelle = pd.DataFrame({
+"Kennzahl": list(kennzahlen),
+"Wert": [round(w, 2) ifisinstance(w, float) else w
+for w in kennzahlen.values()],
+ })
+
+with pd.ExcelWriter(pfad, engine="openpyxl") as mappe:
+ plan.round(2).to_excel(mappe, sheet_name="Plan", index=False)
+ kennzahlen_tabelle.to_excel(mappe, sheet_name="Kennzahlen", index=False)
+
+
+if__name__=="__main__":
+# Die Schreinerei aus Kapitel Einfuehrung - jetzt als Domaenenmodell.
+ schreinerei = Produktionsproblem(
+ produkte=[
+ Produkt(name="Tisch", deckungsbeitrag=240.0,
+ verbrauch={"Montagestunden": 3.0, "Plattenmaterial": 6.0}),
+ Produkt(name="Stuhl", deckungsbeitrag=60.0,
+ verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0}),
+ ],
+ kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0},
+ )
+
+print("="*78)
+print(" DERSELBE FALL, ZWEI SOLVER, EIN AUSWERTUNGSCODE")
+print("="*78)
+
+for name, bauer in [("OR-Tools GLOP", loese_mit_glop),
+ ("scipy / HiGHS", loese_mit_scipy)]:
+ loesung = bauer(schreinerei)
+ beanstandungen = pruefe_loesung(schreinerei, loesung)
+print(f"\n{name}")
+print(f" {loesung.als_bericht()}")
+for produkt, menge in loesung.werte.items():
+print(f" {produkt:<10}{menge:8.2f}")
+print(f" Abnahmepruefung: "
+f"{'bestanden'ifnot beanstandungen else beanstandungen}")
+
+print("\n"+"="*78)
+print(" WAS DIE PRUEFUNGEN ABFANGEN")
+print("="*78)
+
+ faelle = [
+ ("Kapazitaet 0", dict(
+ produkte=schreinerei.produkte,
+ kapazitaeten={"Montagestunden": 0.0, "Plattenmaterial": 240.0})),
+ ("Ressource ohne Kapazitaet", dict(
+ produkte=[Produkt(name="Regal", deckungsbeitrag=130.0,
+ verbrauch={"Lackieren": 2.0})],
+ kapazitaeten={"Montagestunden": 150.0})),
+ ("doppelter Produktname", dict(
+ produkte=[schreinerei.produkte[0], schreinerei.produkte[0]],
+ kapazitaeten=schreinerei.kapazitaeten)),
+ ]
+for beschreibung, daten in faelle:
+try:
+ Produktionsproblem(**daten)
+print(f" {beschreibung:<28} NICHT erkannt (!)")
+exceptExceptionas fehler:
+ meldung =str(fehler).splitlines()
+ kern =next((z.strip() for z in meldung
+if"Value error"in z or"greater than"in z), meldung[-1])
+print(f" {beschreibung:<28} abgefangen: {kern[:44]}")
+
+print("\nAlle drei scheitern beim EINLESEN - nicht erst beim Loesen und")
+print("schon gar nicht erst im Bericht. Das ist der ganze Zweck der")
+print("Domaenenschicht.")
+print("="*78)
Erwartete Ausgabe:
==============================================================================
DERSELBE FALL, ZWEI SOLVER, EIN AUSWERTUNGSCODE
@@ -25476,330 +25844,330 @@ Domaenenschicht.
📎 Warum zwei Prozesse?
ortools und highspy bringen beide eine eigene HiGHS-Kopie mit und kollidieren beim gemeinsamen Import (Kapitel 3). Das Programm startet sich deshalb für jeden Solver selbst noch einmal als Kindprozess und lässt sich das Ergebnis als JSON zurückgeben:
-
print(MODELLBAUER[sys.argv[1]](problem).model_dump_json()) # im Kindprozess
-Loesung.model_validate_json(...) # im Hauptprozess
+
print(MODELLBAUER[sys.argv[1]](problem).model_dump_json()) # im Kindprozess
+Loesung.model_validate_json(...) # im Hauptprozess
Das ist der zweite, weniger offensichtliche Ertrag des DTOs: Ein Lösungsobjekt ohne Solverbezug ist nicht nur eine Sprachregelung, sondern ein Datenformat. Es überlebt eine Prozessgrenze, passt in eine Warteschlange und lässt sich protokollieren. Ein pywraplp.Solver-Objekt kann das alles nicht.
-
#!/usr/bin/env python3
-
-# Solverwechsel_CPSAT_HiGHS.py
-"""
-Kapitel Praxisfallen: Denselben Fall einmal mit CP-SAT und einmal mit HiGHS rechnen.
-
-Die Trennung aus or_kern.py behauptet, ein Solverwechsel koste genau EINEN
-Baustein. Dieses Programm loest diese Behauptung ein. Aufgabe ist eine
-Standortplanung: Welche Lager oeffnen wir, und wer beliefert welchen Kunden?
-
- Standortproblem (Pydantic, geprueft) <- gemeinsam
- baue_und_loese_mit_cpsat() <- der EINE Baustein
- baue_und_loese_mit_highs() <- ... in zwei Ausfuehrungen
- Loesung (DTO) <- gemeinsam
- pruefe_zuordnung() <- gemeinsam
- berichte() <- gemeinsam
-
-Die beiden Modellbauer sind 39 und 52 Zeilen lang - sie sind der gesamte
-solverabhaengige Teil des Programms. Alles andere wird zweimal benutzt und
-einmal geschrieben.
-
-WARUM ZWEI PROZESSE? ortools und highspy bringen beide eine eigene
-HiGHS-Kopie mit und lassen sich auf vielen Systemen nicht gemeinsam
-importieren (Kapitel Oekosystem). Das Hauptprogramm startet deshalb fuer jeden
-Solver einen eigenen Python-Prozess und laesst sich die Loesung als JSON
-zurueckgeben - das DTO ist nicht nur eine Sprachregelung, sondern ein
-Datenformat, das eine Prozessgrenze ueberlebt.
-
-Aufruf:
- python3 Solverwechsel_CPSAT_HiGHS.py # beide, mit Vergleich
- python3 Solverwechsel_CPSAT_HiGHS.py cpsat # nur der Kindprozess
- python3 Solverwechsel_CPSAT_HiGHS.py highs
-
-Benoetigt: numpy, pydantic, ortools, highspy (jeweils im eigenen Prozess)
-"""
-
-from __future__ import annotations
-
-import subprocess
-import sys
-import time
-
-import numpy as np
-from pydantic import BaseModel, Field, model_validator
-
-from or_kern import Loesung, SolverStatus, status_von_cpsat, status_von_highs
-
-ZEITLIMIT =30.0
-
-
-# --- 1. Domaenenmodell: dieselben Fakten fuer beide Solver -------------------
-
-class Standortproblem(BaseModel):
-"""Kapazitierte Standortplanung mit Einzelbelieferung.
-
- Nach dem Muster von Produktionsproblem in or_kern.py: Die Pruefungen
- stehen im Konstruktor, nicht im Solvercode - sie gelten damit fuer beide
- Solver, und sie schlagen beim Einlesen zu.
-
- Alle Kosten sind ganzzahlig (Euro). Das ist keine Bequemlichkeit, sondern
- Voraussetzung: CP-SAT rechnet ausschliesslich ganzzahlig.
- """
- lager: list[str] = Field(min_length=1)
- kunden: list[str] = Field(min_length=1)
- fixkosten: list[int] # je Lager, faellt bei Eroeffnung an
- kapazitaet: list[int] # je Lager, in Paletten
- bedarf: list[int] # je Kunde, in Paletten
- transport: list[list[int]] # [Lager][Kunde], Kosten der Belieferung
-
-@model_validator(mode="after")
-def pruefe_masse(self) ->"Standortproblem":
- n, m =len(self.lager), len(self.kunden)
-iflen(self.fixkosten) != n orlen(self.kapazitaet) != n:
-raiseValueError(f"fixkosten/kapazitaet muessen {n} Eintraege haben")
-iflen(self.bedarf) != m:
-raiseValueError(f"bedarf muss {m} Eintraege haben")
-iflen(self.transport) != n orany(len(z) != m for z inself.transport):
-raiseValueError(f"transport muss {n} x {m} sein")
-ifsum(self.kapazitaet) <sum(self.bedarf):
-raiseValueError(f"Gesamtkapazitaet {sum(self.kapazitaet)} deckt den "
-f"Gesamtbedarf {sum(self.bedarf)} nicht")
-returnself
-
-def schluessel(self, i: int, j: int) ->str:
-"""Variablenname im Loesung-DTO - beide Modellbauer benutzen ihn."""
-returnf"{self.lager[i]}->{self.kunden[j]}"
-
-
-def beispielproblem(saat: int=11) -> Standortproblem:
-"""Sechs moegliche Lager, zwoelf Kunden - klein genug fuer beide Solver."""
- rng = np.random.default_rng(saat)
- lager = [f"Lager_{k}"for k in"ABCDEF"]
- kunden = [f"Kunde_{k:02d}"for k inrange(1, 13)]
- bedarf = rng.integers(10, 60, len(kunden))
-return Standortproblem(
- lager=lager,
- kunden=kunden,
- fixkosten=rng.integers(3000, 9000, len(lager)).tolist(),
- kapazitaet=[int(bedarf.sum() *0.45)] *len(lager),
- bedarf=bedarf.tolist(),
- transport=rng.integers(200, 1800, (len(lager), len(kunden))).tolist(),
- )
-
-
-# --- 2. Der eine Baustein, der sich aendert: der Modellbauer -----------------
-
-def baue_und_loese_mit_cpsat(problem: Standortproblem) -> Loesung:
-"""CP-SAT: Bool-Variablen, ganzzahlige Koeffizienten, Minimize."""
-from ortools.sat.python import cp_model
-
- n, m =len(problem.lager), len(problem.kunden)
- modell = cp_model.CpModel()
- y = [modell.NewBoolVar(f"offen_{i}") for i inrange(n)]
- x = {(i, j): modell.NewBoolVar(f"liefert_{i}_{j}")
-for i inrange(n) for j inrange(m)}
-
-for j inrange(m): # jeder Kunde genau einmal
- modell.AddExactlyOne(x[i, j] for i inrange(n))
-for i inrange(n):
-for j inrange(m): # nur aus offenen Lagern
- modell.AddImplication(x[i, j], y[i])
- modell.Add(sum(problem.bedarf[j] * x[i, j] for j inrange(m))
-<= problem.kapazitaet[i] * y[i]) # Kapazitaet
-
- modell.Minimize(
-sum(problem.fixkosten[i] * y[i] for i inrange(n))
-+sum(problem.transport[i][j] * x[i, j] for i inrange(n) for j inrange(m)))
-
- loeser = cp_model.CpSolver()
- loeser.parameters.max_time_in_seconds = ZEITLIMIT
- loeser.parameters.num_workers =1
- loeser.parameters.random_seed =1
- status = status_von_cpsat(loeser.Solve(modell))
-ifnot status.brauchbar:
-return Loesung(status=status, laufzeit=loeser.WallTime())
-
-return Loesung(
- status=status,
- werte={problem.schluessel(i, j): float(loeser.Value(x[i, j]))
-for i inrange(n) for j inrange(m)},
- zielwert=loeser.ObjectiveValue(),
- schranke=loeser.BestObjectiveBound(),
- laufzeit=loeser.WallTime())
-
-
-def baue_und_loese_mit_highs(problem: Standortproblem) -> Loesung:
-"""HiGHS: dieselben Restriktionen als Ungleichungszeilen einer Matrix."""
-import highspy
-
- n, m =len(problem.lager), len(problem.kunden)
- anzahl_x = n * m # Spalten 0..n*m-1
- spalte_y =lambda i: anzahl_x + i # danach die y_i # noqa: E731
-
- modell = highspy.Highs()
- modell.setOptionValue("output_flag", False)
- modell.setOptionValue("time_limit", ZEITLIMIT)
- modell.addVars(anzahl_x + n, np.zeros(anzahl_x + n), np.ones(anzahl_x + n))
-for spalte inrange(anzahl_x + n):
- modell.changeColIntegrality(spalte, highspy.HighsVarType.kInteger)
-for i inrange(n):
- modell.changeColCost(spalte_y(i), float(problem.fixkosten[i]))
-for j inrange(m):
- modell.changeColCost(i * m + j, float(problem.transport[i][j]))
-
-for j inrange(m): # jeder Kunde genau einmal
- index = np.array([i * m + j for i inrange(n)], dtype=np.int32)
- modell.addRow(1.0, 1.0, n, index, np.ones(n))
-for i inrange(n):
-for j inrange(m): # x_ij - y_i <= 0
- modell.addRow(-highspy.kHighsInf, 0.0, 2,
- np.array([i * m + j, spalte_y(i)], dtype=np.int32),
- np.array([1.0, -1.0]))
- index = np.array([i * m + j for j inrange(m)] + [spalte_y(i)],
- dtype=np.int32) # Kapazitaet
- werte = np.concatenate([np.array(problem.bedarf, dtype=float),
- [-float(problem.kapazitaet[i])]])
- modell.addRow(-highspy.kHighsInf, 0.0, m +1, index, werte)
-
- t0 = time.perf_counter()
- modell.run()
- laufzeit = time.perf_counter() - t0
-
- status = status_von_highs(modell.modelStatusToString(modell.getModelStatus()))
-ifnot status.brauchbar:
-return Loesung(status=status, laufzeit=laufzeit)
-
- loesungswerte = modell.getSolution().col_value
- info = modell.getInfo()
-return Loesung(
- status=status,
- werte={problem.schluessel(i, j): float(loesungswerte[i * m + j])
-for i inrange(n) for j inrange(m)},
- zielwert=info.objective_function_value,
- schranke=info.mip_dual_bound,
- laufzeit=laufzeit)
-
-
-MODELLBAUER = {"cpsat": baue_und_loese_mit_cpsat, "highs": baue_und_loese_mit_highs}
-
-
-# --- 3. Alles Weitere ist wieder gemeinsam -----------------------------------
-
-def pruefe_zuordnung(problem: Standortproblem, loesung: Loesung,
- toleranz: float=1e-6) ->list[str]:
-"""Prueft die Loesung gegen die Anforderung - ohne Solver, ohne Modell."""
-ifnot loesung.status.brauchbar:
-return [f"kein verwertbares Ergebnis ({loesung.status.value})"]
-
- n, m =len(problem.lager), len(problem.kunden)
- zuordnung = np.array([[loesung.werte[problem.schluessel(i, j)]
-for j inrange(m)] for i inrange(n)])
- beanstandungen: list[str] = []
-
-if (np.abs(zuordnung - np.round(zuordnung)) > toleranz).any():
- beanstandungen.append("Zuordnungen sind nicht 0/1")
- zuordnung = np.round(zuordnung)
-
-for j, kunde inenumerate(problem.kunden):
-ifabs(zuordnung[:, j].sum() -1.0) > toleranz:
- beanstandungen.append(f"{kunde} wird {zuordnung[:, j].sum():.0f}-mal beliefert")
-
- beliefert = zuordnung @ np.array(problem.bedarf, dtype=float)
-for i, lagername inenumerate(problem.lager):
-if beliefert[i] > problem.kapazitaet[i] + toleranz:
- beanstandungen.append(f"{lagername}: {beliefert[i]:.0f} Paletten ueber "
-f"Kapazitaet {problem.kapazitaet[i]}")
-
-if loesung.zielwert isnotNone:
- offen = beliefert > toleranz
- nachgerechnet = (np.array(problem.fixkosten, dtype=float) @ offen
-+ (np.array(problem.transport, dtype=float) * zuordnung).sum())
-ifabs(nachgerechnet - loesung.zielwert) >0.5:
- beanstandungen.append(f"Zielwert {loesung.zielwert:,.0f} passt nicht zur "
-f"Zuordnung (nachgerechnet {nachgerechnet:,.0f})")
-return beanstandungen
-
-
-def geoeffnete_lager(problem: Standortproblem, loesung: Loesung) ->list[str]:
- m =len(problem.kunden)
-return [name for i, name inenumerate(problem.lager)
-ifany(loesung.werte[problem.schluessel(i, j)] >0.5for j inrange(m))]
-
-
-def loese_in_eigenem_prozess(name: str) -> Loesung:
-"""Startet dieses Programm noch einmal - mit genau einem Solverimport."""
- ergebnis = subprocess.run([sys.executable, __file__, name],
- capture_output=True, text=True, timeout=300)
-if ergebnis.returncode !=0:
-raiseRuntimeError(ergebnis.stderr.strip().splitlines()[-1])
-# Das DTO als JSON - genau dafuer ist ein Datenobjekt ohne Solverbezug gut.
-return Loesung.model_validate_json(ergebnis.stdout.strip().splitlines()[-1])
-
-
-if__name__=="__main__":
- problem = beispielproblem()
-
-# --- Kindprozess: rechnen und das DTO als JSON ausgeben ---------------
-iflen(sys.argv) >1:
-print(MODELLBAUER[sys.argv[1]](problem).model_dump_json())
- sys.exit(0)
-
-# --- Hauptprozess: beide Solver anstossen und vergleichen -------------
-print("="*82)
-print(" DERSELBE FALL, ZWEI SOLVER - UND EIN AUSWERTUNGSCODE")
-print("="*82)
-print(f"Standortplanung: {len(problem.lager)} moegliche Lager, "
-f"{len(problem.kunden)} Kunden, {sum(problem.bedarf)} Paletten Bedarf.")
-print(f"Kapazitaet je Lager: {problem.kapazitaet[0]} Paletten "
-f"-> mindestens 3 Lager noetig.\n")
-
- loesungen: dict[str, Loesung] = {}
-for name, beschriftung in [("cpsat", "OR-Tools CP-SAT"),
- ("highs", "HiGHS (highspy)")]:
- loesung = loesungen[name] = loese_in_eigenem_prozess(name)
- beanstandungen = pruefe_zuordnung(problem, loesung)
-
-print(f"{beschriftung}")
-print(f" {loesung.als_bericht()}")
-print(f" eroeffnete Lager: {', '.join(geoeffnete_lager(problem, loesung))}")
-print(f" Abnahmepruefung: "
-f"{'bestanden'ifnot beanstandungen else beanstandungen}")
-
-# --- Was der Vergleich zeigt -----------------------------------------
- zielwerte = [loesung.zielwert for loesung in loesungen.values()]
-print("-"*82)
-print(f"Zielwertdifferenz: {abs(zielwerte[0] - zielwerte[1]):.6f} EUR")
-
- gleich_belegt =all(
-round(loesungen["cpsat"].werte[s]) ==round(loesungen["highs"].werte[s])
-for s in loesungen["cpsat"].werte)
-print(f"Identische Zuordnung: {'ja'if gleich_belegt else'nein'}")
-
-assertabs(zielwerte[0] - zielwerte[1]) <0.5, "Die Solver widersprechen sich!"
-assertall(l.status is SolverStatus.OPTIMAL for l in loesungen.values())
-
-print("\n"+"="*82)
-print(" WAS DER WECHSEL GEKOSTET HAT")
-print("="*82)
-print("Ausgetauscht wurde EINE Funktion. Domaenenmodell, Abnahmepruefung und")
-print("Bericht sind woertlich dieselben - sie sehen den Solver nie.")
-print()
-print("Nicht umsonst ist der Wechsel trotzdem:")
-print(" * CP-SAT rechnet ausschliesslich GANZZAHLIG. Alle Kosten sind hier")
-print(" deshalb int. Wer in Euro und Cent rechnet, skaliert vorher auf Cent -")
-print(" und muss das im Bericht wieder zuruecknehmen.")
-print(" * HiGHS braucht die Restriktionen als Matrixzeilen, CP-SAT nimmt sie")
-print(" als Ausdruecke. Das ist der Grund, warum der HiGHS-Modellbauer")
-print(" laenger ist, obwohl er dasselbe Modell beschreibt.")
-print(" * Beide Bibliotheken bringen eine eigene HiGHS-Kopie mit und lassen")
-print(" sich nicht gemeinsam importieren - daher die zwei Prozesse.")
-print()
-print("Der Ertrag: Beide beweisen denselben optimalen Zielwert, und die")
-print("Entscheidung zwischen ihnen ist eine Frage der Laufzeit geworden -")
-print("nicht eine Frage, wie viel Code man neu schreiben muss.")
-print()
-print("Verglichen wird deshalb der ZIELWERT, nicht der Plan: Gibt es mehrere")
-print("gleich teure Loesungen, darf jeder Solver eine andere davon liefern.")
-print("Hier stimmen sie zufaellig ueberein - darauf zu testen waere trotzdem")
-print("ein unzuverlaessiger Test (siehe JobShop_Intervalle.py).")
-print("="*82)
+
#!/usr/bin/env python3
+
+# Solverwechsel_CPSAT_HiGHS.py
+"""
+Kapitel Praxisfallen: Denselben Fall einmal mit CP-SAT und einmal mit HiGHS rechnen.
+
+Die Trennung aus or_kern.py behauptet, ein Solverwechsel koste genau EINEN
+Baustein. Dieses Programm loest diese Behauptung ein. Aufgabe ist eine
+Standortplanung: Welche Lager oeffnen wir, und wer beliefert welchen Kunden?
+
+ Standortproblem (Pydantic, geprueft) <- gemeinsam
+ baue_und_loese_mit_cpsat() <- der EINE Baustein
+ baue_und_loese_mit_highs() <- ... in zwei Ausfuehrungen
+ Loesung (DTO) <- gemeinsam
+ pruefe_zuordnung() <- gemeinsam
+ berichte() <- gemeinsam
+
+Die beiden Modellbauer sind 39 und 52 Zeilen lang - sie sind der gesamte
+solverabhaengige Teil des Programms. Alles andere wird zweimal benutzt und
+einmal geschrieben.
+
+WARUM ZWEI PROZESSE? ortools und highspy bringen beide eine eigene
+HiGHS-Kopie mit und lassen sich auf vielen Systemen nicht gemeinsam
+importieren (Kapitel Oekosystem). Das Hauptprogramm startet deshalb fuer jeden
+Solver einen eigenen Python-Prozess und laesst sich die Loesung als JSON
+zurueckgeben - das DTO ist nicht nur eine Sprachregelung, sondern ein
+Datenformat, das eine Prozessgrenze ueberlebt.
+
+Aufruf:
+ python3 Solverwechsel_CPSAT_HiGHS.py # beide, mit Vergleich
+ python3 Solverwechsel_CPSAT_HiGHS.py cpsat # nur der Kindprozess
+ python3 Solverwechsel_CPSAT_HiGHS.py highs
+
+Benoetigt: numpy, pydantic, ortools, highspy (jeweils im eigenen Prozess)
+"""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+import time
+
+import numpy as np
+from pydantic import BaseModel, Field, model_validator
+
+from or_kern import Loesung, SolverStatus, status_von_cpsat, status_von_highs
+
+ZEITLIMIT =30.0
+
+
+# --- 1. Domaenenmodell: dieselben Fakten fuer beide Solver -------------------
+
+class Standortproblem(BaseModel):
+"""Kapazitierte Standortplanung mit Einzelbelieferung.
+
+ Nach dem Muster von Produktionsproblem in or_kern.py: Die Pruefungen
+ stehen im Konstruktor, nicht im Solvercode - sie gelten damit fuer beide
+ Solver, und sie schlagen beim Einlesen zu.
+
+ Alle Kosten sind ganzzahlig (Euro). Das ist keine Bequemlichkeit, sondern
+ Voraussetzung: CP-SAT rechnet ausschliesslich ganzzahlig.
+ """
+ lager: list[str] = Field(min_length=1)
+ kunden: list[str] = Field(min_length=1)
+ fixkosten: list[int] # je Lager, faellt bei Eroeffnung an
+ kapazitaet: list[int] # je Lager, in Paletten
+ bedarf: list[int] # je Kunde, in Paletten
+ transport: list[list[int]] # [Lager][Kunde], Kosten der Belieferung
+
+@model_validator(mode="after")
+def pruefe_masse(self) ->"Standortproblem":
+ n, m =len(self.lager), len(self.kunden)
+iflen(self.fixkosten) != n orlen(self.kapazitaet) != n:
+raiseValueError(f"fixkosten/kapazitaet muessen {n} Eintraege haben")
+iflen(self.bedarf) != m:
+raiseValueError(f"bedarf muss {m} Eintraege haben")
+iflen(self.transport) != n orany(len(z) != m for z inself.transport):
+raiseValueError(f"transport muss {n} x {m} sein")
+ifsum(self.kapazitaet) <sum(self.bedarf):
+raiseValueError(f"Gesamtkapazitaet {sum(self.kapazitaet)} deckt den "
+f"Gesamtbedarf {sum(self.bedarf)} nicht")
+returnself
+
+def schluessel(self, i: int, j: int) ->str:
+"""Variablenname im Loesung-DTO - beide Modellbauer benutzen ihn."""
+returnf"{self.lager[i]}->{self.kunden[j]}"
+
+
+def beispielproblem(saat: int=11) -> Standortproblem:
+"""Sechs moegliche Lager, zwoelf Kunden - klein genug fuer beide Solver."""
+ rng = np.random.default_rng(saat)
+ lager = [f"Lager_{k}"for k in"ABCDEF"]
+ kunden = [f"Kunde_{k:02d}"for k inrange(1, 13)]
+ bedarf = rng.integers(10, 60, len(kunden))
+return Standortproblem(
+ lager=lager,
+ kunden=kunden,
+ fixkosten=rng.integers(3000, 9000, len(lager)).tolist(),
+ kapazitaet=[int(bedarf.sum() *0.45)] *len(lager),
+ bedarf=bedarf.tolist(),
+ transport=rng.integers(200, 1800, (len(lager), len(kunden))).tolist(),
+ )
+
+
+# --- 2. Der eine Baustein, der sich aendert: der Modellbauer -----------------
+
+def baue_und_loese_mit_cpsat(problem: Standortproblem) -> Loesung:
+"""CP-SAT: Bool-Variablen, ganzzahlige Koeffizienten, Minimize."""
+from ortools.sat.python import cp_model
+
+ n, m =len(problem.lager), len(problem.kunden)
+ modell = cp_model.CpModel()
+ y = [modell.NewBoolVar(f"offen_{i}") for i inrange(n)]
+ x = {(i, j): modell.NewBoolVar(f"liefert_{i}_{j}")
+for i inrange(n) for j inrange(m)}
+
+for j inrange(m): # jeder Kunde genau einmal
+ modell.AddExactlyOne(x[i, j] for i inrange(n))
+for i inrange(n):
+for j inrange(m): # nur aus offenen Lagern
+ modell.AddImplication(x[i, j], y[i])
+ modell.Add(sum(problem.bedarf[j] * x[i, j] for j inrange(m))
+<= problem.kapazitaet[i] * y[i]) # Kapazitaet
+
+ modell.Minimize(
+sum(problem.fixkosten[i] * y[i] for i inrange(n))
++sum(problem.transport[i][j] * x[i, j] for i inrange(n) for j inrange(m)))
+
+ loeser = cp_model.CpSolver()
+ loeser.parameters.max_time_in_seconds = ZEITLIMIT
+ loeser.parameters.num_workers =1
+ loeser.parameters.random_seed =1
+ status = status_von_cpsat(loeser.Solve(modell))
+ifnot status.brauchbar:
+return Loesung(status=status, laufzeit=loeser.WallTime())
+
+return Loesung(
+ status=status,
+ werte={problem.schluessel(i, j): float(loeser.Value(x[i, j]))
+for i inrange(n) for j inrange(m)},
+ zielwert=loeser.ObjectiveValue(),
+ schranke=loeser.BestObjectiveBound(),
+ laufzeit=loeser.WallTime())
+
+
+def baue_und_loese_mit_highs(problem: Standortproblem) -> Loesung:
+"""HiGHS: dieselben Restriktionen als Ungleichungszeilen einer Matrix."""
+import highspy
+
+ n, m =len(problem.lager), len(problem.kunden)
+ anzahl_x = n * m # Spalten 0..n*m-1
+ spalte_y =lambda i: anzahl_x + i # danach die y_i # noqa: E731
+
+ modell = highspy.Highs()
+ modell.setOptionValue("output_flag", False)
+ modell.setOptionValue("time_limit", ZEITLIMIT)
+ modell.addVars(anzahl_x + n, np.zeros(anzahl_x + n), np.ones(anzahl_x + n))
+for spalte inrange(anzahl_x + n):
+ modell.changeColIntegrality(spalte, highspy.HighsVarType.kInteger)
+for i inrange(n):
+ modell.changeColCost(spalte_y(i), float(problem.fixkosten[i]))
+for j inrange(m):
+ modell.changeColCost(i * m + j, float(problem.transport[i][j]))
+
+for j inrange(m): # jeder Kunde genau einmal
+ index = np.array([i * m + j for i inrange(n)], dtype=np.int32)
+ modell.addRow(1.0, 1.0, n, index, np.ones(n))
+for i inrange(n):
+for j inrange(m): # x_ij - y_i <= 0
+ modell.addRow(-highspy.kHighsInf, 0.0, 2,
+ np.array([i * m + j, spalte_y(i)], dtype=np.int32),
+ np.array([1.0, -1.0]))
+ index = np.array([i * m + j for j inrange(m)] + [spalte_y(i)],
+ dtype=np.int32) # Kapazitaet
+ werte = np.concatenate([np.array(problem.bedarf, dtype=float),
+ [-float(problem.kapazitaet[i])]])
+ modell.addRow(-highspy.kHighsInf, 0.0, m +1, index, werte)
+
+ t0 = time.perf_counter()
+ modell.run()
+ laufzeit = time.perf_counter() - t0
+
+ status = status_von_highs(modell.modelStatusToString(modell.getModelStatus()))
+ifnot status.brauchbar:
+return Loesung(status=status, laufzeit=laufzeit)
+
+ loesungswerte = modell.getSolution().col_value
+ info = modell.getInfo()
+return Loesung(
+ status=status,
+ werte={problem.schluessel(i, j): float(loesungswerte[i * m + j])
+for i inrange(n) for j inrange(m)},
+ zielwert=info.objective_function_value,
+ schranke=info.mip_dual_bound,
+ laufzeit=laufzeit)
+
+
+MODELLBAUER = {"cpsat": baue_und_loese_mit_cpsat, "highs": baue_und_loese_mit_highs}
+
+
+# --- 3. Alles Weitere ist wieder gemeinsam -----------------------------------
+
+def pruefe_zuordnung(problem: Standortproblem, loesung: Loesung,
+ toleranz: float=1e-6) ->list[str]:
+"""Prueft die Loesung gegen die Anforderung - ohne Solver, ohne Modell."""
+ifnot loesung.status.brauchbar:
+return [f"kein verwertbares Ergebnis ({loesung.status.value})"]
+
+ n, m =len(problem.lager), len(problem.kunden)
+ zuordnung = np.array([[loesung.werte[problem.schluessel(i, j)]
+for j inrange(m)] for i inrange(n)])
+ beanstandungen: list[str] = []
+
+if (np.abs(zuordnung - np.round(zuordnung)) > toleranz).any():
+ beanstandungen.append("Zuordnungen sind nicht 0/1")
+ zuordnung = np.round(zuordnung)
+
+for j, kunde inenumerate(problem.kunden):
+ifabs(zuordnung[:, j].sum() -1.0) > toleranz:
+ beanstandungen.append(f"{kunde} wird {zuordnung[:, j].sum():.0f}-mal beliefert")
+
+ beliefert = zuordnung @ np.array(problem.bedarf, dtype=float)
+for i, lagername inenumerate(problem.lager):
+if beliefert[i] > problem.kapazitaet[i] + toleranz:
+ beanstandungen.append(f"{lagername}: {beliefert[i]:.0f} Paletten ueber "
+f"Kapazitaet {problem.kapazitaet[i]}")
+
+if loesung.zielwert isnotNone:
+ offen = beliefert > toleranz
+ nachgerechnet = (np.array(problem.fixkosten, dtype=float) @ offen
++ (np.array(problem.transport, dtype=float) * zuordnung).sum())
+ifabs(nachgerechnet - loesung.zielwert) >0.5:
+ beanstandungen.append(f"Zielwert {loesung.zielwert:,.0f} passt nicht zur "
+f"Zuordnung (nachgerechnet {nachgerechnet:,.0f})")
+return beanstandungen
+
+
+def geoeffnete_lager(problem: Standortproblem, loesung: Loesung) ->list[str]:
+ m =len(problem.kunden)
+return [name for i, name inenumerate(problem.lager)
+ifany(loesung.werte[problem.schluessel(i, j)] >0.5for j inrange(m))]
+
+
+def loese_in_eigenem_prozess(name: str) -> Loesung:
+"""Startet dieses Programm noch einmal - mit genau einem Solverimport."""
+ ergebnis = subprocess.run([sys.executable, __file__, name],
+ capture_output=True, text=True, timeout=300)
+if ergebnis.returncode !=0:
+raiseRuntimeError(ergebnis.stderr.strip().splitlines()[-1])
+# Das DTO als JSON - genau dafuer ist ein Datenobjekt ohne Solverbezug gut.
+return Loesung.model_validate_json(ergebnis.stdout.strip().splitlines()[-1])
+
+
+if__name__=="__main__":
+ problem = beispielproblem()
+
+# --- Kindprozess: rechnen und das DTO als JSON ausgeben ---------------
+iflen(sys.argv) >1:
+print(MODELLBAUER[sys.argv[1]](problem).model_dump_json())
+ sys.exit(0)
+
+# --- Hauptprozess: beide Solver anstossen und vergleichen -------------
+print("="*82)
+print(" DERSELBE FALL, ZWEI SOLVER - UND EIN AUSWERTUNGSCODE")
+print("="*82)
+print(f"Standortplanung: {len(problem.lager)} moegliche Lager, "
+f"{len(problem.kunden)} Kunden, {sum(problem.bedarf)} Paletten Bedarf.")
+print(f"Kapazitaet je Lager: {problem.kapazitaet[0]} Paletten "
+f"-> mindestens 3 Lager noetig.\n")
+
+ loesungen: dict[str, Loesung] = {}
+for name, beschriftung in [("cpsat", "OR-Tools CP-SAT"),
+ ("highs", "HiGHS (highspy)")]:
+ loesung = loesungen[name] = loese_in_eigenem_prozess(name)
+ beanstandungen = pruefe_zuordnung(problem, loesung)
+
+print(f"{beschriftung}")
+print(f" {loesung.als_bericht()}")
+print(f" eroeffnete Lager: {', '.join(geoeffnete_lager(problem, loesung))}")
+print(f" Abnahmepruefung: "
+f"{'bestanden'ifnot beanstandungen else beanstandungen}")
+
+# --- Was der Vergleich zeigt -----------------------------------------
+ zielwerte = [loesung.zielwert for loesung in loesungen.values()]
+print("-"*82)
+print(f"Zielwertdifferenz: {abs(zielwerte[0] - zielwerte[1]):.6f} EUR")
+
+ gleich_belegt =all(
+round(loesungen["cpsat"].werte[s]) ==round(loesungen["highs"].werte[s])
+for s in loesungen["cpsat"].werte)
+print(f"Identische Zuordnung: {'ja'if gleich_belegt else'nein'}")
+
+assertabs(zielwerte[0] - zielwerte[1]) <0.5, "Die Solver widersprechen sich!"
+assertall(l.status is SolverStatus.OPTIMAL for l in loesungen.values())
+
+print("\n"+"="*82)
+print(" WAS DER WECHSEL GEKOSTET HAT")
+print("="*82)
+print("Ausgetauscht wurde EINE Funktion. Domaenenmodell, Abnahmepruefung und")
+print("Bericht sind woertlich dieselben - sie sehen den Solver nie.")
+print()
+print("Nicht umsonst ist der Wechsel trotzdem:")
+print(" * CP-SAT rechnet ausschliesslich GANZZAHLIG. Alle Kosten sind hier")
+print(" deshalb int. Wer in Euro und Cent rechnet, skaliert vorher auf Cent -")
+print(" und muss das im Bericht wieder zuruecknehmen.")
+print(" * HiGHS braucht die Restriktionen als Matrixzeilen, CP-SAT nimmt sie")
+print(" als Ausdruecke. Das ist der Grund, warum der HiGHS-Modellbauer")
+print(" laenger ist, obwohl er dasselbe Modell beschreibt.")
+print(" * Beide Bibliotheken bringen eine eigene HiGHS-Kopie mit und lassen")
+print(" sich nicht gemeinsam importieren - daher die zwei Prozesse.")
+print()
+print("Der Ertrag: Beide beweisen denselben optimalen Zielwert, und die")
+print("Entscheidung zwischen ihnen ist eine Frage der Laufzeit geworden -")
+print("nicht eine Frage, wie viel Code man neu schreiben muss.")
+print()
+print("Verglichen wird deshalb der ZIELWERT, nicht der Plan: Gibt es mehrere")
+print("gleich teure Loesungen, darf jeder Solver eine andere davon liefern.")
+print("Hier stimmen sie zufaellig ueberein - darauf zu testen waere trotzdem")
+print("ein unzuverlaessiger Test (siehe JobShop_Intervalle.py).")
+print("="*82)
Erwartete Ausgabe (Laufzeiten hardwareabhängig):
==================================================================================
DERSELBE FALL, ZWEI SOLVER - UND EIN AUSWERTUNGSCODE
@@ -25967,186 +26335,186 @@ ein unzuverlaessiger Test (siehe JobShop_Intervalle.py).
Ihre Aufgabe: (a) Warum ist die gleichbleibende Laufzeit hier kein Zeichen von Stabilität, sondern das Symptom? (b) Welche drei Größen fehlen im Protokoll? (c) Welche der drei warnt am frühesten — und warum ist gerade sie so wertvoll? (d) Das Team bemerkt den Fehler 2026. Wie hoch waren die Mehrkosten, und warum lässt sich das nachträglich nicht mehr genau beziffern?
#!/usr/bin/env python3
-
-# Betriebsueberwachung.py
-"""
-Kapitel Praxisfallen: Das Modell, das still aufgehoert hat zu optimieren.
-
-Ein Optimierungsmodell geht in Betrieb und laeuft jede Nacht. Es liefert
-zuverlaessig eine Zahl, die Job-Dauer bleibt konstant, es gibt keine
-Fehlermeldung. Alles sieht gut aus.
-
-Waehrenddessen waechst das Unternehmen. Aus 15 Lagern werden 40, dann 80. Das
-Zeitlimit von drei Sekunden - vor zwei Jahren grosszuegig bemessen - reicht
-irgendwann nicht mehr. Der Solver bricht ab und liefert die beste Loesung, die
-er bis dahin gefunden hat.
-
-Das ist nicht falsch. Es ist sogar richtig so. Aber es ist etwas anderes als
-das, was das Modell einmal geleistet hat - und niemand merkt es, weil im
-Protokoll nur die Kosten stehen.
-
-Dieses Programm zeigt die Verschlechterung und die Ueberwachung, die sie
-sichtbar macht.
-
-WICHTIG: ortools wird hier nicht importiert (Konflikt mit highspy, siehe
-Kapitel Oekosystem).
-
-Benoetigt: numpy, highspy
-"""
-
-from __future__ import annotations
-
-import time
-from dataclasses import dataclass
-
-import numpy as np
-import highspy
-
-ZEITLIMIT =3.0# das Budget, das der Nachtjob hat
-GAP_WARNSCHWELLE =0.02# ab 2 % Gap wollen wir es wissen
-ZEITAUSLASTUNG_WARNSCHWELLE =0.9
-
-
-@dataclass
-class Laufprotokoll:
-"""Was nach JEDEM Produktivlauf protokolliert gehoert.
-
- Die meisten Systeme schreiben nur 'kosten'. Genau deshalb faellt eine
- schleichende Verschlechterung ueber Monate nicht auf.
- """
- zeitpunkt: str
- lager: int
- kunden: int
- status: str
- kosten: float
- gap: float
- laufzeit: float
- knoten: int
-
-@property
-def warnungen(self) ->list[str]:
- meldungen = []
-ifself.status !="Optimal":
- meldungen.append(f"nicht beweisbar optimal ({self.status})")
-ifself.gap > GAP_WARNSCHWELLE:
- meldungen.append(f"Gap {self.gap:.1%} ueber Schwelle "
-f"{GAP_WARNSCHWELLE:.0%}")
-ifself.laufzeit > ZEITAUSLASTUNG_WARNSCHWELLE * ZEITLIMIT:
- meldungen.append(f"Zeitbudget zu {self.laufzeit / ZEITLIMIT:.0%} "
-f"ausgeschoepft")
-return meldungen
-
-
-def plane_netzwerk(n_lager: int, n_kunden: int, zeitlimit: float,
- saat: int=7) ->tuple[str, float, float, float, int]:
-"""Standortplanung wie in Kapitel MILP, nur mit wachsender Groesse."""
- rng = np.random.default_rng(saat)
- fixkosten = rng.uniform(3000, 9000, n_lager)
- transport = rng.uniform(5, 60, (n_lager, n_kunden))
- bedarf = rng.uniform(10, 60, n_kunden)
- kapazitaet = np.full(n_lager, bedarf.sum() *0.22)
-
- modell = highspy.Highs()
- modell.setOptionValue("output_flag", False)
- modell.setOptionValue("time_limit", zeitlimit)
-
- anzahl_x = n_lager * n_kunden
- unendlich = highspy.kHighsInf
- modell.addVars(anzahl_x, np.zeros(anzahl_x), np.full(anzahl_x, unendlich))
- modell.addVars(n_lager, np.zeros(n_lager), np.ones(n_lager))
-for i inrange(n_lager):
- modell.changeColIntegrality(anzahl_x + i, highspy.HighsVarType.kInteger)
- modell.changeColCost(anzahl_x + i, fixkosten[i])
-for j inrange(n_kunden):
- modell.changeColCost(i * n_kunden + j, transport[i, j])
-
-for j inrange(n_kunden):
- index = np.array([i * n_kunden + j for i inrange(n_lager)], dtype=np.int32)
- modell.addRow(bedarf[j], bedarf[j], len(index), index, np.ones(len(index)))
-for i inrange(n_lager):
- index = np.array([i * n_kunden + j for j inrange(n_kunden)] + [anzahl_x + i],
- dtype=np.int32)
- werte = np.concatenate([np.ones(n_kunden), [-kapazitaet[i]]])
- modell.addRow(-unendlich, 0.0, len(index), index, werte)
-
- t0 = time.perf_counter()
- modell.run()
- laufzeit = time.perf_counter() - t0
- info = modell.getInfo()
-return (modell.modelStatusToString(modell.getModelStatus()),
- info.objective_function_value, info.mip_gap, laufzeit,
- info.mip_node_count)
-
-
-if__name__=="__main__":
-# So ist das Unternehmen ueber zwei Jahre gewachsen.
- entwicklung = [("2024-Q1", 15, 40), ("2024-Q3", 25, 70),
- ("2025-Q1", 40, 110), ("2025-Q3", 60, 160),
- ("2026-Q1", 80, 220)]
-
-print("="*84)
-print(" WAS DAS PROTOKOLL ZEIGT - UND WAS ES ZEIGEN SOLLTE")
-print("="*84)
-print(f"Derselbe Nachtjob, unveraendert, mit {ZEITLIMIT:.0f} Sekunden Zeitlimit.\n")
-
- protokolle = []
-for zeitpunkt, lager, kunden in entwicklung:
- status, kosten, gap, laufzeit, knoten = plane_netzwerk(
- lager, kunden, ZEITLIMIT)
- protokolle.append(Laufprotokoll(zeitpunkt, lager, kunden, status,
- kosten, gap, laufzeit, knoten))
-
-print("So sieht das ueblich gefuehrte Protokoll aus:\n")
-print(f" {'Zeitpunkt':<10}{'Kosten':>12}")
-print(" "+"-"*24)
-for p in protokolle:
-print(f" {p.zeitpunkt:<10}{p.kosten:>12,.0f}")
-print("\n -> Die Kosten steigen. Das Unternehmen waechst ja auch.")
-print(" Nichts an dieser Tabelle deutet auf ein Problem hin.")
-
-print("\n"+"-"*84)
-print("So sieht ein vollstaendiges Protokoll aus:\n")
-print(f" {'Zeitpunkt':<10}{'Groesse':>10}{'Status':>20}{'Kosten':>11} "
-f"{'Gap':>8}{'Zeit':>7}")
-print(" "+"-"*80)
-for p in protokolle:
- groesse =f"{p.lager}x{p.kunden}"
-print(f" {p.zeitpunkt:<10}{groesse:>10}{p.status:>20} "
-f"{p.kosten:>11,.0f}{p.gap *100:7.2f} % {p.laufzeit:6.2f}s")
-
-print("\n"+"-"*84)
-print("Und so sehen die Warnungen aus, die daraus folgen:\n")
-for p in protokolle:
-if p.warnungen:
-print(f" {p.zeitpunkt}: "+"; ".join(p.warnungen))
-else:
-print(f" {p.zeitpunkt}: in Ordnung")
-
- letzte = protokolle[-1]
- erste_warnung =next(p for p in protokolle if p.warnungen)
-
-print("\n"+"="*84)
-print(" WAS DA PASSIERT IST")
-print("="*84)
-print(f"Seit {erste_warnung.zeitpunkt} erreicht der Job das Zeitlimit und liefert")
-print("nicht mehr das Optimum, sondern die beste bis dahin gefundene Loesung.")
-print(f"Im letzten Lauf betraegt der Abstand zum Bestmoeglichen "
-f"{letzte.gap:.1%}.")
-print()
-print("Der Job ist nicht abgestuerzt. Er hat keine Fehlermeldung erzeugt.")
-print("Die Laufzeit ist sogar bemerkenswert STABIL geblieben - genau deshalb,")
-print("weil das Zeitlimit greift. Ein Ueberwachungssystem, das auf Abstuerze")
-print("und Laufzeitspitzen achtet, sieht hier nichts.")
-print()
-print("Drei Zahlen gehoeren deshalb in jedes Protokoll eines Optimierungsjobs:")
-print(" * der STATUS - 'Optimal' oder etwas anderes?")
-print(" * der GAP - wie weit ist die Loesung vom Bestmoeglichen entfernt?")
-print(" * die LAUFZEIT im Verhaeltnis zum Limit - wie nah am Anschlag?")
-print()
-print("Die dritte ist die frueheste Warnung: Sie steigt, lange bevor der Gap")
-print("sichtbar wird, und gibt Zeit zum Handeln.")
-print("="*84)
+
#!/usr/bin/env python3
+
+# Betriebsueberwachung.py
+"""
+Kapitel Praxisfallen: Das Modell, das still aufgehoert hat zu optimieren.
+
+Ein Optimierungsmodell geht in Betrieb und laeuft jede Nacht. Es liefert
+zuverlaessig eine Zahl, die Job-Dauer bleibt konstant, es gibt keine
+Fehlermeldung. Alles sieht gut aus.
+
+Waehrenddessen waechst das Unternehmen. Aus 15 Lagern werden 40, dann 80. Das
+Zeitlimit von drei Sekunden - vor zwei Jahren grosszuegig bemessen - reicht
+irgendwann nicht mehr. Der Solver bricht ab und liefert die beste Loesung, die
+er bis dahin gefunden hat.
+
+Das ist nicht falsch. Es ist sogar richtig so. Aber es ist etwas anderes als
+das, was das Modell einmal geleistet hat - und niemand merkt es, weil im
+Protokoll nur die Kosten stehen.
+
+Dieses Programm zeigt die Verschlechterung und die Ueberwachung, die sie
+sichtbar macht.
+
+WICHTIG: ortools wird hier nicht importiert (Konflikt mit highspy, siehe
+Kapitel Oekosystem).
+
+Benoetigt: numpy, highspy
+"""
+
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass
+
+import numpy as np
+import highspy
+
+ZEITLIMIT =3.0# das Budget, das der Nachtjob hat
+GAP_WARNSCHWELLE =0.02# ab 2 % Gap wollen wir es wissen
+ZEITAUSLASTUNG_WARNSCHWELLE =0.9
+
+
+@dataclass
+class Laufprotokoll:
+"""Was nach JEDEM Produktivlauf protokolliert gehoert.
+
+ Die meisten Systeme schreiben nur 'kosten'. Genau deshalb faellt eine
+ schleichende Verschlechterung ueber Monate nicht auf.
+ """
+ zeitpunkt: str
+ lager: int
+ kunden: int
+ status: str
+ kosten: float
+ gap: float
+ laufzeit: float
+ knoten: int
+
+@property
+def warnungen(self) ->list[str]:
+ meldungen = []
+ifself.status !="Optimal":
+ meldungen.append(f"nicht beweisbar optimal ({self.status})")
+ifself.gap > GAP_WARNSCHWELLE:
+ meldungen.append(f"Gap {self.gap:.1%} ueber Schwelle "
+f"{GAP_WARNSCHWELLE:.0%}")
+ifself.laufzeit > ZEITAUSLASTUNG_WARNSCHWELLE * ZEITLIMIT:
+ meldungen.append(f"Zeitbudget zu {self.laufzeit / ZEITLIMIT:.0%} "
+f"ausgeschoepft")
+return meldungen
+
+
+def plane_netzwerk(n_lager: int, n_kunden: int, zeitlimit: float,
+ saat: int=7) ->tuple[str, float, float, float, int]:
+"""Standortplanung wie in Kapitel MILP, nur mit wachsender Groesse."""
+ rng = np.random.default_rng(saat)
+ fixkosten = rng.uniform(3000, 9000, n_lager)
+ transport = rng.uniform(5, 60, (n_lager, n_kunden))
+ bedarf = rng.uniform(10, 60, n_kunden)
+ kapazitaet = np.full(n_lager, bedarf.sum() *0.22)
+
+ modell = highspy.Highs()
+ modell.setOptionValue("output_flag", False)
+ modell.setOptionValue("time_limit", zeitlimit)
+
+ anzahl_x = n_lager * n_kunden
+ unendlich = highspy.kHighsInf
+ modell.addVars(anzahl_x, np.zeros(anzahl_x), np.full(anzahl_x, unendlich))
+ modell.addVars(n_lager, np.zeros(n_lager), np.ones(n_lager))
+for i inrange(n_lager):
+ modell.changeColIntegrality(anzahl_x + i, highspy.HighsVarType.kInteger)
+ modell.changeColCost(anzahl_x + i, fixkosten[i])
+for j inrange(n_kunden):
+ modell.changeColCost(i * n_kunden + j, transport[i, j])
+
+for j inrange(n_kunden):
+ index = np.array([i * n_kunden + j for i inrange(n_lager)], dtype=np.int32)
+ modell.addRow(bedarf[j], bedarf[j], len(index), index, np.ones(len(index)))
+for i inrange(n_lager):
+ index = np.array([i * n_kunden + j for j inrange(n_kunden)] + [anzahl_x + i],
+ dtype=np.int32)
+ werte = np.concatenate([np.ones(n_kunden), [-kapazitaet[i]]])
+ modell.addRow(-unendlich, 0.0, len(index), index, werte)
+
+ t0 = time.perf_counter()
+ modell.run()
+ laufzeit = time.perf_counter() - t0
+ info = modell.getInfo()
+return (modell.modelStatusToString(modell.getModelStatus()),
+ info.objective_function_value, info.mip_gap, laufzeit,
+ info.mip_node_count)
+
+
+if__name__=="__main__":
+# So ist das Unternehmen ueber zwei Jahre gewachsen.
+ entwicklung = [("2024-Q1", 15, 40), ("2024-Q3", 25, 70),
+ ("2025-Q1", 40, 110), ("2025-Q3", 60, 160),
+ ("2026-Q1", 80, 220)]
+
+print("="*84)
+print(" WAS DAS PROTOKOLL ZEIGT - UND WAS ES ZEIGEN SOLLTE")
+print("="*84)
+print(f"Derselbe Nachtjob, unveraendert, mit {ZEITLIMIT:.0f} Sekunden Zeitlimit.\n")
+
+ protokolle = []
+for zeitpunkt, lager, kunden in entwicklung:
+ status, kosten, gap, laufzeit, knoten = plane_netzwerk(
+ lager, kunden, ZEITLIMIT)
+ protokolle.append(Laufprotokoll(zeitpunkt, lager, kunden, status,
+ kosten, gap, laufzeit, knoten))
+
+print("So sieht das ueblich gefuehrte Protokoll aus:\n")
+print(f" {'Zeitpunkt':<10}{'Kosten':>12}")
+print(" "+"-"*24)
+for p in protokolle:
+print(f" {p.zeitpunkt:<10}{p.kosten:>12,.0f}")
+print("\n -> Die Kosten steigen. Das Unternehmen waechst ja auch.")
+print(" Nichts an dieser Tabelle deutet auf ein Problem hin.")
+
+print("\n"+"-"*84)
+print("So sieht ein vollstaendiges Protokoll aus:\n")
+print(f" {'Zeitpunkt':<10}{'Groesse':>10}{'Status':>20}{'Kosten':>11} "
+f"{'Gap':>8}{'Zeit':>7}")
+print(" "+"-"*80)
+for p in protokolle:
+ groesse =f"{p.lager}x{p.kunden}"
+print(f" {p.zeitpunkt:<10}{groesse:>10}{p.status:>20} "
+f"{p.kosten:>11,.0f}{p.gap *100:7.2f} % {p.laufzeit:6.2f}s")
+
+print("\n"+"-"*84)
+print("Und so sehen die Warnungen aus, die daraus folgen:\n")
+for p in protokolle:
+if p.warnungen:
+print(f" {p.zeitpunkt}: "+"; ".join(p.warnungen))
+else:
+print(f" {p.zeitpunkt}: in Ordnung")
+
+ letzte = protokolle[-1]
+ erste_warnung =next(p for p in protokolle if p.warnungen)
+
+print("\n"+"="*84)
+print(" WAS DA PASSIERT IST")
+print("="*84)
+print(f"Seit {erste_warnung.zeitpunkt} erreicht der Job das Zeitlimit und liefert")
+print("nicht mehr das Optimum, sondern die beste bis dahin gefundene Loesung.")
+print(f"Im letzten Lauf betraegt der Abstand zum Bestmoeglichen "
+f"{letzte.gap:.1%}.")
+print()
+print("Der Job ist nicht abgestuerzt. Er hat keine Fehlermeldung erzeugt.")
+print("Die Laufzeit ist sogar bemerkenswert STABIL geblieben - genau deshalb,")
+print("weil das Zeitlimit greift. Ein Ueberwachungssystem, das auf Abstuerze")
+print("und Laufzeitspitzen achtet, sieht hier nichts.")
+print()
+print("Drei Zahlen gehoeren deshalb in jedes Protokoll eines Optimierungsjobs:")
+print(" * der STATUS - 'Optimal' oder etwas anderes?")
+print(" * der GAP - wie weit ist die Loesung vom Bestmoeglichen entfernt?")
+print(" * die LAUFZEIT im Verhaeltnis zum Limit - wie nah am Anschlag?")
+print()
+print("Die dritte ist die frueheste Warnung: Sie steigt, lange bevor der Gap")
+print("sichtbar wird, und gibt Zeit zum Handeln.")
+print("="*84)
Erwartete Ausgabe (Zeiten hardwareabhängig):
====================================================================================
WAS DAS PROTOKOLL ZEIGT - UND WAS ES ZEIGEN SOLLTE
@@ -26264,22 +26632,22 @@ sichtbar wird, und gibt Zeit zum Handeln.
🚀 In 5 Minuten gelöst: drei Zeilen, die einen echten Fehler fangen
Die Schreinerei aus Kapitel 1, nur mit etwas knapperen Kapazitäten. Der Solver liefert einen Plan, halbe Tische gibt es nicht — also wird gerundet.
-
import numpy as np
-from scipy.optimize import linprog
-
-verbrauch_je_stueck = np.array([[3.0, 1.0], # Montagestunden je Tisch/Stuhl
- [6.0, 1.0]]) # Plattenmaterial je Tisch/Stuhl
-kapazitaet = np.array([140.0, 232.0])
-deckungsbeitrag = np.array([240.0, 60.0])
-
-loesung = linprog(-deckungsbeitrag, A_ub=verbrauch_je_stueck, b_ub=kapazitaet,
- bounds=(0, None), method="highs")
-plan = np.round(loesung.x) # "Halbe Tische gibt es nicht"
-
-verbrauch = verbrauch_je_stueck @ plan # DER TEST
-assert (verbrauch <= kapazitaet +1e-6).all(), \
-f"Plan {plan} braucht {verbrauch}, verfuegbar sind {kapazitaet}"
-print("Abnahme bestanden:", plan)
+
import numpy as np
+from scipy.optimize import linprog
+
+verbrauch_je_stueck = np.array([[3.0, 1.0], # Montagestunden je Tisch/Stuhl
+ [6.0, 1.0]]) # Plattenmaterial je Tisch/Stuhl
+kapazitaet = np.array([140.0, 232.0])
+deckungsbeitrag = np.array([240.0, 60.0])
+
+loesung = linprog(-deckungsbeitrag, A_ub=verbrauch_je_stueck, b_ub=kapazitaet,
+ bounds=(0, None), method="highs")
+plan = np.round(loesung.x) # "Halbe Tische gibt es nicht"
+
+verbrauch = verbrauch_je_stueck @ plan # DER TEST
+assert (verbrauch <= kapazitaet +1e-6).all(), \
+f"Plan {plan} braucht {verbrauch}, verfuegbar sind {kapazitaet}"
+print("Abnahme bestanden:", plan)
Ausgabe:
Traceback (most recent call last):
File "schnellstart.py", line 14, in <module>
@@ -26306,7 +26674,7 @@ AssertionError: Plan [31. 48.] braucht [141. 234.], verfuegbar sind [140. 232.]<
23.3 Warum Optimierungsmodelle schwer zu testen sind
Bei einer gewöhnlichen Funktion schreibt man den erwarteten Wert hin:
-
assert steuer(50_000) ==12_345.60
+
assert steuer(50_000) ==12_345.60
Bei „der beste Produktionsplan für 400 Aufträge” gibt es niemanden, der die Antwort unabhängig ausrechnen könnte. Gäbe es ihn, bräuchte man den Solver nicht. Damit fällt die naheliegende Testform aus — und mit ihr die Illusion, man könne Optimierungscode wie Geschäftslogik testen.
Was bleibt, sind vier Ersatzformen:
@@ -26353,315 +26721,315 @@ AssertionError: Plan [31. 48.] braucht [141. 234.], verfuegbar sind [140. 232.]<
23.4 Die Testsuite
-
#!/usr/bin/env python3
-
-# test_or_kern.py
-"""
-Kapitel Testen: Eine Testsuite fuer ein Optimierungsmodell.
-
-Das Grundproblem beim Testen von Optimierungsmodellen: Man kennt die richtige
-Antwort nicht. Bei einer Funktion `steuer(brutto)` schreibt man den erwarteten
-Wert hin. Bei "der beste Produktionsplan fuer 400 Auftraege" gibt es niemanden,
-der ihn unabhaengig ausrechnen koennte - sonst braeuchte man den Solver nicht.
-
-Deshalb testet man nicht den WERT, sondern vier andere Dinge:
-
- 1. EIGENSCHAFTEN Die Loesung haelt jede Nebenbedingung ein, und der
- ausgewiesene Zielwert passt zu den Mengen.
- 2. INVARIANTEN Was das Ergebnis NICHT aendern darf: die Reihenfolge der
- Produkte, die Waehrungseinheit, ein zusaetzliches Produkt
- mit Deckungsbeitrag 0.
- 3. REGRESSION Eine kleine Instanz mit von Hand nachgerechnetem Optimum.
- 4. FEHLERFAELLE Unsinnige Eingaben werden abgewiesen, unloesbare Modelle
- als unloesbar erkannt - und die Abnahmepruefung schlaegt
- tatsaechlich an, wenn man ihr eine kaputte Loesung gibt.
-
-Der vierte Punkt ist der wichtigste und wird am haeufigsten vergessen: Eine
-Pruefung, die noch nie etwas gefunden hat, ist keine Pruefung, sondern eine
-Vermutung. Die Tests unten fuettern `pruefe_loesung()` deshalb absichtlich mit
-falschen Loesungen und verlangen, dass sie anschlaegt.
-
-Aufruf:
- pytest test_or_kern.py -v
- python3 test_or_kern.py # ruft pytest selbst auf
-
-Benoetigt: pytest, numpy, pydantic, ortools, scipy (ueber or_kern)
-"""
-
-from __future__ import annotations
-
-import pytest
-
-from or_kern import (Loesung, Produkt, Produktionsproblem, SolverStatus,
- loese_mit_glop, loese_mit_scipy, pruefe_loesung,
- status_von_scipy)
-
-
-# --- Die Instanz, gegen die getestet wird ----------------------------------
-
-def schreinerei() -> Produktionsproblem:
-"""Der Fall aus Kapitel Einfuehrung - klein genug fuer die Handrechnung.
-
- Optimum: 30 Tische, 60 Stuehle, Deckungsbeitrag 10.800 EUR.
- Beide Ressourcen sind voll ausgelastet (3*30 + 1*60 = 150,
- 6*30 + 1*60 = 240).
- """
-return Produktionsproblem(
- produkte=[
- Produkt(name="Tisch", deckungsbeitrag=240.0,
- verbrauch={"Montagestunden": 3.0, "Plattenmaterial": 6.0}),
- Produkt(name="Stuhl", deckungsbeitrag=60.0,
- verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0}),
- ],
- kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0},
- )
-
-
-# Beide Modellbauer durchlaufen JEDEN Test. Ein Test, der nur mit einem Solver
-# laeuft, prueft die Bibliothek mit - nicht das Modell.
-MODELLBAUER = [loese_mit_glop, loese_mit_scipy]
-NAMEN = ["glop", "scipy"]
-
-
-@pytest.fixture(params=MODELLBAUER, ids=NAMEN)
-def bauer(request):
-return request.param
-
-
-# --- 1. Eigenschaften ------------------------------------------------------
-
-def test_loesung_haelt_alle_nebenbedingungen_ein(bauer):
-"""Die Abnahmepruefung darf nichts zu beanstanden haben."""
- problem = schreinerei()
- loesung = bauer(problem)
-assert loesung.status is SolverStatus.OPTIMAL
-assert pruefe_loesung(problem, loesung) == []
-
-
-def test_zielwert_passt_zu_den_mengen(bauer):
-"""Der ausgewiesene Zielwert wird unabhaengig nachgerechnet.
-
- Klingt trivial, ist es nicht: Wer die Zielfunktion im Modell anders
- zusammensetzt als im Bericht (etwa Gebuehren einmal abgezogen, einmal
- nicht), merkt es sonst nie.
- """
- problem = schreinerei()
- loesung = bauer(problem)
- nachgerechnet =sum(p.deckungsbeitrag * loesung.werte[p.name]
-for p in problem.produkte)
-assert loesung.zielwert == pytest.approx(nachgerechnet, abs=1e-6)
-
-
-def test_beide_solver_liefern_dasselbe():
-"""Der Kern der Architektur aus Kapitel Praxisfallen, als Test."""
- problem = schreinerei()
- glop, scipy_ = loese_mit_glop(problem), loese_mit_scipy(problem)
-assert glop.zielwert == pytest.approx(scipy_.zielwert, abs=1e-6)
-for ressource in problem.ressourcen:
-assert glop.schattenpreise[ressource] == pytest.approx(
- scipy_.schattenpreise[ressource], abs=1e-6)
-
-
-# --- 2. Invarianten --------------------------------------------------------
-
-def test_produktreihenfolge_aendert_nichts(bauer):
-"""Dieselben Daten in anderer Zeilenreihenfolge - gleiches Ergebnis.
-
- Das faengt die Spaltenvertauschungsfalle aus Kapitel Finanzdaten ab: ein
- Modell, das Positionen statt Namen benutzt, faellt hier durch.
- """
- problem = schreinerei()
- gedreht = Produktionsproblem(produkte=list(reversed(problem.produkte)),
- kapazitaeten=problem.kapazitaeten)
-assert bauer(problem).zielwert == pytest.approx(bauer(gedreht).zielwert)
-
-
-def test_waehrungseinheit_skaliert_linear(bauer):
-"""Deckungsbeitraege in Cent statt Euro: Zielwert mal 100, Mengen gleich.
-
- Der Test prueft nicht die Mathematik - die ist offensichtlich -, sondern
- die NUMERIK. Wer schlecht skalierte Modelle baut (Kapitel Fundament),
- bekommt hier Abweichungen weit ueber der Toleranz.
- """
- problem = schreinerei()
- in_cent = Produktionsproblem(
- produkte=[Produkt(name=p.name, deckungsbeitrag=p.deckungsbeitrag *100,
- verbrauch=p.verbrauch) for p in problem.produkte],
- kapazitaeten=problem.kapazitaeten)
- basis, skaliert = bauer(problem), bauer(in_cent)
-assert skaliert.zielwert == pytest.approx(basis.zielwert *100, rel=1e-9)
-for p in problem.produkte:
-assert skaliert.werte[p.name] == pytest.approx(basis.werte[p.name],
-abs=1e-6)
-
-
-def test_produkt_ohne_deckungsbeitrag_aendert_das_optimum_nicht(bauer):
-"""Ein Produkt, das nichts einbringt, darf den Zielwert nicht heben."""
- problem = schreinerei()
- mit_nullprodukt = Produktionsproblem(
- produkte=problem.produkte + [
- Produkt(name="Muster", deckungsbeitrag=0.0,
- verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0})],
- kapazitaeten=problem.kapazitaeten)
-assert bauer(mit_nullprodukt).zielwert <= bauer(problem).zielwert +1e-6
-
-
-# --- 3. Regression ---------------------------------------------------------
-
-def test_bekanntes_optimum(bauer):
-"""Von Hand nachgerechnet (Kapitel Einfuehrung, Handrechnung).
-
- Das ist der einzige Test, der einen ZAHLENWERT festschreibt - und er darf
- es, weil dieser Wert von Hand belegt ist. Fuer grosse Instanzen gibt es
- diesen Test nicht; dort tragen die Eigenschaften und Invarianten.
- """
- loesung = bauer(schreinerei())
-assert loesung.zielwert == pytest.approx(10_800.0, abs=1e-6)
-assert loesung.werte["Tisch"] == pytest.approx(30.0, abs=1e-6)
-assert loesung.werte["Stuhl"] == pytest.approx(60.0, abs=1e-6)
-
-
-def test_schattenpreise_bekannt(bauer):
-"""40 EUR je Montagestunde, 20 EUR je Einheit Plattenmaterial."""
- loesung = bauer(schreinerei())
-assert loesung.schattenpreise["Montagestunden"] == pytest.approx(40.0, abs=1e-6)
-assert loesung.schattenpreise["Plattenmaterial"] == pytest.approx(20.0, abs=1e-6)
-
-
-# --- 4. Fehlerfaelle: schlaegt die Pruefung ueberhaupt an? -----------------
-
-def test_pruefung_findet_kapazitaetsverletzung():
-"""Eine Loesung, die zu viel verbraucht, MUSS beanstandet werden."""
- problem = schreinerei()
- kaputt = Loesung(status=SolverStatus.OPTIMAL,
- werte={"Tisch": 50.0, "Stuhl": 60.0}, # 3*50+60 = 210 > 150
- zielwert=50*240.0+60*60.0)
- beanstandungen = pruefe_loesung(problem, kaputt)
-assertany("Montagestunden"in b for b in beanstandungen)
-
-
-def test_pruefung_findet_falschen_zielwert():
-"""Mengen und Zielwert passen nicht zusammen - der haeufigste stille Fehler."""
- problem = schreinerei()
- kaputt = Loesung(status=SolverStatus.OPTIMAL,
- werte={"Tisch": 30.0, "Stuhl": 60.0},
- zielwert=99_999.0)
-assertany("Zielwert"in b for b in pruefe_loesung(problem, kaputt))
-
-
-def test_pruefung_findet_negative_mengen():
- problem = schreinerei()
- kaputt = Loesung(status=SolverStatus.OPTIMAL,
- werte={"Tisch": -5.0, "Stuhl": 60.0},
- zielwert=-5*240.0+60*60.0)
-assertany("negativ"in b for b in pruefe_loesung(problem, kaputt))
-
-
-def test_pruefung_findet_gebrochene_ganzzahligkeit():
-"""Die Falle aus Kapitel MILP: 0,99999998 ist nicht 1."""
- problem = schreinerei()
- fast_ganz = Loesung(status=SolverStatus.OPTIMAL,
- werte={"Tisch": 29.4, "Stuhl": 61.8},
- zielwert=29.4*240.0+61.8*60.0)
- beanstandungen = pruefe_loesung(problem, fast_ganz, ganzzahlig=["Tisch"])
-assertany("ganzzahlig"in b for b in beanstandungen)
-
-
-def test_pruefung_verweigert_unbrauchbaren_status():
-"""Ohne verwertbares Ergebnis wird gar nicht erst gerechnet."""
- problem = schreinerei()
- ohne = Loesung(status=SolverStatus.UNZULAESSIG)
- beanstandungen = pruefe_loesung(problem, ohne)
-assertlen(beanstandungen) ==1and"unzulaessig"in beanstandungen[0]
-
-
-@pytest.mark.parametrize("daten, stichwort", [
- (dict(produkte=[Produkt(name="Tisch", deckungsbeitrag=240.0,
- verbrauch={"Montagestunden": 3.0})],
- kapazitaeten={"Montagestunden": 0.0}), "greater than"),
- (dict(produkte=[Produkt(name="Regal", deckungsbeitrag=130.0,
- verbrauch={"Lackieren": 2.0})],
- kapazitaeten={"Montagestunden": 150.0}), "Kapazitaetsangabe"),
- (dict(produkte=[], kapazitaeten={"Montagestunden": 150.0}), "at least 1"),
-])
-def test_unsinnige_eingaben_werden_beim_einlesen_abgewiesen(daten, stichwort):
-"""Drei Fehler, die NICHT erst beim Loesen auffallen duerfen."""
-with pytest.raises(Exception) as fehler:
- Produktionsproblem(**daten)
-assert stichwort instr(fehler.value)
-
-
-def test_doppelter_produktname_wird_abgewiesen():
- produkt = Produkt(name="Tisch", deckungsbeitrag=240.0,
- verbrauch={"Montagestunden": 3.0})
-with pytest.raises(Exception) as fehler:
- Produktionsproblem(produkte=[produkt, produkt],
- kapazitaeten={"Montagestunden": 150.0})
-assert"mehrfach"instr(fehler.value)
-
-
-@pytest.mark.parametrize("rohstatus, erwartet", [
- (0, SolverStatus.OPTIMAL),
- (1, SolverStatus.ZEITLIMIT),
- (2, SolverStatus.UNZULAESSIG),
- (3, SolverStatus.UNBESCHRAENKT),
- (4, SolverStatus.FEHLERHAFT),
- (99, SolverStatus.UNBEKANNT),
-])
-def test_statusuebersetzung_scipy(rohstatus, erwartet):
-"""Die Uebersetzertabelle selbst - inklusive des unbekannten Falls."""
-class Ergebnis:
- status = rohstatus
-assert status_von_scipy(Ergebnis()) is erwartet
-
-
-def test_status_eigenschaften_sind_konsistent():
-"""brauchbar und modellfehler duerfen sich nie ueberschneiden."""
-for status in SolverStatus:
-assertnot (status.brauchbar and status.modellfehler)
-
-
-# Die beiden folgenden Tests gab es zuerst nicht. Sie sind entstanden, weil
-# Mutationstest.py zwei eingebaute Fehler UEBERLEBEN liess - siehe den
-# Abschnitt "Wer testet die Tests?".
-
-def test_nur_optimal_und_zulaessig_gelten_als_brauchbar():
-"""ZEITLIMIT ist NICHT brauchbar - der Solver hat nichts gefunden.
-
- Der Unterschied entscheidet, ob ein Nachtjob einen Plan ausliefert oder
- Alarm schlaegt. Ein aufgeweichtes 'brauchbar' faellt sonst nirgends auf.
- """
-assert SolverStatus.OPTIMAL.brauchbar
-assert SolverStatus.ZULAESSIG.brauchbar
-for status in (SolverStatus.ZEITLIMIT, SolverStatus.UNZULAESSIG,
- SolverStatus.UNBESCHRAENKT, SolverStatus.FEHLERHAFT,
- SolverStatus.UNBEKANNT):
-assertnot status.brauchbar, f"{status.value} darf nicht brauchbar sein"
-
-
-def test_ressourcenreihenfolge_haengt_nicht_an_der_eingabe():
-"""Zwei Mappings mit gleichem Inhalt, andere Einfuegereihenfolge.
-
- Die Verbrauchsmatrix muss zeilenweise identisch sein. Ohne diese
- Zusicherung passt die Matrix irgendwann nicht mehr zum
- Kapazitaetsvektor - die Vertauschungsfalle aus Kapitel Finanzdaten,
- nur eine Ebene tiefer.
- """
- produkte = schreinerei().produkte
- vorwaerts = Produktionsproblem(
- produkte=produkte,
- kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0})
- rueckwaerts = Produktionsproblem(
- produkte=produkte,
- kapazitaeten={"Plattenmaterial": 240.0, "Montagestunden": 150.0})
-assert vorwaerts.ressourcen == rueckwaerts.ressourcen
-assert (vorwaerts.verbrauchsmatrix() == rueckwaerts.verbrauchsmatrix()).all()
-assert (vorwaerts.kapazitaetsvektor() == rueckwaerts.kapazitaetsvektor()).all()
-
-
-if__name__=="__main__":
-import sys
- sys.exit(pytest.main([__file__, "-v", "--tb=short", "-p", "no:cacheprovider"]))
+
#!/usr/bin/env python3
+
+# test_or_kern.py
+"""
+Kapitel Testen: Eine Testsuite fuer ein Optimierungsmodell.
+
+Das Grundproblem beim Testen von Optimierungsmodellen: Man kennt die richtige
+Antwort nicht. Bei einer Funktion `steuer(brutto)` schreibt man den erwarteten
+Wert hin. Bei "der beste Produktionsplan fuer 400 Auftraege" gibt es niemanden,
+der ihn unabhaengig ausrechnen koennte - sonst braeuchte man den Solver nicht.
+
+Deshalb testet man nicht den WERT, sondern vier andere Dinge:
+
+ 1. EIGENSCHAFTEN Die Loesung haelt jede Nebenbedingung ein, und der
+ ausgewiesene Zielwert passt zu den Mengen.
+ 2. INVARIANTEN Was das Ergebnis NICHT aendern darf: die Reihenfolge der
+ Produkte, die Waehrungseinheit, ein zusaetzliches Produkt
+ mit Deckungsbeitrag 0.
+ 3. REGRESSION Eine kleine Instanz mit von Hand nachgerechnetem Optimum.
+ 4. FEHLERFAELLE Unsinnige Eingaben werden abgewiesen, unloesbare Modelle
+ als unloesbar erkannt - und die Abnahmepruefung schlaegt
+ tatsaechlich an, wenn man ihr eine kaputte Loesung gibt.
+
+Der vierte Punkt ist der wichtigste und wird am haeufigsten vergessen: Eine
+Pruefung, die noch nie etwas gefunden hat, ist keine Pruefung, sondern eine
+Vermutung. Die Tests unten fuettern `pruefe_loesung()` deshalb absichtlich mit
+falschen Loesungen und verlangen, dass sie anschlaegt.
+
+Aufruf:
+ pytest test_or_kern.py -v
+ python3 test_or_kern.py # ruft pytest selbst auf
+
+Benoetigt: pytest, numpy, pydantic, ortools, scipy (ueber or_kern)
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from or_kern import (Loesung, Produkt, Produktionsproblem, SolverStatus,
+ loese_mit_glop, loese_mit_scipy, pruefe_loesung,
+ status_von_scipy)
+
+
+# --- Die Instanz, gegen die getestet wird ----------------------------------
+
+def schreinerei() -> Produktionsproblem:
+"""Der Fall aus Kapitel Einfuehrung - klein genug fuer die Handrechnung.
+
+ Optimum: 30 Tische, 60 Stuehle, Deckungsbeitrag 10.800 EUR.
+ Beide Ressourcen sind voll ausgelastet (3*30 + 1*60 = 150,
+ 6*30 + 1*60 = 240).
+ """
+return Produktionsproblem(
+ produkte=[
+ Produkt(name="Tisch", deckungsbeitrag=240.0,
+ verbrauch={"Montagestunden": 3.0, "Plattenmaterial": 6.0}),
+ Produkt(name="Stuhl", deckungsbeitrag=60.0,
+ verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0}),
+ ],
+ kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0},
+ )
+
+
+# Beide Modellbauer durchlaufen JEDEN Test. Ein Test, der nur mit einem Solver
+# laeuft, prueft die Bibliothek mit - nicht das Modell.
+MODELLBAUER = [loese_mit_glop, loese_mit_scipy]
+NAMEN = ["glop", "scipy"]
+
+
+@pytest.fixture(params=MODELLBAUER, ids=NAMEN)
+def bauer(request):
+return request.param
+
+
+# --- 1. Eigenschaften ------------------------------------------------------
+
+def test_loesung_haelt_alle_nebenbedingungen_ein(bauer):
+"""Die Abnahmepruefung darf nichts zu beanstanden haben."""
+ problem = schreinerei()
+ loesung = bauer(problem)
+assert loesung.status is SolverStatus.OPTIMAL
+assert pruefe_loesung(problem, loesung) == []
+
+
+def test_zielwert_passt_zu_den_mengen(bauer):
+"""Der ausgewiesene Zielwert wird unabhaengig nachgerechnet.
+
+ Klingt trivial, ist es nicht: Wer die Zielfunktion im Modell anders
+ zusammensetzt als im Bericht (etwa Gebuehren einmal abgezogen, einmal
+ nicht), merkt es sonst nie.
+ """
+ problem = schreinerei()
+ loesung = bauer(problem)
+ nachgerechnet =sum(p.deckungsbeitrag * loesung.werte[p.name]
+for p in problem.produkte)
+assert loesung.zielwert == pytest.approx(nachgerechnet, abs=1e-6)
+
+
+def test_beide_solver_liefern_dasselbe():
+"""Der Kern der Architektur aus Kapitel Praxisfallen, als Test."""
+ problem = schreinerei()
+ glop, scipy_ = loese_mit_glop(problem), loese_mit_scipy(problem)
+assert glop.zielwert == pytest.approx(scipy_.zielwert, abs=1e-6)
+for ressource in problem.ressourcen:
+assert glop.schattenpreise[ressource] == pytest.approx(
+ scipy_.schattenpreise[ressource], abs=1e-6)
+
+
+# --- 2. Invarianten --------------------------------------------------------
+
+def test_produktreihenfolge_aendert_nichts(bauer):
+"""Dieselben Daten in anderer Zeilenreihenfolge - gleiches Ergebnis.
+
+ Das faengt die Spaltenvertauschungsfalle aus Kapitel Finanzdaten ab: ein
+ Modell, das Positionen statt Namen benutzt, faellt hier durch.
+ """
+ problem = schreinerei()
+ gedreht = Produktionsproblem(produkte=list(reversed(problem.produkte)),
+ kapazitaeten=problem.kapazitaeten)
+assert bauer(problem).zielwert == pytest.approx(bauer(gedreht).zielwert)
+
+
+def test_waehrungseinheit_skaliert_linear(bauer):
+"""Deckungsbeitraege in Cent statt Euro: Zielwert mal 100, Mengen gleich.
+
+ Der Test prueft nicht die Mathematik - die ist offensichtlich -, sondern
+ die NUMERIK. Wer schlecht skalierte Modelle baut (Kapitel Fundament),
+ bekommt hier Abweichungen weit ueber der Toleranz.
+ """
+ problem = schreinerei()
+ in_cent = Produktionsproblem(
+ produkte=[Produkt(name=p.name, deckungsbeitrag=p.deckungsbeitrag *100,
+ verbrauch=p.verbrauch) for p in problem.produkte],
+ kapazitaeten=problem.kapazitaeten)
+ basis, skaliert = bauer(problem), bauer(in_cent)
+assert skaliert.zielwert == pytest.approx(basis.zielwert *100, rel=1e-9)
+for p in problem.produkte:
+assert skaliert.werte[p.name] == pytest.approx(basis.werte[p.name],
+abs=1e-6)
+
+
+def test_produkt_ohne_deckungsbeitrag_aendert_das_optimum_nicht(bauer):
+"""Ein Produkt, das nichts einbringt, darf den Zielwert nicht heben."""
+ problem = schreinerei()
+ mit_nullprodukt = Produktionsproblem(
+ produkte=problem.produkte + [
+ Produkt(name="Muster", deckungsbeitrag=0.0,
+ verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0})],
+ kapazitaeten=problem.kapazitaeten)
+assert bauer(mit_nullprodukt).zielwert <= bauer(problem).zielwert +1e-6
+
+
+# --- 3. Regression ---------------------------------------------------------
+
+def test_bekanntes_optimum(bauer):
+"""Von Hand nachgerechnet (Kapitel Einfuehrung, Handrechnung).
+
+ Das ist der einzige Test, der einen ZAHLENWERT festschreibt - und er darf
+ es, weil dieser Wert von Hand belegt ist. Fuer grosse Instanzen gibt es
+ diesen Test nicht; dort tragen die Eigenschaften und Invarianten.
+ """
+ loesung = bauer(schreinerei())
+assert loesung.zielwert == pytest.approx(10_800.0, abs=1e-6)
+assert loesung.werte["Tisch"] == pytest.approx(30.0, abs=1e-6)
+assert loesung.werte["Stuhl"] == pytest.approx(60.0, abs=1e-6)
+
+
+def test_schattenpreise_bekannt(bauer):
+"""40 EUR je Montagestunde, 20 EUR je Einheit Plattenmaterial."""
+ loesung = bauer(schreinerei())
+assert loesung.schattenpreise["Montagestunden"] == pytest.approx(40.0, abs=1e-6)
+assert loesung.schattenpreise["Plattenmaterial"] == pytest.approx(20.0, abs=1e-6)
+
+
+# --- 4. Fehlerfaelle: schlaegt die Pruefung ueberhaupt an? -----------------
+
+def test_pruefung_findet_kapazitaetsverletzung():
+"""Eine Loesung, die zu viel verbraucht, MUSS beanstandet werden."""
+ problem = schreinerei()
+ kaputt = Loesung(status=SolverStatus.OPTIMAL,
+ werte={"Tisch": 50.0, "Stuhl": 60.0}, # 3*50+60 = 210 > 150
+ zielwert=50*240.0+60*60.0)
+ beanstandungen = pruefe_loesung(problem, kaputt)
+assertany("Montagestunden"in b for b in beanstandungen)
+
+
+def test_pruefung_findet_falschen_zielwert():
+"""Mengen und Zielwert passen nicht zusammen - der haeufigste stille Fehler."""
+ problem = schreinerei()
+ kaputt = Loesung(status=SolverStatus.OPTIMAL,
+ werte={"Tisch": 30.0, "Stuhl": 60.0},
+ zielwert=99_999.0)
+assertany("Zielwert"in b for b in pruefe_loesung(problem, kaputt))
+
+
+def test_pruefung_findet_negative_mengen():
+ problem = schreinerei()
+ kaputt = Loesung(status=SolverStatus.OPTIMAL,
+ werte={"Tisch": -5.0, "Stuhl": 60.0},
+ zielwert=-5*240.0+60*60.0)
+assertany("negativ"in b for b in pruefe_loesung(problem, kaputt))
+
+
+def test_pruefung_findet_gebrochene_ganzzahligkeit():
+"""Die Falle aus Kapitel MILP: 0,99999998 ist nicht 1."""
+ problem = schreinerei()
+ fast_ganz = Loesung(status=SolverStatus.OPTIMAL,
+ werte={"Tisch": 29.4, "Stuhl": 61.8},
+ zielwert=29.4*240.0+61.8*60.0)
+ beanstandungen = pruefe_loesung(problem, fast_ganz, ganzzahlig=["Tisch"])
+assertany("ganzzahlig"in b for b in beanstandungen)
+
+
+def test_pruefung_verweigert_unbrauchbaren_status():
+"""Ohne verwertbares Ergebnis wird gar nicht erst gerechnet."""
+ problem = schreinerei()
+ ohne = Loesung(status=SolverStatus.UNZULAESSIG)
+ beanstandungen = pruefe_loesung(problem, ohne)
+assertlen(beanstandungen) ==1and"unzulaessig"in beanstandungen[0]
+
+
+@pytest.mark.parametrize("daten, stichwort", [
+ (dict(produkte=[Produkt(name="Tisch", deckungsbeitrag=240.0,
+ verbrauch={"Montagestunden": 3.0})],
+ kapazitaeten={"Montagestunden": 0.0}), "greater than"),
+ (dict(produkte=[Produkt(name="Regal", deckungsbeitrag=130.0,
+ verbrauch={"Lackieren": 2.0})],
+ kapazitaeten={"Montagestunden": 150.0}), "Kapazitaetsangabe"),
+ (dict(produkte=[], kapazitaeten={"Montagestunden": 150.0}), "at least 1"),
+])
+def test_unsinnige_eingaben_werden_beim_einlesen_abgewiesen(daten, stichwort):
+"""Drei Fehler, die NICHT erst beim Loesen auffallen duerfen."""
+with pytest.raises(Exception) as fehler:
+ Produktionsproblem(**daten)
+assert stichwort instr(fehler.value)
+
+
+def test_doppelter_produktname_wird_abgewiesen():
+ produkt = Produkt(name="Tisch", deckungsbeitrag=240.0,
+ verbrauch={"Montagestunden": 3.0})
+with pytest.raises(Exception) as fehler:
+ Produktionsproblem(produkte=[produkt, produkt],
+ kapazitaeten={"Montagestunden": 150.0})
+assert"mehrfach"instr(fehler.value)
+
+
+@pytest.mark.parametrize("rohstatus, erwartet", [
+ (0, SolverStatus.OPTIMAL),
+ (1, SolverStatus.ZEITLIMIT),
+ (2, SolverStatus.UNZULAESSIG),
+ (3, SolverStatus.UNBESCHRAENKT),
+ (4, SolverStatus.FEHLERHAFT),
+ (99, SolverStatus.UNBEKANNT),
+])
+def test_statusuebersetzung_scipy(rohstatus, erwartet):
+"""Die Uebersetzertabelle selbst - inklusive des unbekannten Falls."""
+class Ergebnis:
+ status = rohstatus
+assert status_von_scipy(Ergebnis()) is erwartet
+
+
+def test_status_eigenschaften_sind_konsistent():
+"""brauchbar und modellfehler duerfen sich nie ueberschneiden."""
+for status in SolverStatus:
+assertnot (status.brauchbar and status.modellfehler)
+
+
+# Die beiden folgenden Tests gab es zuerst nicht. Sie sind entstanden, weil
+# Mutationstest.py zwei eingebaute Fehler UEBERLEBEN liess - siehe den
+# Abschnitt "Wer testet die Tests?".
+
+def test_nur_optimal_und_zulaessig_gelten_als_brauchbar():
+"""ZEITLIMIT ist NICHT brauchbar - der Solver hat nichts gefunden.
+
+ Der Unterschied entscheidet, ob ein Nachtjob einen Plan ausliefert oder
+ Alarm schlaegt. Ein aufgeweichtes 'brauchbar' faellt sonst nirgends auf.
+ """
+assert SolverStatus.OPTIMAL.brauchbar
+assert SolverStatus.ZULAESSIG.brauchbar
+for status in (SolverStatus.ZEITLIMIT, SolverStatus.UNZULAESSIG,
+ SolverStatus.UNBESCHRAENKT, SolverStatus.FEHLERHAFT,
+ SolverStatus.UNBEKANNT):
+assertnot status.brauchbar, f"{status.value} darf nicht brauchbar sein"
+
+
+def test_ressourcenreihenfolge_haengt_nicht_an_der_eingabe():
+"""Zwei Mappings mit gleichem Inhalt, andere Einfuegereihenfolge.
+
+ Die Verbrauchsmatrix muss zeilenweise identisch sein. Ohne diese
+ Zusicherung passt die Matrix irgendwann nicht mehr zum
+ Kapazitaetsvektor - die Vertauschungsfalle aus Kapitel Finanzdaten,
+ nur eine Ebene tiefer.
+ """
+ produkte = schreinerei().produkte
+ vorwaerts = Produktionsproblem(
+ produkte=produkte,
+ kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0})
+ rueckwaerts = Produktionsproblem(
+ produkte=produkte,
+ kapazitaeten={"Plattenmaterial": 240.0, "Montagestunden": 150.0})
+assert vorwaerts.ressourcen == rueckwaerts.ressourcen
+assert (vorwaerts.verbrauchsmatrix() == rueckwaerts.verbrauchsmatrix()).all()
+assert (vorwaerts.kapazitaetsvektor() == rueckwaerts.kapazitaetsvektor()).all()
+
+
+if__name__=="__main__":
+import sys
+ sys.exit(pytest.main([__file__, "-v", "--tb=short", "-p", "no:cacheprovider"]))
💻 Code-Durchgang
@@ -26738,185 +27106,185 @@ AssertionError: Plan [31. 48.] braucht [141. 234.], verfuegbar sind [140. 232.]<
-
#!/usr/bin/env python3
-
-# Mutationstest.py
-"""
-Kapitel Testen: Wer testet die Tests?
-
-`test_or_kern.py` besteht aus 31 Tests, und alle sind gruen. Das ist noch kein
-Ergebnis. Eine Testsuite, die nie rot war, ist keine Pruefung, sondern eine
-Vermutung - sie koennte aus lauter `assert True` bestehen und saehe genauso aus.
-
-Dieses Programm prueft die Pruefung. Es baut nacheinander KLEINE, gezielte
-Fehler in `or_kern.py` ein - jeder einzelne ist ein Fehler, den man beim
-Programmieren tatsaechlich macht - und laesst die Testsuite darauf los. Fuer
-jede Mutation gibt es genau zwei moegliche Ausgaenge:
-
- GETOETET Mindestens ein Test wird rot. Die Suite haette diesen Fehler
- in der Praxis gefunden.
- UEBERLEBT Alle Tests bleiben gruen. Die Suite haette diesen Fehler
- DURCHGEHEN LASSEN - hier fehlt ein Test.
-
-Ueberlebende Mutationen sind das eigentliche Ergebnis: Sie zeigen die Luecken,
-und zwar ohne dass man sie erraten muss.
-
-Das Verfahren heisst Mutationstest und ist alt; neu ist nur, wie gut es zu
-Optimierungsmodellen passt. Dort kann man den Zielwert meist nicht gegen einen
-bekannten Sollwert pruefen - also weiss man ohne so ein Experiment nicht, ob
-die Ersatzpruefungen (Eigenschaften, Invarianten) ueberhaupt greifen.
-
-WICHTIG: Es wird nichts veraendert. Jede Mutation laeuft in einem eigenen
-temporaeren Verzeichnis mit einer Kopie von or_kern.py.
-
-Benoetigt: pytest; im selben Verzeichnis or_kern.py und test_or_kern.py
-"""
-
-from __future__ import annotations
-
-import os
-import shutil
-import subprocess
-import sys
-import tempfile
-
-# (Name, gesuchter Text, Ersatz) - jede Zeile ein realistischer Fehler.
-MUTATIONEN = [
- ("Nebenbedingung umgedreht (<= wird >=)",
-"<= problem.kapazitaeten[ressource], name=ressource)",
-">= problem.kapazitaeten[ressource], name=ressource)"),
-
- ("Maximierung wird Minimierung",
-"solver.Maximize(sum(menge[p.name] * p.deckungsbeitrag",
-"solver.Minimize(sum(menge[p.name] * p.deckungsbeitrag"),
-
- ("Vorzeichen der Dualwerte vergessen",
-"schattenpreise={r: float(-m) for r, m in",
-"schattenpreise={r: float(m) for r, m in"),
-
- ("Abnahmepruefung aufgeweicht (Toleranz 1e-6 -> 1.0)",
-"toleranz: float = 1e-6) -> list[str]:",
-"toleranz: float = 1.0) -> list[str]:"),
-
- ("Kapazitaetspruefung uebersprungen",
-"if ist > grenze + toleranz:",
-"if False:"),
-
- ("Pruefung auf doppelte Produktnamen entfernt",
-"if len(set(namen)) != len(namen):",
-"if False:"),
-
- ("Ganzzahligkeitspruefung entfernt",
-"if abs(wert - round(wert)) > toleranz:",
-"if False:"),
-
- ("Zielwertabgleich entfernt",
-"if abs(nachgerechnet - loesung.zielwert) > toleranz * max(1.0, abs(nachgerechnet)):",
-"if False:"),
-
- ("ZEITLIMIT gilt faelschlich als brauchbar",
-"return self in (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG)",
-"return self in (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG,\n"
-" SolverStatus.ZEITLIMIT)"),
-
- ("Ressourcenreihenfolge nicht mehr stabil",
-"return sorted(self.kapazitaeten)",
-"return list(self.kapazitaeten)"),
-]
-
-
-def fuehre_suite_aus(verzeichnis: str) ->tuple[int, int, str]:
-"""Laesst pytest im Verzeichnis laufen; liefert (bestanden, fehlgeschlagen)."""
- ergebnis = subprocess.run(
- [sys.executable, "-m", "pytest", "test_or_kern.py", "-q",
-"--tb=no", "-p", "no:cacheprovider"],
- cwd=verzeichnis, capture_output=True, text=True, timeout=600)
- letzte = [z for z in ergebnis.stdout.strip().splitlines() if z.strip()]
- zeile = letzte[-1] if letzte else""
- bestanden = fehlgeschlagen =0
-for teil in zeile.replace("=", " ").split(","):
-for wort in teil.split():
-if wort.isdigit():
- zahl =int(wort)
-elif wort.startswith("passed"):
- bestanden = zahl
-elif wort.startswith("failed") or wort.startswith("error"):
- fehlgeschlagen += zahl
-return bestanden, fehlgeschlagen, zeile
-
-
-def mutiere_und_pruefe(quelle: str, ziel: str, suche: str,
- ersatz: str) ->tuple[int, int, str] |None:
-"""Legt eine mutierte Kopie an und laesst die Suite darauf laufen."""
-with tempfile.TemporaryDirectory() as verzeichnis:
- text =open(quelle, encoding="utf-8").read()
-if text.count(suche) !=1:
-returnNone# Muster passt nicht (mehr)
-open(os.path.join(verzeichnis, "or_kern.py"), "w",
- encoding="utf-8").write(text.replace(suche, ersatz))
- shutil.copy(ziel, verzeichnis)
-return fuehre_suite_aus(verzeichnis)
-
-
-if__name__=="__main__":
- hier = os.path.dirname(os.path.abspath(__file__))
- quelle = os.path.join(hier, "or_kern.py")
- suite = os.path.join(hier, "test_or_kern.py")
-for pfad in (quelle, suite):
-ifnot os.path.exists(pfad):
-raiseSystemExit(f"Nicht gefunden: {pfad}\n"
-f"Beide Dateien muessen im selben Verzeichnis liegen.")
-
-print("="*84)
-print(" MUTATIONSTEST: WUERDE DIE SUITE DIESE FEHLER FINDEN?")
-print("="*84)
-
-with tempfile.TemporaryDirectory() as v:
- shutil.copy(quelle, v)
- shutil.copy(suite, v)
- bestanden, fehlgeschlagen, _ = fuehre_suite_aus(v)
-print(f"Ausgangslage: {bestanden} Tests, {fehlgeschlagen} rot.\n")
-if fehlgeschlagen:
-raiseSystemExit("Die Suite ist schon ohne Mutation rot - erst das reparieren.")
-
-print(f"{'eingebauter Fehler':<48}{'rote Tests':>11}{'Urteil':>12}")
-print("-"*84)
-
- getoetet, ueberlebt = [], []
-for name, suche, ersatz in MUTATIONEN:
- ergebnis = mutiere_und_pruefe(quelle, suite, suche, ersatz)
-if ergebnis isNone:
-print(f"{name:<48}{'-':>11}{'nicht anwendbar':>12}")
-continue
- _, rot, _ = ergebnis
- urteil ="GETOETET"if rot else"UEBERLEBT"
- (getoetet if rot else ueberlebt).append(name)
-print(f"{name:<48}{rot:>11}{urteil:>12}")
-
-print("-"*84)
- quote =len(getoetet) /max(1, len(getoetet) +len(ueberlebt)) *100
-print(f"Getoetet: {len(getoetet)} von {len(getoetet) +len(ueberlebt)} "
-f"({quote:.0f} %)")
-
-print("\n"+"="*84)
-print(" WAS DAS ERGEBNIS BEDEUTET")
-print("="*84)
-if ueberlebt:
-print("Diese Fehler haette die Suite DURCHGEHEN LASSEN:\n")
-for name in ueberlebt:
-print(f" * {name}")
-print("\nJeder ueberlebende Eintrag ist eine Testluecke - und zwar eine")
-print("gefundene, keine vermutete. Das ist der ganze Zweck des Verfahrens:")
-print("Es sagt einem, welchen Test man als naechstes schreiben sollte,")
-print("statt dass man raten muss.")
-else:
-print("Alle eingebauten Fehler wurden gefunden. Das heisst nicht, dass die")
-print("Suite vollstaendig ist - nur, dass sie diese zehn Fehler faengt.")
-print()
-print("Die Quote selbst ist keine Kennzahl fuer ein Dashboard. Zehn von Hand")
-print("gewaehlte Mutationen sind keine Stichprobe aus der Menge aller")
-print("moeglichen Fehler. Was zaehlt, ist die LISTE der Ueberlebenden.")
-print("="*84)
+
#!/usr/bin/env python3
+
+# Mutationstest.py
+"""
+Kapitel Testen: Wer testet die Tests?
+
+`test_or_kern.py` besteht aus 31 Tests, und alle sind gruen. Das ist noch kein
+Ergebnis. Eine Testsuite, die nie rot war, ist keine Pruefung, sondern eine
+Vermutung - sie koennte aus lauter `assert True` bestehen und saehe genauso aus.
+
+Dieses Programm prueft die Pruefung. Es baut nacheinander KLEINE, gezielte
+Fehler in `or_kern.py` ein - jeder einzelne ist ein Fehler, den man beim
+Programmieren tatsaechlich macht - und laesst die Testsuite darauf los. Fuer
+jede Mutation gibt es genau zwei moegliche Ausgaenge:
+
+ GETOETET Mindestens ein Test wird rot. Die Suite haette diesen Fehler
+ in der Praxis gefunden.
+ UEBERLEBT Alle Tests bleiben gruen. Die Suite haette diesen Fehler
+ DURCHGEHEN LASSEN - hier fehlt ein Test.
+
+Ueberlebende Mutationen sind das eigentliche Ergebnis: Sie zeigen die Luecken,
+und zwar ohne dass man sie erraten muss.
+
+Das Verfahren heisst Mutationstest und ist alt; neu ist nur, wie gut es zu
+Optimierungsmodellen passt. Dort kann man den Zielwert meist nicht gegen einen
+bekannten Sollwert pruefen - also weiss man ohne so ein Experiment nicht, ob
+die Ersatzpruefungen (Eigenschaften, Invarianten) ueberhaupt greifen.
+
+WICHTIG: Es wird nichts veraendert. Jede Mutation laeuft in einem eigenen
+temporaeren Verzeichnis mit einer Kopie von or_kern.py.
+
+Benoetigt: pytest; im selben Verzeichnis or_kern.py und test_or_kern.py
+"""
+
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+
+# (Name, gesuchter Text, Ersatz) - jede Zeile ein realistischer Fehler.
+MUTATIONEN = [
+ ("Nebenbedingung umgedreht (<= wird >=)",
+"<= problem.kapazitaeten[ressource], name=ressource)",
+">= problem.kapazitaeten[ressource], name=ressource)"),
+
+ ("Maximierung wird Minimierung",
+"solver.Maximize(sum(menge[p.name] * p.deckungsbeitrag",
+"solver.Minimize(sum(menge[p.name] * p.deckungsbeitrag"),
+
+ ("Vorzeichen der Dualwerte vergessen",
+"schattenpreise={r: float(-m) for r, m in",
+"schattenpreise={r: float(m) for r, m in"),
+
+ ("Abnahmepruefung aufgeweicht (Toleranz 1e-6 -> 1.0)",
+"toleranz: float = 1e-6) -> list[str]:",
+"toleranz: float = 1.0) -> list[str]:"),
+
+ ("Kapazitaetspruefung uebersprungen",
+"if ist > grenze + toleranz:",
+"if False:"),
+
+ ("Pruefung auf doppelte Produktnamen entfernt",
+"if len(set(namen)) != len(namen):",
+"if False:"),
+
+ ("Ganzzahligkeitspruefung entfernt",
+"if abs(wert - round(wert)) > toleranz:",
+"if False:"),
+
+ ("Zielwertabgleich entfernt",
+"if abs(nachgerechnet - loesung.zielwert) > toleranz * max(1.0, abs(nachgerechnet)):",
+"if False:"),
+
+ ("ZEITLIMIT gilt faelschlich als brauchbar",
+"return self in (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG)",
+"return self in (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG,\n"
+" SolverStatus.ZEITLIMIT)"),
+
+ ("Ressourcenreihenfolge nicht mehr stabil",
+"return sorted(self.kapazitaeten)",
+"return list(self.kapazitaeten)"),
+]
+
+
+def fuehre_suite_aus(verzeichnis: str) ->tuple[int, int, str]:
+"""Laesst pytest im Verzeichnis laufen; liefert (bestanden, fehlgeschlagen)."""
+ ergebnis = subprocess.run(
+ [sys.executable, "-m", "pytest", "test_or_kern.py", "-q",
+"--tb=no", "-p", "no:cacheprovider"],
+ cwd=verzeichnis, capture_output=True, text=True, timeout=600)
+ letzte = [z for z in ergebnis.stdout.strip().splitlines() if z.strip()]
+ zeile = letzte[-1] if letzte else""
+ bestanden = fehlgeschlagen =0
+for teil in zeile.replace("=", " ").split(","):
+for wort in teil.split():
+if wort.isdigit():
+ zahl =int(wort)
+elif wort.startswith("passed"):
+ bestanden = zahl
+elif wort.startswith("failed") or wort.startswith("error"):
+ fehlgeschlagen += zahl
+return bestanden, fehlgeschlagen, zeile
+
+
+def mutiere_und_pruefe(quelle: str, ziel: str, suche: str,
+ ersatz: str) ->tuple[int, int, str] |None:
+"""Legt eine mutierte Kopie an und laesst die Suite darauf laufen."""
+with tempfile.TemporaryDirectory() as verzeichnis:
+ text =open(quelle, encoding="utf-8").read()
+if text.count(suche) !=1:
+returnNone# Muster passt nicht (mehr)
+open(os.path.join(verzeichnis, "or_kern.py"), "w",
+ encoding="utf-8").write(text.replace(suche, ersatz))
+ shutil.copy(ziel, verzeichnis)
+return fuehre_suite_aus(verzeichnis)
+
+
+if__name__=="__main__":
+ hier = os.path.dirname(os.path.abspath(__file__))
+ quelle = os.path.join(hier, "or_kern.py")
+ suite = os.path.join(hier, "test_or_kern.py")
+for pfad in (quelle, suite):
+ifnot os.path.exists(pfad):
+raiseSystemExit(f"Nicht gefunden: {pfad}\n"
+f"Beide Dateien muessen im selben Verzeichnis liegen.")
+
+print("="*84)
+print(" MUTATIONSTEST: WUERDE DIE SUITE DIESE FEHLER FINDEN?")
+print("="*84)
+
+with tempfile.TemporaryDirectory() as v:
+ shutil.copy(quelle, v)
+ shutil.copy(suite, v)
+ bestanden, fehlgeschlagen, _ = fuehre_suite_aus(v)
+print(f"Ausgangslage: {bestanden} Tests, {fehlgeschlagen} rot.\n")
+if fehlgeschlagen:
+raiseSystemExit("Die Suite ist schon ohne Mutation rot - erst das reparieren.")
+
+print(f"{'eingebauter Fehler':<48}{'rote Tests':>11}{'Urteil':>12}")
+print("-"*84)
+
+ getoetet, ueberlebt = [], []
+for name, suche, ersatz in MUTATIONEN:
+ ergebnis = mutiere_und_pruefe(quelle, suite, suche, ersatz)
+if ergebnis isNone:
+print(f"{name:<48}{'-':>11}{'nicht anwendbar':>12}")
+continue
+ _, rot, _ = ergebnis
+ urteil ="GETOETET"if rot else"UEBERLEBT"
+ (getoetet if rot else ueberlebt).append(name)
+print(f"{name:<48}{rot:>11}{urteil:>12}")
+
+print("-"*84)
+ quote =len(getoetet) /max(1, len(getoetet) +len(ueberlebt)) *100
+print(f"Getoetet: {len(getoetet)} von {len(getoetet) +len(ueberlebt)} "
+f"({quote:.0f} %)")
+
+print("\n"+"="*84)
+print(" WAS DAS ERGEBNIS BEDEUTET")
+print("="*84)
+if ueberlebt:
+print("Diese Fehler haette die Suite DURCHGEHEN LASSEN:\n")
+for name in ueberlebt:
+print(f" * {name}")
+print("\nJeder ueberlebende Eintrag ist eine Testluecke - und zwar eine")
+print("gefundene, keine vermutete. Das ist der ganze Zweck des Verfahrens:")
+print("Es sagt einem, welchen Test man als naechstes schreiben sollte,")
+print("statt dass man raten muss.")
+else:
+print("Alle eingebauten Fehler wurden gefunden. Das heisst nicht, dass die")
+print("Suite vollstaendig ist - nur, dass sie diese zehn Fehler faengt.")
+print()
+print("Die Quote selbst ist keine Kennzahl fuer ein Dashboard. Zehn von Hand")
+print("gewaehlte Mutationen sind keine Stichprobe aus der Menge aller")
+print("moeglichen Fehler. Was zaehlt, ist die LISTE der Ueberlebenden.")
+print("="*84)
Der erste Lauf fand zwei Lücken
Die Suite hatte zunächst 31 Tests. Der Mutationstest sagte dazu:
Nebenbedingung umgedreht (<= wird >=) 7 GETOETET
@@ -26971,208 +27339,208 @@ moeglichen Fehler. Was zaehlt, ist die LISTE der Ueberlebenden.
23.6 Ein Vergleich, dem man glauben kann
Solververgleiche stehen in jedem Blog, und die meisten sind wertlos. Nicht, weil falsch gemessen wurde, sondern weil zu wenig dazugesagt wird.
-
#!/usr/bin/env python3
-
-# Benchmark_Skalierung.py
-"""
-Kapitel Testen: Ein Vergleich, dem man glauben kann.
-
-Solververgleiche stehen in jedem Blog, und die meisten sind wertlos - nicht
-weil falsch gemessen wurde, sondern weil zu wenig dazugesagt wird. Dieses
-Programm misst dasselbe Transportproblem in drei Groessen mit vier
-Bibliotheken und haelt sich dabei an fuenf Regeln, die den Unterschied machen:
-
- 1. EIGENER PROZESS je Bibliothek. Nicht nur wegen des Importkonflikts
- zwischen ortools und highspy (Kapitel Oekosystem) - auch, damit der
- Speicherverbrauch der einen nicht in der Messung der anderen auftaucht.
- 2. AUFBAU UND LOESEN GETRENNT messen. Bei grossen Instanzen ist der Aufbau
- des Modells in Python regelmaessig teurer als das Loesen. Wer nur die
- Gesamtzeit misst, optimiert am Ende die falsche Haelfte.
- 3. ZIELWERTE GEGENEINANDER PRUEFEN. Eine Bibliothek, die schneller ist und
- etwas anderes ausrechnet, hat den Vergleich nicht gewonnen. Diese Pruefung
- ist der wichtigste Teil des Programms.
- 4. SPEICHER MITMESSEN. Bei 10.000 Variablen entscheidet oft er und nicht die
- Zeit darueber, was auf einer Maschine noch laeuft.
- 5. DIESELBE INSTANZ fuer alle. Feste Saat, kein Neuwuerfeln zwischendurch.
-
-Und die Einschraenkung, die dazugehoert: Gemessen wird EIN Problemtyp in EINER
-Formulierung auf EINER Maschine. Das Ergebnis ist keine Rangliste der Solver,
-sondern eine Entscheidungshilfe fuer genau diesen Fall. Wer es verallgemeinert,
-macht denselben Fehler wie jemand, der aus einem Backtest auf die Zukunft
-schliesst (Kapitel Handelsmaschine).
-
-Benoetigt: numpy; in den Kindprozessen scipy, highspy, ortools, cvxpy
-"""
-
-from __future__ import annotations
-
-import json
-import subprocess
-import sys
-import textwrap
-
-GROESSEN = [(10, 10), (32, 32), (100, 100)] # (Lager, Kunden) -> 100 / 1.024 / 10.000 Variablen
-
-
-# Jeder Eintrag ist ein eigenstaendiges Programm: Instanz aufbauen, loesen,
-# Ergebnis als JSON ausgeben. Die Instanz wird in jedem Kindprozess aus
-# derselben Saat neu erzeugt - so reist nichts ueber die Prozessgrenze,
-# was das Ergebnis verfaelschen koennte.
-VORSPANN ="""
-import json, time, resource
-import numpy as np
-
-def instanz(m, n):
- rng = np.random.default_rng(20)
- kosten = rng.integers(5, 95, (m, n)).astype(float)
- angebot = rng.integers(50, 150, m).astype(float)
- bedarf = angebot.sum() * rng.dirichlet(np.ones(n))
- return kosten, angebot, bedarf
-
-def speicher_mb():
- # ru_maxrss ist unter Linux in Kilobyte
- return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
-
-M, N = {m}, {n}
-kosten, angebot, bedarf = instanz(M, N)
-"""
-
-ANSAETZE = {
-"scipy.linprog": """
- from scipy.optimize import linprog
- t0 = time.perf_counter()
- c = kosten.reshape(-1)
- A_ub = np.zeros((M, M * N)); A_eq = np.zeros((N, M * N))
- for i in range(M):
- A_ub[i, i * N:(i + 1) * N] = 1.0
- for j in range(N):
- A_eq[j, j::N] = 1.0
- aufbau = time.perf_counter() - t0
- t0 = time.perf_counter()
- r = linprog(c=c, A_ub=A_ub, b_ub=angebot, A_eq=A_eq, b_eq=bedarf,
- bounds=(0, None), method="highs")
- loesen = time.perf_counter() - t0
- ausgabe = (float(r.fun), aufbau, loesen, speicher_mb())
- """,
-
-"highspy": """
- import highspy
- t0 = time.perf_counter()
- h = highspy.Highs(); h.setOptionValue("output_flag", False)
- h.addVars(M * N, np.zeros(M * N), np.full(M * N, highspy.kHighsInf))
- for k in range(M * N):
- h.changeColCost(k, float(kosten.reshape(-1)[k]))
- for i in range(M):
- idx = np.arange(i * N, (i + 1) * N, dtype=np.int32)
- h.addRow(-highspy.kHighsInf, float(angebot[i]), N, idx, np.ones(N))
- for j in range(N):
- idx = np.arange(j, M * N, N, dtype=np.int32)
- h.addRow(float(bedarf[j]), float(bedarf[j]), M, idx, np.ones(M))
- aufbau = time.perf_counter() - t0
- t0 = time.perf_counter(); h.run(); loesen = time.perf_counter() - t0
- ausgabe = (h.getInfo().objective_function_value, aufbau, loesen, speicher_mb())
- """,
-
-"ortools/GLOP": """
- from ortools.linear_solver import pywraplp
- t0 = time.perf_counter()
- s = pywraplp.Solver.CreateSolver("GLOP")
- x = [[s.NumVar(0, s.infinity(), f"x{i}_{j}") for j in range(N)]
- for i in range(M)]
- for i in range(M):
- s.Add(sum(x[i]) <= float(angebot[i]))
- for j in range(N):
- s.Add(sum(x[i][j] for i in range(M)) == float(bedarf[j]))
- s.Minimize(sum(float(kosten[i, j]) * x[i][j]
- for i in range(M) for j in range(N)))
- aufbau = time.perf_counter() - t0
- t0 = time.perf_counter(); s.Solve(); loesen = time.perf_counter() - t0
- ausgabe = (s.Objective().Value(), aufbau, loesen, speicher_mb())
- """,
-
-"cvxpy": """
- import cvxpy as cp
- t0 = time.perf_counter()
- x = cp.Variable((M, N), nonneg=True)
- problem = cp.Problem(cp.Minimize(cp.sum(cp.multiply(kosten, x))),
- [cp.sum(x, axis=1) <= angebot,
- cp.sum(x, axis=0) == bedarf])
- aufbau = time.perf_counter() - t0
- t0 = time.perf_counter(); problem.solve(); loesen = time.perf_counter() - t0
- ausgabe = (float(problem.value), aufbau, loesen, speicher_mb())
- """,
-}
-
-
-def messe(name: str, quelltext: str, m: int, n: int):
-"""Fuehrt einen Ansatz in einem eigenen Prozess aus."""
- programm = (VORSPANN.format(m=m, n=n) + textwrap.dedent(quelltext)
-+"\nprint(json.dumps(ausgabe))\n")
- ergebnis = subprocess.run([sys.executable, "-c", programm],
- capture_output=True, text=True, timeout=600)
-if ergebnis.returncode !=0:
-returnNone, ergebnis.stderr.strip().splitlines()[-1][:60]
-return json.loads(ergebnis.stdout.strip().splitlines()[-1]), None
-
-
-if__name__=="__main__":
-print("="*92)
-print(" SKALIERUNGSVERGLEICH: TRANSPORTPROBLEM, VIER BIBLIOTHEKEN")
-print("="*92)
-print("Jede Zeile ein eigener Prozess. Zeiten und Speicher sind "
-"hardwareabhaengig,")
-print("die Zielwerte und ihr Verhaeltnis zueinander nicht.\n")
-
-for m, n in GROESSEN:
- kopf =f"--- {m} Lager x {n} Kunden = {m * n:,} Variablen "
-print(kopf +"-"*max(3, 92-len(kopf)))
-print(f" {'Bibliothek':<16}{'Zielwert':>14}{'Aufbau':>9} "
-f"{'Loesen':>9}{'Anteil':>8}{'Speicher':>10}")
-print(" "+"-"*72)
- zielwerte = {}
-for name, quelltext in ANSAETZE.items():
- werte, fehler = messe(name, quelltext, m, n)
-if werte isNone:
-print(f" {name:<16} nicht verfuegbar: {fehler}")
-continue
- ziel, aufbau, loesen, speicher = werte
- zielwerte[name] = ziel
- anteil = aufbau / (aufbau + loesen) *100
-print(f" {name:<16}{ziel:>14,.2f}{aufbau:>8.3f}s "
-f"{loesen:>8.3f}s {anteil:>7.0f}% {speicher:>9.0f} MB")
-
-# Die wichtigste Zeile: Rechnen alle dasselbe aus?
- spanne =max(zielwerte.values()) -min(zielwerte.values())
- bezug =max(abs(v) for v in zielwerte.values())
-print(f" {'':16} Spannweite der Zielwerte: {spanne:.2e} "
-f"(relativ {spanne / bezug:.1e})")
-if spanne / bezug >1e-6:
-print(" ACHTUNG: Die Bibliotheken widersprechen sich - "
-"der Zeitvergleich ist wertlos.")
-print()
-
-print("="*92)
-print(" WAS MAN AUS SO EINER TABELLE ABLESEN DARF - UND WAS NICHT")
-print("="*92)
-print("DARF man ablesen:")
-print(" * Die Spalte 'Anteil' - wie viel der Zeit in den AUFBAU geht statt")
-print(" ins Loesen. Wenn dort 80 % stehen, ist ein schnellerer Solver die")
-print(" falsche Antwort; dann gehoert das Modell vektorisiert aufgebaut")
-print(" (Kapitel Oekosystem).")
-print(" * Die Groessenordnung des Speicherbedarfs. Sie entscheidet, was auf")
-print(" einer bestimmten Maschine ueberhaupt laeuft.")
-print(" * Wie sich beides mit der Groesse ENTWICKELT. Der Trend ist")
-print(" uebertragbarer als der Absolutwert.")
-print()
-print("NICHT ablesen darf man:")
-print(" * 'Bibliothek X ist schneller als Y.' Gemessen wurde EIN")
-print(" Problemtyp in EINER Formulierung. Ein MILP, ein QP oder eine")
-print(" andere Modellierung desselben Problems koennen die Reihenfolge")
-print(" umdrehen.")
-print(" * Etwas ueber Ihre Maschine. Diese Zahlen stammen von einer")
-print(" anderen. Der Sinn des Programms ist, dass Sie es auf Ihrer")
-print(" laufen lassen.")
-print("="*92)
+
#!/usr/bin/env python3
+
+# Benchmark_Skalierung.py
+"""
+Kapitel Testen: Ein Vergleich, dem man glauben kann.
+
+Solververgleiche stehen in jedem Blog, und die meisten sind wertlos - nicht
+weil falsch gemessen wurde, sondern weil zu wenig dazugesagt wird. Dieses
+Programm misst dasselbe Transportproblem in drei Groessen mit vier
+Bibliotheken und haelt sich dabei an fuenf Regeln, die den Unterschied machen:
+
+ 1. EIGENER PROZESS je Bibliothek. Nicht nur wegen des Importkonflikts
+ zwischen ortools und highspy (Kapitel Oekosystem) - auch, damit der
+ Speicherverbrauch der einen nicht in der Messung der anderen auftaucht.
+ 2. AUFBAU UND LOESEN GETRENNT messen. Bei grossen Instanzen ist der Aufbau
+ des Modells in Python regelmaessig teurer als das Loesen. Wer nur die
+ Gesamtzeit misst, optimiert am Ende die falsche Haelfte.
+ 3. ZIELWERTE GEGENEINANDER PRUEFEN. Eine Bibliothek, die schneller ist und
+ etwas anderes ausrechnet, hat den Vergleich nicht gewonnen. Diese Pruefung
+ ist der wichtigste Teil des Programms.
+ 4. SPEICHER MITMESSEN. Bei 10.000 Variablen entscheidet oft er und nicht die
+ Zeit darueber, was auf einer Maschine noch laeuft.
+ 5. DIESELBE INSTANZ fuer alle. Feste Saat, kein Neuwuerfeln zwischendurch.
+
+Und die Einschraenkung, die dazugehoert: Gemessen wird EIN Problemtyp in EINER
+Formulierung auf EINER Maschine. Das Ergebnis ist keine Rangliste der Solver,
+sondern eine Entscheidungshilfe fuer genau diesen Fall. Wer es verallgemeinert,
+macht denselben Fehler wie jemand, der aus einem Backtest auf die Zukunft
+schliesst (Kapitel Handelsmaschine).
+
+Benoetigt: numpy; in den Kindprozessen scipy, highspy, ortools, cvxpy
+"""
+
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+import textwrap
+
+GROESSEN = [(10, 10), (32, 32), (100, 100)] # (Lager, Kunden) -> 100 / 1.024 / 10.000 Variablen
+
+
+# Jeder Eintrag ist ein eigenstaendiges Programm: Instanz aufbauen, loesen,
+# Ergebnis als JSON ausgeben. Die Instanz wird in jedem Kindprozess aus
+# derselben Saat neu erzeugt - so reist nichts ueber die Prozessgrenze,
+# was das Ergebnis verfaelschen koennte.
+VORSPANN ="""
+import json, time, resource
+import numpy as np
+
+def instanz(m, n):
+ rng = np.random.default_rng(20)
+ kosten = rng.integers(5, 95, (m, n)).astype(float)
+ angebot = rng.integers(50, 150, m).astype(float)
+ bedarf = angebot.sum() * rng.dirichlet(np.ones(n))
+ return kosten, angebot, bedarf
+
+def speicher_mb():
+ # ru_maxrss ist unter Linux in Kilobyte
+ return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
+
+M, N = {m}, {n}
+kosten, angebot, bedarf = instanz(M, N)
+"""
+
+ANSAETZE = {
+"scipy.linprog": """
+ from scipy.optimize import linprog
+ t0 = time.perf_counter()
+ c = kosten.reshape(-1)
+ A_ub = np.zeros((M, M * N)); A_eq = np.zeros((N, M * N))
+ for i in range(M):
+ A_ub[i, i * N:(i + 1) * N] = 1.0
+ for j in range(N):
+ A_eq[j, j::N] = 1.0
+ aufbau = time.perf_counter() - t0
+ t0 = time.perf_counter()
+ r = linprog(c=c, A_ub=A_ub, b_ub=angebot, A_eq=A_eq, b_eq=bedarf,
+ bounds=(0, None), method="highs")
+ loesen = time.perf_counter() - t0
+ ausgabe = (float(r.fun), aufbau, loesen, speicher_mb())
+ """,
+
+"highspy": """
+ import highspy
+ t0 = time.perf_counter()
+ h = highspy.Highs(); h.setOptionValue("output_flag", False)
+ h.addVars(M * N, np.zeros(M * N), np.full(M * N, highspy.kHighsInf))
+ for k in range(M * N):
+ h.changeColCost(k, float(kosten.reshape(-1)[k]))
+ for i in range(M):
+ idx = np.arange(i * N, (i + 1) * N, dtype=np.int32)
+ h.addRow(-highspy.kHighsInf, float(angebot[i]), N, idx, np.ones(N))
+ for j in range(N):
+ idx = np.arange(j, M * N, N, dtype=np.int32)
+ h.addRow(float(bedarf[j]), float(bedarf[j]), M, idx, np.ones(M))
+ aufbau = time.perf_counter() - t0
+ t0 = time.perf_counter(); h.run(); loesen = time.perf_counter() - t0
+ ausgabe = (h.getInfo().objective_function_value, aufbau, loesen, speicher_mb())
+ """,
+
+"ortools/GLOP": """
+ from ortools.linear_solver import pywraplp
+ t0 = time.perf_counter()
+ s = pywraplp.Solver.CreateSolver("GLOP")
+ x = [[s.NumVar(0, s.infinity(), f"x{i}_{j}") for j in range(N)]
+ for i in range(M)]
+ for i in range(M):
+ s.Add(sum(x[i]) <= float(angebot[i]))
+ for j in range(N):
+ s.Add(sum(x[i][j] for i in range(M)) == float(bedarf[j]))
+ s.Minimize(sum(float(kosten[i, j]) * x[i][j]
+ for i in range(M) for j in range(N)))
+ aufbau = time.perf_counter() - t0
+ t0 = time.perf_counter(); s.Solve(); loesen = time.perf_counter() - t0
+ ausgabe = (s.Objective().Value(), aufbau, loesen, speicher_mb())
+ """,
+
+"cvxpy": """
+ import cvxpy as cp
+ t0 = time.perf_counter()
+ x = cp.Variable((M, N), nonneg=True)
+ problem = cp.Problem(cp.Minimize(cp.sum(cp.multiply(kosten, x))),
+ [cp.sum(x, axis=1) <= angebot,
+ cp.sum(x, axis=0) == bedarf])
+ aufbau = time.perf_counter() - t0
+ t0 = time.perf_counter(); problem.solve(); loesen = time.perf_counter() - t0
+ ausgabe = (float(problem.value), aufbau, loesen, speicher_mb())
+ """,
+}
+
+
+def messe(name: str, quelltext: str, m: int, n: int):
+"""Fuehrt einen Ansatz in einem eigenen Prozess aus."""
+ programm = (VORSPANN.format(m=m, n=n) + textwrap.dedent(quelltext)
++"\nprint(json.dumps(ausgabe))\n")
+ ergebnis = subprocess.run([sys.executable, "-c", programm],
+ capture_output=True, text=True, timeout=600)
+if ergebnis.returncode !=0:
+returnNone, ergebnis.stderr.strip().splitlines()[-1][:60]
+return json.loads(ergebnis.stdout.strip().splitlines()[-1]), None
+
+
+if__name__=="__main__":
+print("="*92)
+print(" SKALIERUNGSVERGLEICH: TRANSPORTPROBLEM, VIER BIBLIOTHEKEN")
+print("="*92)
+print("Jede Zeile ein eigener Prozess. Zeiten und Speicher sind "
+"hardwareabhaengig,")
+print("die Zielwerte und ihr Verhaeltnis zueinander nicht.\n")
+
+for m, n in GROESSEN:
+ kopf =f"--- {m} Lager x {n} Kunden = {m * n:,} Variablen "
+print(kopf +"-"*max(3, 92-len(kopf)))
+print(f" {'Bibliothek':<16}{'Zielwert':>14}{'Aufbau':>9} "
+f"{'Loesen':>9}{'Anteil':>8}{'Speicher':>10}")
+print(" "+"-"*72)
+ zielwerte = {}
+for name, quelltext in ANSAETZE.items():
+ werte, fehler = messe(name, quelltext, m, n)
+if werte isNone:
+print(f" {name:<16} nicht verfuegbar: {fehler}")
+continue
+ ziel, aufbau, loesen, speicher = werte
+ zielwerte[name] = ziel
+ anteil = aufbau / (aufbau + loesen) *100
+print(f" {name:<16}{ziel:>14,.2f}{aufbau:>8.3f}s "
+f"{loesen:>8.3f}s {anteil:>7.0f}% {speicher:>9.0f} MB")
+
+# Die wichtigste Zeile: Rechnen alle dasselbe aus?
+ spanne =max(zielwerte.values()) -min(zielwerte.values())
+ bezug =max(abs(v) for v in zielwerte.values())
+print(f" {'':16} Spannweite der Zielwerte: {spanne:.2e} "
+f"(relativ {spanne / bezug:.1e})")
+if spanne / bezug >1e-6:
+print(" ACHTUNG: Die Bibliotheken widersprechen sich - "
+"der Zeitvergleich ist wertlos.")
+print()
+
+print("="*92)
+print(" WAS MAN AUS SO EINER TABELLE ABLESEN DARF - UND WAS NICHT")
+print("="*92)
+print("DARF man ablesen:")
+print(" * Die Spalte 'Anteil' - wie viel der Zeit in den AUFBAU geht statt")
+print(" ins Loesen. Wenn dort 80 % stehen, ist ein schnellerer Solver die")
+print(" falsche Antwort; dann gehoert das Modell vektorisiert aufgebaut")
+print(" (Kapitel Oekosystem).")
+print(" * Die Groessenordnung des Speicherbedarfs. Sie entscheidet, was auf")
+print(" einer bestimmten Maschine ueberhaupt laeuft.")
+print(" * Wie sich beides mit der Groesse ENTWICKELT. Der Trend ist")
+print(" uebertragbarer als der Absolutwert.")
+print()
+print("NICHT ablesen darf man:")
+print(" * 'Bibliothek X ist schneller als Y.' Gemessen wurde EIN")
+print(" Problemtyp in EINER Formulierung. Ein MILP, ein QP oder eine")
+print(" andere Modellierung desselben Problems koennen die Reihenfolge")
+print(" umdrehen.")
+print(" * Etwas ueber Ihre Maschine. Diese Zahlen stammen von einer")
+print(" anderen. Der Sinn des Programms ist, dass Sie es auf Ihrer")
+print(" laufen lassen.")
+print("="*92)
Erwartete Ausgabe (Zeiten und Speicher hardwareabhängig, die Zielwerte nicht):
============================================================================================
SKALIERUNGSVERGLEICH: TRANSPORTPROBLEM, VIER BIBLIOTHEKEN
@@ -27299,321 +27667,321 @@ NICHT ablesen darf man:
-
#!/usr/bin/env python3
-
-# Optimierungsdienst.py
-"""
-Kapitel Testen: Das Modell als Dienst - und warum es kein normaler Endpunkt ist.
-
-Ein Optimierungsmodell hinter eine HTTP-Schnittstelle zu haengen sieht nach
-einer Fingeruebung aus. Es gibt aber einen Unterschied, der alles bestimmt:
-
- Eine gewoehnliche Anfrage dauert Millisekunden.
- Eine Optimierung dauert Sekunden bis Minuten - und manchmal laenger,
- als jemand warten will.
-
-Damit scheidet die naheliegende Bauform aus. Wer den Solver direkt im
-Request-Handler aufruft, baut einen Dienst, der bei der ersten grossen Instanz
-in einen Timeout des Reverse Proxy laeuft, und bei zehn gleichzeitigen Anfragen
-alle Arbeiter blockiert.
-
-Die tragfaehige Bauform ist deshalb ZWEISTUFIG:
-
- POST /plaene nimmt den Auftrag an, prueft ihn, gibt sofort
- 202 Accepted und eine Auftragsnummer zurueck
- GET /plaene/{id} sagt, wie weit es ist - und liefert am Ende die
- Loesung samt Status und Gap
-
-Drei Dinge, die dabei leicht untergehen und hier bewusst sichtbar sind:
-
- 1. Das Domaenenmodell aus or_kern.py ist zugleich das API-Schema. Eine
- unsinnige Anfrage wird von FastAPI mit 422 abgewiesen, bevor irgendein
- Solver startet - dieselbe Pruefung wie beim Excel-Import.
- 2. Die Antwort enthaelt STATUS und GAP, nicht nur Zahlen. Ein Aufrufer, der
- nur die Mengen bekommt, kann nicht unterscheiden, ob er ein bewiesenes
- Optimum oder einen Zeitlimit-Abbruch in der Hand haelt.
- 3. Jeder Auftrag hat ein ZEITLIMIT. Ohne das belegt eine einzige unguenstige
- Instanz einen Arbeiter auf unbestimmte Zeit.
-
-Dieses Programm laeuft ohne Server: Der Selbsttest unten benutzt den
-TestClient von FastAPI und spricht die Anwendung direkt an. Fuer den echten
-Betrieb steht am Ende, was sich aendert.
-
-Aufruf:
- python3 Optimierungsdienst.py # Selbsttest, kein Server noetig
- uvicorn Optimierungsdienst:app # echter Server auf Port 8000
-
-Benoetigt: fastapi, httpx (fuer den Selbsttest), pydantic, ortools (ueber or_kern)
-"""
-
-from __future__ import annotations
-
-import time
-import uuid
-from concurrent.futures import ThreadPoolExecutor
-from enum import Enum
-
-from fastapi import FastAPI, HTTPException
-from pydantic import BaseModel, Field
-
-from or_kern import (Loesung, Produktionsproblem, loese_mit_glop,
- pruefe_loesung)
-
-ZEITLIMIT_SEKUNDEN =30.0
-ARBEITER =2
-
-
-class Auftragsstand(str, Enum):
- WARTET ="wartet"
- LAEUFT ="laeuft"
- FERTIG ="fertig"
- GESCHEITERT ="gescheitert"
-
-
-class Auftragsantwort(BaseModel):
-"""Was der Aufrufer beim Abholen bekommt.
-
- Bewusst NICHT nur die Mengen: 'stand' und die Felder aus 'loesung'
- (Status, Gap) sind der Unterschied zwischen einer Zahl und einer
- belastbaren Auskunft.
- """
-id: str
- stand: Auftragsstand
- eingegangen: float
- laufzeit: float|None=None
- loesung: Loesung |None=None
- beanstandungen: list[str] = Field(default_factory=list)
- fehler: str|None=None
-
-
-app = FastAPI(title="Optimierungsdienst",
- summary="Produktionsplanung als Auftrag, nicht als Abfrage")
-
-# Fuer das Buchbeispiel: Auftragsbuch im Speicher, Arbeiter im selben Prozess.
-# Was daran im echten Betrieb nicht reicht, steht unten.
-AUFTRAEGE: dict[str, Auftragsantwort] = {}
-POOL = ThreadPoolExecutor(max_workers=ARBEITER)
-
-
-def rechne(auftrags_id: str, problem: Produktionsproblem) ->None:
-"""Laeuft im Arbeiterthread - nie im Request-Handler."""
- antwort = AUFTRAEGE[auftrags_id]
- antwort.stand = Auftragsstand.LAEUFT
- start = time.perf_counter()
-try:
- loesung = loese_mit_glop(problem)
- antwort.loesung = loesung
-# Dieselbe Abnahmepruefung wie ueberall sonst. Ein Dienst, der sie
-# weglaesst, liefert Fehler schneller aus als ein Mensch sie faende.
- antwort.beanstandungen = pruefe_loesung(problem, loesung)
- antwort.stand = (Auftragsstand.FERTIG if loesung.status.brauchbar
-andnot antwort.beanstandungen
-else Auftragsstand.GESCHEITERT)
-ifnot loesung.status.brauchbar:
- antwort.fehler =f"Solverstatus: {loesung.status.value}"
-elif antwort.beanstandungen:
- antwort.fehler ="Abnahmepruefung fehlgeschlagen"
-exceptExceptionas fehler: # noqa: BLE001
- antwort.stand = Auftragsstand.GESCHEITERT
- antwort.fehler =f"{type(fehler).__name__}: {fehler}"
-finally:
- antwort.laufzeit = time.perf_counter() - start
-
-
-@app.post("/plaene", status_code=202, response_model=Auftragsantwort)
-def auftrag_annehmen(problem: Produktionsproblem) -> Auftragsantwort:
-"""Nimmt an, prueft, gibt sofort zurueck.
-
- Der Typ 'Produktionsproblem' im Parameter ist der ganze Trick: FastAPI
- validiert die Anfrage damit gegen das Domaenenmodell und antwortet bei
- Unsinn mit 422, ohne dass hier eine Zeile Pruefcode steht.
- """
- auftrags_id =str(uuid.uuid4())
- AUFTRAEGE[auftrags_id] = Auftragsantwort(
-id=auftrags_id, stand=Auftragsstand.WARTET, eingegangen=time.time())
- POOL.submit(rechne, auftrags_id, problem)
-return AUFTRAEGE[auftrags_id]
-
-
-@app.get("/plaene/{auftrags_id}", response_model=Auftragsantwort)
-def auftrag_abholen(auftrags_id: str) -> Auftragsantwort:
-if auftrags_id notin AUFTRAEGE:
-raise HTTPException(status_code=404, detail="Unbekannter Auftrag")
-return AUFTRAEGE[auftrags_id]
-
-
-@app.get("/gesundheit")
-def gesundheit() ->dict[str, object]:
-"""Was ein Ueberwachungssystem abfragt - siehe Betriebsueberwachung.py."""
- offen =sum(1for a in AUFTRAEGE.values()
-if a.stand in (Auftragsstand.WARTET, Auftragsstand.LAEUFT))
-return {"zustand": "bereit", "auftraege_gesamt": len(AUFTRAEGE),
-"offen": offen, "arbeiter": ARBEITER}
-
-
-# --- Selbsttest ohne Server -------------------------------------------------
-
-SCHREINEREI = {
-"produkte": [
- {"name": "Tisch", "deckungsbeitrag": 240.0,
-"verbrauch": {"Montagestunden": 3.0, "Plattenmaterial": 6.0}},
- {"name": "Stuhl", "deckungsbeitrag": 60.0,
-"verbrauch": {"Montagestunden": 1.0, "Plattenmaterial": 1.0}},
- ],
-"kapazitaeten": {"Montagestunden": 150.0, "Plattenmaterial": 240.0},
-}
-
-DOCKERFILE ="""\
-FROM python:3.12-slim
-WORKDIR /app
-COPY requirements.txt .
-RUN pip install --no-cache-dir -r requirements.txt
-COPY or_kern.py Optimierungsdienst.py .
-# Ein Arbeiter je CPU-Kern - der Solver rechnet selbst schon parallel,
-# mehr Prozesse machen ihn nicht schneller, sondern langsamer.
-CMD ["uvicorn", "Optimierungsdienst:app", "--host", "0.0.0.0", "--port", "8000"]
-"""
-
-
-def warte_auf_ergebnis(klient, auftrags_id: str, grenze: float=30.0) ->dict:
- ende = time.time() + grenze
-while time.time() < ende:
- antwort = klient.get(f"/plaene/{auftrags_id}").json()
-if antwort["stand"] in ("fertig", "gescheitert"):
-return antwort
- time.sleep(0.02)
-raiseTimeoutError("Auftrag wurde nicht fertig")
-
-
-if__name__=="__main__":
-from fastapi.testclient import TestClient
-
-print("="*80)
-print(" DAS MODELL ALS DIENST")
-print("="*80)
-
-with TestClient(app) as klient:
-# 1. Gueltiger Auftrag
-print("\n1. Auftrag einreichen")
- angenommen = klient.post("/plaene", json=SCHREINEREI)
- auftrags_id = angenommen.json()["id"]
-print(f" POST /plaene -> {angenommen.status_code} "
-f"{'Accepted'if angenommen.status_code ==202else''}")
-print(f" Auftragsnummer -> UUID, {len(auftrags_id)} Zeichen")
-# Der entscheidende Punkt, und er ist pruefbar: Die Antwort ist da,
-# BEVOR es eine Loesung gibt. Genau dafuer ist 202 gedacht.
-print(f" Loesung schon dabei? -> "
-f"{'ja'if angenommen.json()['loesung'] else'nein - genau so soll es sein'}")
-
- fertig = warte_auf_ergebnis(klient, auftrags_id)
- loesung = fertig["loesung"]
-print("\n2. Ergebnis abholen")
-print(f" Stand -> {fertig['stand']}")
-print(f" Solverstatus -> {loesung['status']}")
-print(f" Zielwert -> {loesung['zielwert']:,.2f} EUR")
-print(f" Mengen -> "+", ".join(
-f"{name}{wert:.0f}"for name, wert in loesung["werte"].items()))
-print(f" Schattenpreise -> "+", ".join(
-f"{name}{wert:.0f}"for name, wert in loesung["schattenpreise"].items()))
-print(f" Abnahmepruefung -> "
-f"{'bestanden'ifnot fertig['beanstandungen'] else fertig['beanstandungen']}")
-
-# 2. Unsinnige Anfrage - muss abgewiesen werden, BEVOR gerechnet wird
-print("\n3. Unsinnige Anfragen (der Solver startet gar nicht erst)")
-for beschreibung, aenderung in [
- ("Kapazitaet 0", {"kapazitaeten": {"Montagestunden": 0.0}}),
- ("keine Produkte", {"produkte": []}),
- ("Ressource ohne Kapazitaet",
- {"produkte": [{"name": "Regal", "deckungsbeitrag": 130.0,
-"verbrauch": {"Lackieren": 2.0}}]})]:
- anfrage = {**SCHREINEREI, **aenderung}
- antwort = klient.post("/plaene", json=anfrage)
-print(f" {beschreibung:<24} -> {antwort.status_code} "
-f"{'Unprocessable Content'if antwort.status_code ==422else''}")
-
-print("\n4. Unbekannter Auftrag")
-print(f" GET /plaene/gibtsnicht -> "
-f"{klient.get('/plaene/gibtsnicht').status_code} Not Found")
-
-print("\n5. Gesundheitsabfrage")
-print(f" GET /gesundheit -> {klient.get('/gesundheit').json()}")
-
-# --- Warum ein Thread je Auftrag genuegt -----------------------------
-print("\n"+"-"*80)
-print("6. Warum Threads hier reichen (und wann nicht)\n")
-from concurrent.futures import ThreadPoolExecutor as Pool
-
-# Wichtig: eine Rechnung mit ECHTER Last. Ein Modell, das in zehn
-# Millisekunden fertig ist, misst nur den Aufwand fuers Threadstarten -
-# der erste Entwurf dieser Messung ist genau daran gescheitert und zeigte
-# eine Verlangsamung, wo in Wirklichkeit eine Beschleunigung steht.
-def eine_rechnung() ->None:
-import numpy as np
-from ortools.linear_solver import pywraplp
- rng = np.random.default_rng(3)
- n =260
- solver = pywraplp.Solver.CreateSolver("SCIP")
- x = [solver.IntVar(0, 1, f"x{i}") for i inrange(n)]
- gewicht = rng.integers(10, 60, n)
- wert = rng.integers(10, 60, n)
- solver.Add(sum(int(gewicht[i]) * x[i] for i inrange(n))
-<=int(gewicht.sum() *0.5))
-for _ inrange(30):
- auswahl = rng.choice(n, 40, replace=False)
- solver.Add(sum(x[int(i)] for i in auswahl) <=12)
- solver.Maximize(sum(int(wert[i]) * x[i] for i inrange(n)))
- solver.Solve()
-
- t0 = time.perf_counter()
-for _ inrange(4):
- eine_rechnung()
- seriell = time.perf_counter() - t0
- t0 = time.perf_counter()
-with Pool(max_workers=4) as p:
-list(p.map(lambda _: eine_rechnung(), range(4)))
- parallel = time.perf_counter() - t0
-
-print(f" vier Rechnungen nacheinander -> {seriell:6.2f} s")
-print(f" vier Rechnungen in Threads -> {parallel:6.2f} s "
-f"(Faktor {seriell / parallel:.1f})")
-print()
-if parallel < seriell *0.75:
-print(" Die Laeufe ueberlappen sich. ortools rechnet in C++ und gibt")
-print(" den GIL waehrend Solve() frei - deshalb genuegt hier ein")
-print(" Threadpool, es braucht keine eigenen Prozesse.")
-else:
-print(" Keine Ueberlappung - hier waeren Prozesse noetig.")
-print()
-print(" ACHTUNG, das gilt nicht allgemein: Eine in reinem Python")
-print(" geschriebene Heuristik (Kapitel Metaheuristiken) haelt den GIL")
-print(" die ganze Zeit. Fuer sie braucht derselbe Dienst einen")
-print(" ProcessPoolExecutor statt eines Threadpools.")
-
-print("\n"+"="*80)
-print(" WAS SICH IM ECHTEN BETRIEB AENDERT")
-print("="*80)
-print("Dieses Beispiel haelt das Auftragsbuch im Speicher und rechnet in")
-print("Threads desselben Prozesses. Das reicht zum Zeigen und fuer einen")
-print("einzelnen Rechner - nicht darueber hinaus:")
-print()
-print(" * NEUSTART LOESCHT ALLES. Auftragsbuch in eine Datenbank oder eine")
-print(" Warteschlange (Redis, RabbitMQ), nicht in ein dict.")
-print(" * ZWEI INSTANZEN KENNEN EINANDER NICHT. Sobald der Dienst mehr als")
-print(" einmal laeuft, muss die Warteschlange ausserhalb liegen -")
-print(" typischerweise Celery mit Redis als Vermittler.")
-print(" * DIE WAHL THREADS/PROZESSE HAENGT AM SOLVER - siehe die Messung")
-print(" unter Punkt 6. Sie gehoert gemessen, nicht angenommen.")
-print(" * OHNE ZEITLIMIT KEIN DIENST. Jede Instanz bekommt eines, und der")
-print(" Aufrufer erfaehrt im Status, ob es gegriffen hat.")
-print()
-print("Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:")
-print()
-for zeile in DOCKERFILE.splitlines():
-print(f" {zeile}")
-print()
-print("(Es wird hier nicht gebaut - das Buch setzt keine laufende")
-print(" Docker-Installation voraus.)")
-print("="*80)
+
#!/usr/bin/env python3
+
+# Optimierungsdienst.py
+"""
+Kapitel Testen: Das Modell als Dienst - und warum es kein normaler Endpunkt ist.
+
+Ein Optimierungsmodell hinter eine HTTP-Schnittstelle zu haengen sieht nach
+einer Fingeruebung aus. Es gibt aber einen Unterschied, der alles bestimmt:
+
+ Eine gewoehnliche Anfrage dauert Millisekunden.
+ Eine Optimierung dauert Sekunden bis Minuten - und manchmal laenger,
+ als jemand warten will.
+
+Damit scheidet die naheliegende Bauform aus. Wer den Solver direkt im
+Request-Handler aufruft, baut einen Dienst, der bei der ersten grossen Instanz
+in einen Timeout des Reverse Proxy laeuft, und bei zehn gleichzeitigen Anfragen
+alle Arbeiter blockiert.
+
+Die tragfaehige Bauform ist deshalb ZWEISTUFIG:
+
+ POST /plaene nimmt den Auftrag an, prueft ihn, gibt sofort
+ 202 Accepted und eine Auftragsnummer zurueck
+ GET /plaene/{id} sagt, wie weit es ist - und liefert am Ende die
+ Loesung samt Status und Gap
+
+Drei Dinge, die dabei leicht untergehen und hier bewusst sichtbar sind:
+
+ 1. Das Domaenenmodell aus or_kern.py ist zugleich das API-Schema. Eine
+ unsinnige Anfrage wird von FastAPI mit 422 abgewiesen, bevor irgendein
+ Solver startet - dieselbe Pruefung wie beim Excel-Import.
+ 2. Die Antwort enthaelt STATUS und GAP, nicht nur Zahlen. Ein Aufrufer, der
+ nur die Mengen bekommt, kann nicht unterscheiden, ob er ein bewiesenes
+ Optimum oder einen Zeitlimit-Abbruch in der Hand haelt.
+ 3. Jeder Auftrag hat ein ZEITLIMIT. Ohne das belegt eine einzige unguenstige
+ Instanz einen Arbeiter auf unbestimmte Zeit.
+
+Dieses Programm laeuft ohne Server: Der Selbsttest unten benutzt den
+TestClient von FastAPI und spricht die Anwendung direkt an. Fuer den echten
+Betrieb steht am Ende, was sich aendert.
+
+Aufruf:
+ python3 Optimierungsdienst.py # Selbsttest, kein Server noetig
+ uvicorn Optimierungsdienst:app # echter Server auf Port 8000
+
+Benoetigt: fastapi, httpx (fuer den Selbsttest), pydantic, ortools (ueber or_kern)
+"""
+
+from __future__ import annotations
+
+import time
+import uuid
+from concurrent.futures import ThreadPoolExecutor
+from enum import Enum
+
+from fastapi import FastAPI, HTTPException
+from pydantic import BaseModel, Field
+
+from or_kern import (Loesung, Produktionsproblem, loese_mit_glop,
+ pruefe_loesung)
+
+ZEITLIMIT_SEKUNDEN =30.0
+ARBEITER =2
+
+
+class Auftragsstand(str, Enum):
+ WARTET ="wartet"
+ LAEUFT ="laeuft"
+ FERTIG ="fertig"
+ GESCHEITERT ="gescheitert"
+
+
+class Auftragsantwort(BaseModel):
+"""Was der Aufrufer beim Abholen bekommt.
+
+ Bewusst NICHT nur die Mengen: 'stand' und die Felder aus 'loesung'
+ (Status, Gap) sind der Unterschied zwischen einer Zahl und einer
+ belastbaren Auskunft.
+ """
+id: str
+ stand: Auftragsstand
+ eingegangen: float
+ laufzeit: float|None=None
+ loesung: Loesung |None=None
+ beanstandungen: list[str] = Field(default_factory=list)
+ fehler: str|None=None
+
+
+app = FastAPI(title="Optimierungsdienst",
+ summary="Produktionsplanung als Auftrag, nicht als Abfrage")
+
+# Fuer das Buchbeispiel: Auftragsbuch im Speicher, Arbeiter im selben Prozess.
+# Was daran im echten Betrieb nicht reicht, steht unten.
+AUFTRAEGE: dict[str, Auftragsantwort] = {}
+POOL = ThreadPoolExecutor(max_workers=ARBEITER)
+
+
+def rechne(auftrags_id: str, problem: Produktionsproblem) ->None:
+"""Laeuft im Arbeiterthread - nie im Request-Handler."""
+ antwort = AUFTRAEGE[auftrags_id]
+ antwort.stand = Auftragsstand.LAEUFT
+ start = time.perf_counter()
+try:
+ loesung = loese_mit_glop(problem)
+ antwort.loesung = loesung
+# Dieselbe Abnahmepruefung wie ueberall sonst. Ein Dienst, der sie
+# weglaesst, liefert Fehler schneller aus als ein Mensch sie faende.
+ antwort.beanstandungen = pruefe_loesung(problem, loesung)
+ antwort.stand = (Auftragsstand.FERTIG if loesung.status.brauchbar
+andnot antwort.beanstandungen
+else Auftragsstand.GESCHEITERT)
+ifnot loesung.status.brauchbar:
+ antwort.fehler =f"Solverstatus: {loesung.status.value}"
+elif antwort.beanstandungen:
+ antwort.fehler ="Abnahmepruefung fehlgeschlagen"
+exceptExceptionas fehler: # noqa: BLE001
+ antwort.stand = Auftragsstand.GESCHEITERT
+ antwort.fehler =f"{type(fehler).__name__}: {fehler}"
+finally:
+ antwort.laufzeit = time.perf_counter() - start
+
+
+@app.post("/plaene", status_code=202, response_model=Auftragsantwort)
+def auftrag_annehmen(problem: Produktionsproblem) -> Auftragsantwort:
+"""Nimmt an, prueft, gibt sofort zurueck.
+
+ Der Typ 'Produktionsproblem' im Parameter ist der ganze Trick: FastAPI
+ validiert die Anfrage damit gegen das Domaenenmodell und antwortet bei
+ Unsinn mit 422, ohne dass hier eine Zeile Pruefcode steht.
+ """
+ auftrags_id =str(uuid.uuid4())
+ AUFTRAEGE[auftrags_id] = Auftragsantwort(
+id=auftrags_id, stand=Auftragsstand.WARTET, eingegangen=time.time())
+ POOL.submit(rechne, auftrags_id, problem)
+return AUFTRAEGE[auftrags_id]
+
+
+@app.get("/plaene/{auftrags_id}", response_model=Auftragsantwort)
+def auftrag_abholen(auftrags_id: str) -> Auftragsantwort:
+if auftrags_id notin AUFTRAEGE:
+raise HTTPException(status_code=404, detail="Unbekannter Auftrag")
+return AUFTRAEGE[auftrags_id]
+
+
+@app.get("/gesundheit")
+def gesundheit() ->dict[str, object]:
+"""Was ein Ueberwachungssystem abfragt - siehe Betriebsueberwachung.py."""
+ offen =sum(1for a in AUFTRAEGE.values()
+if a.stand in (Auftragsstand.WARTET, Auftragsstand.LAEUFT))
+return {"zustand": "bereit", "auftraege_gesamt": len(AUFTRAEGE),
+"offen": offen, "arbeiter": ARBEITER}
+
+
+# --- Selbsttest ohne Server -------------------------------------------------
+
+SCHREINEREI = {
+"produkte": [
+ {"name": "Tisch", "deckungsbeitrag": 240.0,
+"verbrauch": {"Montagestunden": 3.0, "Plattenmaterial": 6.0}},
+ {"name": "Stuhl", "deckungsbeitrag": 60.0,
+"verbrauch": {"Montagestunden": 1.0, "Plattenmaterial": 1.0}},
+ ],
+"kapazitaeten": {"Montagestunden": 150.0, "Plattenmaterial": 240.0},
+}
+
+DOCKERFILE ="""\
+FROM python:3.12-slim
+WORKDIR /app
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+COPY or_kern.py Optimierungsdienst.py .
+# Ein Arbeiter je CPU-Kern - der Solver rechnet selbst schon parallel,
+# mehr Prozesse machen ihn nicht schneller, sondern langsamer.
+CMD ["uvicorn", "Optimierungsdienst:app", "--host", "0.0.0.0", "--port", "8000"]
+"""
+
+
+def warte_auf_ergebnis(klient, auftrags_id: str, grenze: float=30.0) ->dict:
+ ende = time.time() + grenze
+while time.time() < ende:
+ antwort = klient.get(f"/plaene/{auftrags_id}").json()
+if antwort["stand"] in ("fertig", "gescheitert"):
+return antwort
+ time.sleep(0.02)
+raiseTimeoutError("Auftrag wurde nicht fertig")
+
+
+if__name__=="__main__":
+from fastapi.testclient import TestClient
+
+print("="*80)
+print(" DAS MODELL ALS DIENST")
+print("="*80)
+
+with TestClient(app) as klient:
+# 1. Gueltiger Auftrag
+print("\n1. Auftrag einreichen")
+ angenommen = klient.post("/plaene", json=SCHREINEREI)
+ auftrags_id = angenommen.json()["id"]
+print(f" POST /plaene -> {angenommen.status_code} "
+f"{'Accepted'if angenommen.status_code ==202else''}")
+print(f" Auftragsnummer -> UUID, {len(auftrags_id)} Zeichen")
+# Der entscheidende Punkt, und er ist pruefbar: Die Antwort ist da,
+# BEVOR es eine Loesung gibt. Genau dafuer ist 202 gedacht.
+print(f" Loesung schon dabei? -> "
+f"{'ja'if angenommen.json()['loesung'] else'nein - genau so soll es sein'}")
+
+ fertig = warte_auf_ergebnis(klient, auftrags_id)
+ loesung = fertig["loesung"]
+print("\n2. Ergebnis abholen")
+print(f" Stand -> {fertig['stand']}")
+print(f" Solverstatus -> {loesung['status']}")
+print(f" Zielwert -> {loesung['zielwert']:,.2f} EUR")
+print(f" Mengen -> "+", ".join(
+f"{name}{wert:.0f}"for name, wert in loesung["werte"].items()))
+print(f" Schattenpreise -> "+", ".join(
+f"{name}{wert:.0f}"for name, wert in loesung["schattenpreise"].items()))
+print(f" Abnahmepruefung -> "
+f"{'bestanden'ifnot fertig['beanstandungen'] else fertig['beanstandungen']}")
+
+# 2. Unsinnige Anfrage - muss abgewiesen werden, BEVOR gerechnet wird
+print("\n3. Unsinnige Anfragen (der Solver startet gar nicht erst)")
+for beschreibung, aenderung in [
+ ("Kapazitaet 0", {"kapazitaeten": {"Montagestunden": 0.0}}),
+ ("keine Produkte", {"produkte": []}),
+ ("Ressource ohne Kapazitaet",
+ {"produkte": [{"name": "Regal", "deckungsbeitrag": 130.0,
+"verbrauch": {"Lackieren": 2.0}}]})]:
+ anfrage = {**SCHREINEREI, **aenderung}
+ antwort = klient.post("/plaene", json=anfrage)
+print(f" {beschreibung:<24} -> {antwort.status_code} "
+f"{'Unprocessable Content'if antwort.status_code ==422else''}")
+
+print("\n4. Unbekannter Auftrag")
+print(f" GET /plaene/gibtsnicht -> "
+f"{klient.get('/plaene/gibtsnicht').status_code} Not Found")
+
+print("\n5. Gesundheitsabfrage")
+print(f" GET /gesundheit -> {klient.get('/gesundheit').json()}")
+
+# --- Warum ein Thread je Auftrag genuegt -----------------------------
+print("\n"+"-"*80)
+print("6. Warum Threads hier reichen (und wann nicht)\n")
+from concurrent.futures import ThreadPoolExecutor as Pool
+
+# Wichtig: eine Rechnung mit ECHTER Last. Ein Modell, das in zehn
+# Millisekunden fertig ist, misst nur den Aufwand fuers Threadstarten -
+# der erste Entwurf dieser Messung ist genau daran gescheitert und zeigte
+# eine Verlangsamung, wo in Wirklichkeit eine Beschleunigung steht.
+def eine_rechnung() ->None:
+import numpy as np
+from ortools.linear_solver import pywraplp
+ rng = np.random.default_rng(3)
+ n =260
+ solver = pywraplp.Solver.CreateSolver("SCIP")
+ x = [solver.IntVar(0, 1, f"x{i}") for i inrange(n)]
+ gewicht = rng.integers(10, 60, n)
+ wert = rng.integers(10, 60, n)
+ solver.Add(sum(int(gewicht[i]) * x[i] for i inrange(n))
+<=int(gewicht.sum() *0.5))
+for _ inrange(30):
+ auswahl = rng.choice(n, 40, replace=False)
+ solver.Add(sum(x[int(i)] for i in auswahl) <=12)
+ solver.Maximize(sum(int(wert[i]) * x[i] for i inrange(n)))
+ solver.Solve()
+
+ t0 = time.perf_counter()
+for _ inrange(4):
+ eine_rechnung()
+ seriell = time.perf_counter() - t0
+ t0 = time.perf_counter()
+with Pool(max_workers=4) as p:
+list(p.map(lambda _: eine_rechnung(), range(4)))
+ parallel = time.perf_counter() - t0
+
+print(f" vier Rechnungen nacheinander -> {seriell:6.2f} s")
+print(f" vier Rechnungen in Threads -> {parallel:6.2f} s "
+f"(Faktor {seriell / parallel:.1f})")
+print()
+if parallel < seriell *0.75:
+print(" Die Laeufe ueberlappen sich. ortools rechnet in C++ und gibt")
+print(" den GIL waehrend Solve() frei - deshalb genuegt hier ein")
+print(" Threadpool, es braucht keine eigenen Prozesse.")
+else:
+print(" Keine Ueberlappung - hier waeren Prozesse noetig.")
+print()
+print(" ACHTUNG, das gilt nicht allgemein: Eine in reinem Python")
+print(" geschriebene Heuristik (Kapitel Metaheuristiken) haelt den GIL")
+print(" die ganze Zeit. Fuer sie braucht derselbe Dienst einen")
+print(" ProcessPoolExecutor statt eines Threadpools.")
+
+print("\n"+"="*80)
+print(" WAS SICH IM ECHTEN BETRIEB AENDERT")
+print("="*80)
+print("Dieses Beispiel haelt das Auftragsbuch im Speicher und rechnet in")
+print("Threads desselben Prozesses. Das reicht zum Zeigen und fuer einen")
+print("einzelnen Rechner - nicht darueber hinaus:")
+print()
+print(" * NEUSTART LOESCHT ALLES. Auftragsbuch in eine Datenbank oder eine")
+print(" Warteschlange (Redis, RabbitMQ), nicht in ein dict.")
+print(" * ZWEI INSTANZEN KENNEN EINANDER NICHT. Sobald der Dienst mehr als")
+print(" einmal laeuft, muss die Warteschlange ausserhalb liegen -")
+print(" typischerweise Celery mit Redis als Vermittler.")
+print(" * DIE WAHL THREADS/PROZESSE HAENGT AM SOLVER - siehe die Messung")
+print(" unter Punkt 6. Sie gehoert gemessen, nicht angenommen.")
+print(" * OHNE ZEITLIMIT KEIN DIENST. Jede Instanz bekommt eines, und der")
+print(" Aufrufer erfaehrt im Status, ob es gegriffen hat.")
+print()
+print("Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:")
+print()
+for zeile in DOCKERFILE.splitlines():
+print(f" {zeile}")
+print()
+print("(Es wird hier nicht gebaut - das Buch setzt keine laufende")
+print(" Docker-Installation voraus.)")
+print("="*80)
Erwartete Ausgabe (Laufzeiten hardwareabhängig):
================================================================================
DAS MODELL ALS DIENST
@@ -28030,15 +28398,15 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
1.3 — Kombinatorik. (a) 12! = 479\,001\,600. (b) 479\,001\,600 / 5\cdot10^6 \approx 95{,}8 Sekunden \approx 1{,}6 Minuten. (c) 13! = 6\,227\,020\,800; das sind 1245 s \approx 20{,}8 Minuten — Faktor 13. Jeder weitere Auftrag multipliziert die Zeit mit der neuen Anzahl. (d) Eine Stunde = 3600 s \to 1{,}8\cdot10^{10} Prüfungen. 13! = 6{,}2\cdot10^9 ✓, 14! = 8{,}7\cdot10^{10} ✗. Also 13 Aufträge.
1.4 — Modell lesen. (a) Variablen x_1, x_2 \ge 0; Parameter (3,5) und die Kapazitäten (4,12,18); Zielfunktion \max 3x_1+5x_2; vier Nebenbedingungen inkl. Nichtnegativität. (b) (2,6): 2\le4 ✓, 12\le12 ✓, 6+12=18\le18 ✓ → zulässig, Z=36. (4,3): 4\le4 ✓, 6\le12 ✓, 12+6=18\le18 ✓ → zulässig, Z=27. (c) Beste ganzzahlige Lösung ist (2,6) mit Z=36 — sie ist hier bereits ganzzahlig, weil die Ecke des Polyeders zufällig auf Gitterpunkten liegt.
Zwei lehrreiche Beobachtungen: 1. Die Vertragsbedingung x_1 \ge 40 ist nicht bindend — der Solver backt freiwillig 96 Brote. Wer aus der Aufgabenstellung schließt, die Mindestmenge werde „gerade so“ erfüllt, liegt falsch. 2. Das kontinuierliche Optimum (40;\ 116{,}67) liefert denselben Zielwert 450. Das Problem hat also mehrere optimale Lösungen — die Zielfunktion verläuft parallel zur Mehlrestriktion (2{,}5/0{,}5 = 5 = 3{,}0/0{,}6). Prüfen Sie das nach: Genau deshalb ist hier auch die ganzzahlige Lösung ohne Verlust erreichbar.
@@ -28053,14 +28421,14 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
Korrektur. Die Summe über alle Produkte gehört in die Bedingung, die Schleife läuft über die Ressourcen, nicht über die Produkte:
-
for r, index in [("Montagestunden", 1), ("Plattenmaterial", 2)]:
- s.Add(sum(x[p] * produkt[p][index] for p in produkt) <= vorrat[r])
+
for r, index in [("Montagestunden", 1), ("Plattenmaterial", 2)]:
+ s.Add(sum(x[p] * produkt[p][index] for p in produkt) <= vorrat[r])
Automatische Absicherung. Nach jedem Lösen den tatsächlichen Verbrauch gegen den Vorrat prüfen — mit kleiner Toleranz, weil Solver mit endlicher Genauigkeit rechnen (siehe Kapitel 3):
-
for r, index in [("Montagestunden", 1), ("Plattenmaterial", 2)]:
- verbrauch =sum(x[p].solution_value() * produkt[p][index] for p in produkt)
-assert verbrauch <= vorrat[r] +1e-6, f"{r} ueberzogen: {verbrauch} > {vorrat[r]}"
+
for r, index in [("Montagestunden", 1), ("Plattenmaterial", 2)]:
+ verbrauch =sum(x[p].solution_value() * produkt[p][index] for p in produkt)
+assert verbrauch <= vorrat[r] +1e-6, f"{r} ueberzogen: {verbrauch} > {vorrat[r]}"
Diese vier Zeilen sind das wichtigste Werkzeug des Kapitels: Sie prüfen die Lösung gegen die Wirklichkeit, nicht gegen das Modell. Ein Modellierungsfehler kann sich vor dem Solver verstecken, vor dieser Prüfung nicht.
Micro-Quiz
1 — (c) präskriptiv. Die Frage lautet „was tun“, und es gibt eine Restriktion (das Restbudget). Dass man für die Bewertung eine Prognose braucht, macht die Frage nicht prädiktiv — die Prognose ist hier Eingabeparameter, nicht Ergebnis.
@@ -28083,9 +28451,9 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
2.6 — Bäckerei visualisieren. Ecken: (40,0), (150,0), (40,116{,}67) und der Schnittpunkt von Mehl- und Ofengrenze. Beste Ecke ist (40;\ 116{,}67) mit Z = 450 (im kontinuierlichen Fall).
2.7 — Eigenwert-Clipping.
-
def repariere(P):
- lam, V = np.linalg.eigh(P)
-return V @ np.diag(np.maximum(lam, 0.0)) @ V.T
+
def repariere(P):
+ lam, V = np.linalg.eigh(P)
+return V @ np.diag(np.maximum(lam, 0.0)) @ V.T
Für \mathbf{P}_2 ergibt sich eine PSD-Matrix mit Korrelation exakt 1{,}0 — das Verfahren zieht die unmögliche Korrelation auf den nächstgelegenen zulässigen Wert. Für Kovarianzmatrizen setzt man in der Praxis auf einen kleinen positiven Wert statt auf 0 (np.maximum(lam, 1e-10)), damit die Matrix invertierbar bleibt.
Finde den Denkfehler 2.1 — Die unauffällige Transposition
@@ -28098,8 +28466,8 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
Warum „zu klein“ schlimmer ist als „zu groß“. Ein unerwartet hoher Zielwert weckt Misstrauen — man rechnet nach (siehe Denkfehler 1.1). Ein leicht niedrigerer Wert wirkt dagegen wie ein normales, etwas enttäuschendes Ergebnis: Niemand prüft nach, warum die Optimierung „nur“ 20,80 € statt der erhofften 22 € bringt. Fehler, die sich als Bescheidenheit tarnen, überleben am längsten.
Zwei Zeilen, die es aufgedeckt hätten. Erstens beim Einlesen die Form gegen die Bedeutung prüfen, zweitens nach dem Lösen den Verbrauch gegen den Vorrat:
Die erste Zeile hilft nur bei rechteckigem \mathbf{A} — die zweite immer. Sie ist die wichtigste Zeile in jedem Optimierungsskript: Sie prüft die Lösung nicht gegen das Modell, sondern gegen die Wirklichkeit, die das Modell abbilden sollte.
Vorbeugend hilft außerdem, Matrizen nie positionsweise abzutippen, sondern über benannte Spalten aus einer Tabelle zu erzeugen — genau das tut Excel_Bruecke.py aus Kapitel 1.
Micro-Quiz
@@ -28135,24 +28503,24 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
Warum das bei 60 Wiederholungen kippt. Import und Kompilierung fallen einmal an, das Lösen sechzigmal. Genau dafür hat CVXPY cp.Parameter: Man baut das Problem einmal, tauscht nur die Daten und löst erneut, ohne neu zu kompilieren. Gemessen wurde also ausgerechnet der Teil, der in der Zielanwendung fast nicht mehr vorkommt — die Entscheidung beruht auf der unwichtigsten Zahl.
Eine Messung, die trägt. Drei Regeln:
-
# 1. Importe VOR die Messung
-import cvxpy as cp
-from scipy.optimize import linprog
-
-# 2. Aufwaermlauf: einmal alles durchlaufen lassen, Ergebnis verwerfen
-loese_klein()
-
-# 3. Aufbau und Loesen GETRENNT messen, ueber mehrere Wiederholungen
-t0 = time.perf_counter()
-problem = cp.Problem(cp.Minimize(kosten @ x), [A @ x == b]) # Aufbau
-t_aufbau = time.perf_counter() - t0
-
-zeiten = []
-for _ inrange(10):
- t0 = time.perf_counter()
- problem.solve()
- zeiten.append(time.perf_counter() - t0)
-print(f"Aufbau {t_aufbau:.3f}s, Loesen Median {statistics.median(zeiten):.3f}s")
+
# 1. Importe VOR die Messung
+import cvxpy as cp
+from scipy.optimize import linprog
+
+# 2. Aufwaermlauf: einmal alles durchlaufen lassen, Ergebnis verwerfen
+loese_klein()
+
+# 3. Aufbau und Loesen GETRENNT messen, ueber mehrere Wiederholungen
+t0 = time.perf_counter()
+problem = cp.Problem(cp.Minimize(kosten @ x), [A @ x == b]) # Aufbau
+t_aufbau = time.perf_counter() - t0
+
+zeiten = []
+for _ inrange(10):
+ t0 = time.perf_counter()
+ problem.solve()
+ zeiten.append(time.perf_counter() - t0)
+print(f"Aufbau {t_aufbau:.3f}s, Loesen Median {statistics.median(zeiten):.3f}s")
Und die vierte, wichtigste Regel: die Größe messen, die später auch läuft. Ein Vergleich bei 200 Variablen sagt nichts über das Verhalten bei 200 000 — die Kurven schneiden sich oft. Genau diese Trennung führt Vektorisierte_Modellgenerierung.py vor, und Kapitel 22 baut daraus eine vollständige Benchmark-Pipeline.
Micro-Quiz
1 — (b) CP-SAT. Es geht um diskrete Zuweisung mit logischen Regeln („nicht gleichzeitig“) — CP-SATs Kerngebiet, und Regeln dieser Art formuliert man dort direkt statt über Big-M-Umwege. (a) scheidet aus, weil CVXPY keine sinnvolle Ganzzahligkeit in dieser Größenordnung bietet; (c) findet bei einem diskreten Problem bestenfalls ein lokales Optimum und hat keinerlei Handhabe für „entweder–oder“-Regeln.
@@ -28173,10 +28541,10 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
Frage 5 anwenden. Wird die durchschnittliche Wartezeit minimiert, lohnt es sich, viele leichte Fälle schnell abzuarbeiten und die aufwendigen nach hinten zu schieben: Ein Fall mit vier Stunden Wartezeit zählt genauso viel wie vier Fälle mit einer Stunde. Der optimale Plan behandelt also bevorzugt Bagatellen und lässt schwere Fälle warten — medizinisch das Gegenteil des Gewollten.
Das ist genau der Ertrag von Frage 5: Der Plan lässt sich ablehnen, und aus der Ablehnung folgt die richtige Zielgröße — etwa die maximale Wartezeit je Dringlichkeitsstufe, oder die Einhaltung von Zielzeiten je Triage-Kategorie.
Der Kompromiss dazwischen. Je Stammkunde eine Nebenbedingung: Die Summe der zugeteilten Stunden dieses Kunden muss mindestens die Hälfte seiner angefragten Stunden betragen.
-
for kunde in STAMMKUNDEN:
- stunden_kunde = [a[3] if a[1] == kunde else0for a in AUFTRAEGE]
- je_kunde_ub.append([-s for s in stunden_kunde])
- je_kunde_b.append(-0.5*sum(stunden_kunde))
+
for kunde in STAMMKUNDEN:
+ stunden_kunde = [a[3] if a[1] == kunde else0for a in AUFTRAEGE]
+ je_kunde_ub.append([-s for s in stunden_kunde])
+ je_kunde_b.append(-0.5*sum(stunden_kunde))
Erwartung: Das Ergebnis liegt zwischen Lesart A und B — die Regel bindet stärker als „ein Auftrag genügt”, aber schwächer als „alle Aufträge”. Der Lehrpunkt ist nicht die Zahl, sondern dass sich zwischen zwei Lesarten beliebig fein abstufen lässt, sobald man sie einmal als Ungleichung geschrieben hat.
Zwei Ziele gleichzeitig. Bei \lambda = 0 ergibt sich der DB-Plan (78 500 € DB), bei großem \lambda der Umsatzplan (66 000 € DB). Dazwischen kippt es an genau einer Stelle — und zwar sprunghaft, nicht allmählich: Weil die Entscheidungsvariablen binär sind, gibt es nur endlich viele Pläne, und der Wechsel erfolgt, sobald ein anderer Plan den höheren gewichteten Wert hat.
Der Kippwert sagt dem Betrieb etwas Konkretes: „Erst wenn Ihnen ein Euro Umsatz mehr wert ist als λ Euro Deckungsbeitrag, ändert sich Ihr Plan.” Das ist eine Frage, die ein Kaufmann beantworten kann — anders als „welche Gewichtung hätten Sie gern?“.
@@ -28321,10 +28689,10 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
Zwei fehlende Prüfungen. Erstens der Entartungstest: Sind mehr Nebenbedingungen aktiv als Variablen vorhanden, ist der Wert 50 € nur einer von vielen möglichen und hätte gar nicht als Einzelwert berichtet werden dürfen (siehe Toleranzen_und_Entartung.py). Zweitens der Gültigkeitsbereich: Bis zu welcher Kapazitätserhöhung bleibt dieser Schattenpreis überhaupt bestehen? Ohne diese Spanne ist die Zahl nicht interpretierbar.
Nicht extrapolieren — nachrechnen. Der zusätzliche Nutzen ergibt sich aus einem zweiten Solverlauf, nicht aus einer Multiplikation:
Das ist die allgemeine Regel für jede Sensitivitätsaussage über mehr als eine Grenzeinheit: Der Schattenpreis zeigt die Richtung, die Neuberechnung liefert den Betrag. Bei einer Investitionsentscheidung kostet ein zusätzlicher Solverlauf Millisekunden — und hier hätte er 380 000 € gespart.
Micro-Quiz
1 — (b). Genau der Satz vom komplementären Schlupf: s_i \cdot y_i = 0. Ist Schlupf vorhanden, muss der Dualwert null sein. (a) hat die Aussage in ihr Gegenteil verkehrt; (c) verwechselt Schlupf mit Auslastung — s_i = 12 heißt gerade, dass 12 Einheiten übrig sind.
@@ -28362,17 +28730,17 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
Die Prüfung. Drei Tests, zusammen keine zwanzig Zeilen, die in jedes MILP-Auswertungsskript gehören:
-
# 1. Sind die Binaervariablen wirklich binaer?
-assert np.abs(y - np.round(y)).max() <=1e-6, "y ist nicht ganzzahlig"
-
-# 2. Widerspricht sich die Loesung selbst?
-liefert = x.sum(axis=1) >1e-6
-assertnot (liefert & (y <0.5)).any(), "Lager liefert, gilt aber als geschlossen"
-
-# 3. Stimmt der Zielwert mit den Kosten der Loesung ueberein?
-echte_kosten = FIXKOSTEN[liefert].sum() + (TRANSPORT * x).sum()
-assertabs(echte_kosten - zielwert) <1e-4* echte_kosten, \
-f"Zielwert {zielwert} passt nicht zu den echten Kosten {echte_kosten}"
+
# 1. Sind die Binaervariablen wirklich binaer?
+assert np.abs(y - np.round(y)).max() <=1e-6, "y ist nicht ganzzahlig"
+
+# 2. Widerspricht sich die Loesung selbst?
+liefert = x.sum(axis=1) >1e-6
+assertnot (liefert & (y <0.5)).any(), "Lager liefert, gilt aber als geschlossen"
+
+# 3. Stimmt der Zielwert mit den Kosten der Loesung ueberein?
+echte_kosten = FIXKOSTEN[liefert].sum() + (TRANSPORT * x).sum()
+assertabs(echte_kosten - zielwert) <1e-4* echte_kosten, \
+f"Zielwert {zielwert} passt nicht zu den echten Kosten {echte_kosten}"
Test 3 ist der wichtigste. Er vergleicht nicht Modell mit Modell, sondern Modell mit Wirklichkeit — er rechnet die Kosten aus der ausgegebenen Lösung neu aus, ganz ohne Solver. Damit fängt er nicht nur den Big-M-Fehler, sondern jeden Fehler, bei dem die Zielfunktion etwas anderes bilanziert als der Plan tatsächlich kostet.
Vorbeugend gilt weiter die Regel aus dem Kapitel: M so klein wie möglich, hergeleitet aus einer echten Kapazität. Hier wäre das schlicht die Lagerkapazität — mehr kann ein Lager ohnehin nicht ausliefern.
Micro-Quiz
@@ -28394,15 +28762,15 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
6.3 — Regeln ergänzen. (a) for s in (2,3): modell.Add(x["Frau_Albrecht", s] == 0) (b) Hilfsvariablen arbeitet_bauer, arbeitet_koch per AddMaxEquality an die Zuweisungssummen koppeln, dann modell.Add(arbeitet_bauer + arbeitet_koch <= 1). (c) Belohnung = negative Strafe: strafterme.append(-30 * folge_var), wobei folge_var per AddBoolAnd/OnlyEnforceIf an x_{p,0} \wedge x_{p,1} gekoppelt wird.
6.4 — Infeasibility. Mit MAX_VERTRETUNGEN = 0 meldet der Solver INFEASIBLE. Nach Einbau der Schlupfvariablen lässt der Solver alle vier Stunden ausfallen (4 × 10 000 = 40 000 Strafpunkte) — es geht nicht anders. Interessanter wird es bei MAX_VERTRETUNGEN = 1: Dann fällt genau die Stunde aus, für die am wenigsten qualifiziertes Personal zur Verfügung steht.
6.5 — Sudoku.
-
x = [[m.NewIntVar(1, 9, f"x{i}{j}") for j inrange(9)] for i inrange(9)]
-for i inrange(9): m.AddAllDifferent(x[i]) # Zeilen
-for j inrange(9): m.AddAllDifferent([x[i][j] for i inrange(9)]) # Spalten
-for bi inrange(3):
-for bj inrange(3):
- m.AddAllDifferent([x[3*bi+i][3*bj+j] for i inrange(3) for j inrange(3)])
-for i inrange(9):
-for j inrange(9):
-if vorgabe[i][j]: m.Add(x[i][j] == vorgabe[i][j])
+
x = [[m.NewIntVar(1, 9, f"x{i}{j}") for j inrange(9)] for i inrange(9)]
+for i inrange(9): m.AddAllDifferent(x[i]) # Zeilen
+for j inrange(9): m.AddAllDifferent([x[i][j] for i inrange(9)]) # Spalten
+for bi inrange(3):
+for bj inrange(3):
+ m.AddAllDifferent([x[3*bi+i][3*bj+j] for i inrange(3) for j inrange(3)])
+for i inrange(9):
+for j inrange(9):
+if vorgabe[i][j]: m.Add(x[i][j] == vorgabe[i][j])
Etwa 10 Zeilen Modellcode — und der Solver löst jedes Sudoku in Millisekunden. Das ist die Stärke globaler Constraints.
6.6 — Job-Shop erweitern. (a) Rüstzeiten: AddNoOverlap durch paarweise Disjunktionen mit Übergangszeit ersetzen, oder AddCircuit je Maschine mit Übergangsmatrix. (b) Verspätung: tardiness = MaxEquality(0, ende - faellig), in die Zielfunktion. (c) AddCumulative(intervalle, [1]*n, 2) statt AddNoOverlap für die Doppelmaschine.
6.7 — Wochendienstplan. Siehe Projekt P2. Kernpunkte: Nachtschicht-Folgeregel über AddImplication; „höchstens 5 Tage in Folge“ über gleitende Fenster (sum(x[p,t..t+5]) <= 5).
@@ -28556,12 +28924,12 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
Die Prüfung — und warum sie unabhängig sein muss.
-
for fahrzeug, (ladung, kapazitaet) inenumerate(zip(ladungen, KAPAZITAETEN)):
-assert ladung <= kapazitaet, \
-f"Fahrzeug {fahrzeug +1}: {ladung} Paletten bei Kapazitaet {kapazitaet}"
+
for fahrzeug, (ladung, kapazitaet) inenumerate(zip(ladungen, KAPAZITAETEN)):
+assert ladung <= kapazitaet, \
+f"Fahrzeug {fahrzeug +1}: {ladung} Paletten bei Kapazitaet {kapazitaet}"
Dabei wird ladungaus der ausgegebenen Tour aufsummiert — also aus der Liste der angefahrenen Kunden und deren Bedarfen. Entscheidend ist, was man dafür nicht benutzt:
-
# FALSCH als Pruefung:
-ladung = loesung.Value(routing.GetDimensionOrDie("Ladung").CumulVar(index))
+
# FALSCH als Pruefung:
+ladung = loesung.Value(routing.GetDimensionOrDie("Ladung").CumulVar(index))
Diese Zeile fragt das Modell, ob das Modell sich an sich selbst hält. Existiert die Dimension „Ladung“ gar nicht, stürzt sie ab — und existiert sie, kann sie per Konstruktion nie verletzt sein. Sie prüft also entweder nichts oder gar nichts.
Die allgemeine Regel: Eine Prüfung, die dieselben Bausteine verwendet wie das Modell, prüft das Modell gegen sich selbst. Nützlich ist nur eine Prüfung, die von der ausgegebenen Lösung ausgeht und die Anforderungen der Wirklichkeit unabhängig nachrechnet — genau wie die Kostengegenrechnung in Denkfehler 5.1 und die Verbrauchsprüfung in Denkfehler 1.1.
Micro-Quiz
@@ -28687,9 +29055,9 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
Die Eigenwerte von \mathbf{R} lauten (-0{,}8;\ 1{,}9;\ 1{,}9). Ein negativer Eigenwert bedeutet: Die Matrix ist nicht positiv semidefinit. Es gibt eine Richtung im Gewichtsraum — hier ungefähr (0{,}58;\ -0{,}58;\ 0{,}58), der Eigenvektor zu -0{,}8 —, entlang derer die quadratische Form negativ wird. Genau diese Richtung hat scipy gefunden: Das Ergebnis (1{,}5;\ -2{,}0;\ 1{,}5) ist ein Vielfaches davon.
Der Test dafür ist eine Zeile und gehört vor jede Risikorechnung:
Warum das kein Rechenfehler ist.scipy hat exakt die Aufgabe gelöst, die man ihm gestellt hat: „minimiere \mathbf{w}^\top\mathbf{S}\mathbf{w}“. Dass dieser Ausdruck bei nicht positiv semidefinitem \mathbf{S} beliebig negativ werden kann, ist eine Eigenschaft der Eingabe, nicht des Verfahrens. success: True heißt hier — wie in Abschnitt 11.6 — nur: „Ich bin an einer Stelle angekommen, an der es nicht weiter bergab geht.“ Dass es überhaupt bis -0{,}254 bergab ging, lag allein an den Schranken \pm 2; ohne sie liefe das Verfahren gegen -\infty.
@@ -28721,13 +29089,14 @@ Das zugehoerige Dockerfile ist kurz genug, um es ganz zu zeigen:
A.12 Lösungen zu Kapitel „Optimierung unter Unsicherheit — Monte-Carlo, Stochastik, Robustheit“
9.1 — Fluch des Durchschnitts. Weil sich Verzögerungen fortpflanzen und die Verteilung rechtsschief ist: Ein Gewerk, das früher fertig wird, beschleunigt selten den Gesamtablauf (der nächste Schritt ist noch nicht bereit), ein verspätetes verzögert alles. Weitere Beispiele: Wartezeiten in Warteschlangen (nichtlinear in der Auslastung), Projektkosten mit Nachträgen.
-
9.2 — Ansatz wählen. (a) robust (Ausfall ist existenziell, Wahrscheinlichkeiten unzuverlässig). (b) stochastisch (Verteilung bekannt). (c) robust bzw. Extremwertstatistik (seltene Ereignisse, katastrophale Folgen). (d) stochastisch (Szenarien mit Wahrscheinlichkeiten liegen vor).
+
9.2 — Ansatz wählen. (a) robust (Ausfall ist existenziell, Wahrscheinlichkeiten unzuverlässig). (b) stochastisch (Verteilung bekannt). (c) robust bzw. Extremwertstatistik (seltene Ereignisse, katastrophale Folgen). (d) stochastisch (Szenarien mit Wahrscheinlichkeiten liegen vor). (e) Chance Constraint — die Zusage ist bereits als Quote formuliert („an 99 % aller Wintertage“). Robust wäre hier zu teuer (es gibt immer einen kälteren Tag), der Erwartungswert zu schwach (er sagt nichts über die Zusage).
9.3 — Zweistufig rechnen. (a) Mit Spot = 60: Ableitung bei x \in (100,250): 40 - 60\cdot0{,}5 + 5\cdot0{,}5 = 40-30+2{,}5 = +12{,}5 > 0 → Kapazität senken. Bei x < 100: 40 - 60 = -20 < 0 → erhöhen. Optimum daher x^* = 100. (b) Billige Nachbesserung macht Vorhalten unattraktiv. (c) Bei x = 225 optimal müsste die Ableitung dort das Vorzeichen wechseln — das ist bei diskreten Szenarien nur zufällig der Fall; der Mittelwert ist nur bei symmetrischen Kosten und stetiger Verteilung optimal.
9.4 — EVPI interpretieren. Selbst eine perfekte Prognose wäre nur 8 400 € pro Jahr wert. Eine Lösung für 15 000 € kann sich also niemals rechnen — unabhängig von ihrer Güte. Argument: „Der theoretische Maximalnutzen liegt unter dem Preis.“
9.5 — Monte-Carlo erweitern. (a) Servicelevel 95 %: Kapazität = 95 %-Quantil des Bedarfs (ca. 485). Die Zusatzkosten gegenüber dem Kostenoptimum (180) betragen ein Vielfaches — Servicelevel ist teuer, und genau diese Zahl braucht die Geschäftsleitung für die Entscheidung. (b) cvar = kosten[kosten >= np.quantile(kosten, 0.95)].mean(). (c) Erwartetes Bild: Bei kleiner Kapazität ist die Kostenverteilung stark rechtsschief (seltene, teure Notzukäufe); bei großer Kapazität schmal und nach rechts verschoben.
9.6 — Den Preis der Zusage selbst bestimmen. (a) Die Kosten je Prozentpunkt steigen weiter steil an; der Mix verschiebt sich dabei fast nur noch zugunsten des Gaskraftwerks, weil Biomasse längst an ihrer Ausbaugrenze liegt. Wind und Sonne bleiben bei einem kleinen Rest — sie senken über ihre Gegenläufigkeit die Norm, tragen zur gesicherten Leistung aber kaum bei. (b) Der Unterschied ist nicht numerisch, sondern inhaltlich. Bei 200 MW Biomasse liefert selbst der Vollausbau in der Kältewelle nur rund 440 MW — 7,5 % der Szenarien sind schlicht nicht bedienbar, egal wie viel Geld man ausgibt. Das Szenariomodell sieht diese Fälle in den Daten und meldet korrekt infeasible, sobald die Zusage über 92 % steigt. Das analytische Modell kennt sie nicht: Für eine Normalverteilung ist jede Zusage unter 100 % erfüllbar, wenn man nur genug Streuung wegkauft. Es meldet optimal — und der Plan hält gemessen 87,4 %. Glauben sollte man dem Szenariomodell: Ein infeasible ist hier die richtige Antwort. Es sagt, dass die Zusage nicht am Budget scheitert, sondern am Kraftwerkspark, und dass nicht mehr Geld hilft, sondern nur eine andere Anlage oder eine kleinere Zusage. (c) Sinngemäß: „99,99 % kosten nicht 5 % mehr als 99 %, sondern ein Vielfaches — der letzte Prozentpunkt ist bereits 4,5-mal so teuer wie der erste. Sagen Sie mir, was ein Ausfalltag das Unternehmen kostet, dann rechne ich aus, welche Quote sich lohnt. Und die Zusage gilt nur für die Wetterlagen, die wir modelliert haben — die Kältewelle gehört hinein.“
\Gamma = 0 entspricht dem nominalen Fall, \Gamma = n dem vollen Worst Case. Dazwischen steuert \Gamma die Vorsicht stufenlos — der praktisch nützlichste Bereich liegt meist bei \Gamma \approx \sqrt{n}.
Finde den Denkfehler 9.1 — Warum jedes Projekt zu spät fertig wird
Die Zahl für den Projektplan. Bei einer geforderten Sicherheit von 90 % gehört das 90-%-Quantil in den Plan: 16,6 Tage, aufgerundet 17. Das sind 47 % mehr als die ursprünglich geplanten 11,3 Tage.
Und die Formulierung gegenüber dem Auftraggeber lautet nicht „der Umbau dauert 17 Tage“, sondern: „In 9 von 10 Fällen sind wir nach 17 Tagen fertig; im Mittel nach 14,4.“ Diese zwei Zahlen sind eine belastbare Zusage — eine einzelne ist es nie.
Micro-Quiz
1 — (b). Das kritische Verhältnis 1400/(1400+120) = 0{,}921 gibt an, wie weit man sich auf die günstigere Fehlerseite stellen soll: Bestellt wird das 92,1-%-Quantil des Bedarfs, hier 38 Stück. (a) ignoriert die Kostenasymmetrie und kostet im Kapitelbeispiel 160 % mehr. (c) verwechselt die Fragestellung: Die Kapitalbindung ist bereits in den 120 € je überzähligem Stück enthalten — sie rechtfertigt keine zusätzliche Kürzung.
Die Differenz zwischen Kosten unter Unsicherheit und Kosten bei perfektem Wissen — eine Obergrenze für den Wert jeder Prognoseverbesserung.
Weil sie nur eine Menge möglicher Werte braucht, nicht deren Verteilung — sie optimiert gegen das schlechteste Element dieser Menge.
Für einfache Mengen (Box, Ellipsoid) lässt sich das innere Maximum geschlossen ausrechnen und wird zu einem Abzugsterm; das Gesamtproblem bleibt konvex.
+
Weil die Streuung der Summe \mathbf{a}^\top\mathbf{x} nicht die Summe der Streuungen ist: \sqrt{\mathbf{x}^\top\boldsymbol{\Sigma}\mathbf{x}} enthält die Kovarianzen. Ein fester Zuschlag je Variable wäre linear und würde deshalb übersehen, dass sich gegenläufige Größen teilweise aufheben — die Norm belohnt Mischung, ein linearer Aufschlag nicht.
+
Weil die Zusage nur so gut ist wie die unterstellte Verteilung. Die Normalverteilung kennt keine Ereignisse, in denen alle Quellen gleichzeitig ausfallen; ihre schwachen Korrelationen unterschätzen genau den Fall, der die Zusage bricht. Die szenariobasierte Formulierung hat diese Fälle in den Daten und trifft die Quote — erkauft mit Mehrkosten und mit dem Risiko, sich an die verwendete Stichprobe anzupassen.
A.13 Lösungen zu Kapitel „Dynamische Programmierung — Die Bellman-Gleichung und Order-Execution“
10.2 — Optimalitätsprinzip. Weil der Wert eines Zustands nur von den künftigen Entscheidungen abhängt, kann man ihn ab dem Ende rekursiv berechnen. Hängen die Kosten zusätzlich von früheren Aktionen ab, ist die Markov-Eigenschaft verletzt — die Lösung: die relevante Vergangenheit in den Zustand aufnehmen (dann wächst allerdings der Zustandsraum).
10.3 — Rückwärtsinduktion. Mit C(n) = n^2 + 2n und 5 Einheiten in 3 Perioden ergibt sich als optimaler Pfad 2 \to 2 \to 1 (oder eine Permutation davon) mit Gesamtkosten 8 + 8 + 3 = 19. Alles auf einmal kostet 25 + 10 = 35.
10.4 — Rucksack als DP.
-
V = np.zeros((n +1, kapazitaet +1))
-for i inrange(1, n +1):
-for c inrange(kapazitaet +1):
- V[i][c] = V[i-1][c]
-if gewicht[i-1] <= c:
- V[i][c] =max(V[i][c], V[i-1][c - gewicht[i-1]] + nutzen[i-1])
+
V = np.zeros((n +1, kapazitaet +1))
+for i inrange(1, n +1):
+for c inrange(kapazitaet +1):
+ V[i][c] = V[i-1][c]
+if gewicht[i-1] <= c:
+ V[i][c] =max(V[i][c], V[i-1][c - gewicht[i-1]] + nutzen[i-1])
Ergebnis identisch zum MILP (110). Laufzeit O(n \cdot C) — bei kleinen ganzzahligen Kapazitäten schneller als Branch-and-Bound, bei großen oder nicht-ganzzahligen Kapazitäten schlechter (dann ist das MILP überlegen). Man nennt das pseudopolynomiell.
10.5 — Risikoaversion kalibrieren. (a) Zwischen \lambda = 10^{-5} (41 %) und 10^{-4} (78 %); genauer etwa bei \lambda \approx 3\cdot10^{-5}. (b) Höheres \lambda → höhere Gesamtkosten (man kauft Sicherheit mit Slippage). (c) Iterativ: \lambda so wählen, dass der Anteil der Marktauswirkungskosten an den Gesamtkosten bei 20 % liegt — im Programm ausrechnen und per Bisektion einstellen.
10.6 — Zustandsraum erweitern. Der Zustand wird zu (Restbestand, Orderbuchtiefe); der Zustandsraum verdreifacht sich, die Rechenzeit ebenfalls. Die Strategie wird zustandsabhängig: Bei tiefem Buch wird mehr verkauft, bei dünnem gewartet. Genau diese Adaptivität ist der Mehrwert von DP gegenüber einem festen Pfad.
berechnet die Rüstkosten allein daraus, ob in dieser Periode produziert wird. Die tatsächliche Regel lautet aber: Rüstkosten fallen an, wenn produziert wird und in der Vorperiode nicht. Ob in der Vorperiode produziert wurde, steht nirgends im Zustand (t, lager) — die Funktion kann es gar nicht wissen.
Das Modell nimmt deshalb an, dass jede Produktionsperiode eine Rüstung kostet. Es bestraft laufende Produktion, obwohl sie in Wirklichkeit gratis weiterläuft.
Der korrigierte Zustand lautet (t, lager, lief_vorher) mit einem zusätzlichen Wahrheitswert:
-
@functools.lru_cache(None)
-def V(t, lager, lief_vorher):
- ...
- kosten = (RUESTKOSTEN if (p >0andnot lief_vorher) else0) + ...
- rest, plan = V(t +1, neu, p >0)
+
@functools.lru_cache(None)
+def V(t, lager, lief_vorher):
+ ...
+ kosten = (RUESTKOSTEN if (p >0andnot lief_vorher) else0) + ...
+ rest, plan = V(t +1, neu, p >0)
Damit findet die Rückwärtsinduktion (3, 1, 4, 2) für 70 € — das nachgerechnete Optimum. Der Zustandsraum verdoppelt sich (je Lagerstand zwei Varianten, „lief“ und „lief nicht“). Das ist der übliche Preis: Vollständigkeit des Zustands kostet Größe, und genau hier beginnt der Fluch der Dimensionalität.
Warum der richtige Betrag das Tückische ist. Man würde erwarten, dass ein falsches Modell auch einen falschen Kostenbetrag ausgibt — dann fiele es beim Nachrechnen auf. Hier nicht: Für den von ihm gewählten Plan (5,0,5,0) stimmt die Rechnung zufällig, weil in diesem Plan tatsächlich jede Produktionsperiode auf eine Pause folgt. Modellannahme und Wirklichkeit fallen für genau diese eine Lösung zusammen.
Den Schnellstart reparieren. Zwei Wege: (a) Abrunden statt runden — np.floor statt np.round. Das ist immer zulässig, weil weniger produzieren nie eine Kapazität sprengt, kostet aber Deckungsbeitrag und ist im Allgemeinen nicht die optimale ganzzahlige Lösung. (b) Ganzzahlig modellieren — linprog(..., integrality=1) bzw. IntVar. Das ist der richtige Weg.
Warum das ein eigenes Kapitel wert ist (Kapitel 6): Runden ist nicht nur ungenau, sondern kann beliebig danebenliegen. Es gibt Instanzen, bei denen die gerundete LP-Lösung nicht nur suboptimal, sondern unzulässig ist — genau das zeigt der Schnellstart — und andere, bei denen zwischen gerundetem LP und echtem Optimum Welten liegen (Runden_Gegenbeispiel.py).
Eine Invariante mehr.
-
def test_kapazitaeten_verdoppeln(bauer):
- problem = schreinerei()
- doppelt = Produktionsproblem(
- produkte=problem.produkte,
- kapazitaeten={r: 2* k for r, k in problem.kapazitaeten.items()})
- basis, gross = bauer(problem), bauer(doppelt)
-assert gross.zielwert == pytest.approx(2* basis.zielwert, rel=1e-9)
+
def test_kapazitaeten_verdoppeln(bauer):
+ problem = schreinerei()
+ doppelt = Produktionsproblem(
+ produkte=problem.produkte,
+ kapazitaeten={r: 2* k for r, k in problem.kapazitaeten.items()})
+ basis, gross = bauer(problem), bauer(doppelt)
+assert gross.zielwert == pytest.approx(2* basis.zielwert, rel=1e-9)
Bei einem LP gilt das exakt: Der zulässige Bereich wird um den Faktor 2 gestreckt, und weil die Zielfunktion linear ist, skaliert das Optimum mit. Bei einem MILP gilt es nicht: Die Ganzzahligkeitsbedingung skaliert nicht mit. Aus 30,67 wird beim Verdoppeln 61,33 — und ob dazwischen eine bessere ganzzahlige Lösung liegt, hängt vom Einzelfall ab. Der Test wäre dort also falsch.
Eine eigene Mutation. Zwei lohnende Kandidaten:
-
("Kapazitaetspruefung mit falschem Vergleich",
-"if ist > grenze + toleranz:", "if ist < grenze - toleranz:"),
-("Toleranz mit falschem Vorzeichen",
-"if (mengen < -toleranz).any():", "if (mengen < toleranz).any():"),
+
("Kapazitaetspruefung mit falschem Vergleich",
+"if ist > grenze + toleranz:", "if ist < grenze - toleranz:"),
+("Toleranz mit falschem Vorzeichen",
+"if (mengen < -toleranz).any():", "if (mengen < toleranz).any():"),
Die erste wird getötet (test_pruefung_findet_kapazitaetsverletzung). Die zweite ist interessanter: Sie macht die Prüfung strenger statt schwächer — eine Lösung mit einer Menge von exakt 0 würde als negativ beanstandet. Ob sie überlebt, hängt daran, ob die Suite eine Instanz enthält, in der ein Produkt mit Menge 0 vorkommt. In der Schreinerei ist das nicht der Fall — die Mutation überlebt also und zeigt eine echte Lücke: Es fehlt eine Testinstanz, in der ein Produkt nicht produziert wird.
Der Benchmark mit MILP. Zu erwarten ist, dass sich die Reihenfolge ändert. Beim reinen LP entscheidet vor allem der Modellaufbau in Python; beim MILP verschiebt sich das Gewicht zum Lösen, und dort spielen die Branch-and-Bound-Heuristiken der Bibliotheken gegeneinander. Die Spalte „Anteil” sollte bei allen deutlich fallen — nicht weil der Aufbau schneller würde, sondern weil das Lösen langsamer wird. Genau deshalb steht im Kapitel, dass die Tabelle nichts über MILPs sagt.
Der Dienst mit Zeitlimit. Der Modellbauer in or_kern.py nimmt bisher kein Zeitlimit entgegen — das ist der erste Schritt (bei GLOP solver.SetTimeLimit(millisekunden)). Danach im Arbeiter durchreichen und den Status auswerten: Liefert der Solver ZEITLIMIT, ist stand weiterhin gescheitert, aber mit einer anderen Begründung als bei UNZULAESSIG — der Unterschied zwischen „rechne länger” und „ändere das Modell” (Kapitel 6).
# y_s ist EINE Variable je Lieferant - nicht eine je Lieferant UND Periode
-y = {s: modell.NewBoolVar(f"vertrag_{s}") for s in lieferanten}
-for s in lieferanten:
- jahresmenge =sum(x[s, t] for t in perioden)
- modell.Add(jahresmenge >= MINDESTMENGE[s] * y[s])
- modell.Add(jahresmenge <= JAHRESKAPAZITAET[s] * y[s]) # koppelt x an y
+
# y_s ist EINE Variable je Lieferant - nicht eine je Lieferant UND Periode
+y = {s: modell.NewBoolVar(f"vertrag_{s}") for s in lieferanten}
+for s in lieferanten:
+ jahresmenge =sum(x[s, t] for t in perioden)
+ modell.Add(jahresmenge >= MINDESTMENGE[s] * y[s])
+ modell.Add(jahresmenge <= JAHRESKAPAZITAET[s] * y[s]) # koppelt x an y
⚠️ Die häufigste Verwechslung:L\,y_t \le x_tje Periode (B2) ist ein anderes Modell — es verlangt in jeder einzelnen Periode eine Mindestmenge und ist erheblich strenger. Wer den Jahresvertrag so formuliert, erzeugt ein unlösbares Modell und sucht den Fehler dann in den Daten.
Regel. „Alle Maßnahmen zusammen dürfen 2 Mio. € nicht überschreiten.“
\sum_i c_i x_i \le B
Mit x_i \in \{0,1\} ist das ein Rucksackproblem — dasselbe Muster, das Kapitel 6 an Rucksack.py vorrechnet und Kapitel 10 als Pricing-Teilproblem wiederverwendet.
-
modell.Add(sum(KOSTEN[i] * x[i] for i in massnahmen) <= BUDGET)
-# Mehrere Toepfe: je Topf eine Zeile - NICHT die Summe ueber alle Toepfe
-for topf, grenze in BUDGETS.items():
- modell.Add(sum(KOSTEN[i] * x[i] for i in massnahmen
-if TOPF[i] == topf) <= grenze)
+
modell.Add(sum(KOSTEN[i] * x[i] for i in massnahmen) <= BUDGET)
+# Mehrere Toepfe: je Topf eine Zeile - NICHT die Summe ueber alle Toepfe
+for topf, grenze in BUDGETS.items():
+ modell.Add(sum(KOSTEN[i] * x[i] for i in massnahmen
+if TOPF[i] == topf) <= grenze)
💡 Der Schattenpreis des Budgets ist die Zahl, nach der die Geschäftsführung fragt: „Was bringt der nächste Euro?“ Bei einem LP ist er direkt ablesbar. Bei Ganzzahligkeit gibt es ihn nicht — die Dualwerte der Relaxation sind keine gültige Antwort. Rechnen Sie stattdessen mit erhöhtem Budget neu und vergleichen Sie die Zielwerte (Abschnitt 5.9).
B17 verbucht den Wechsel in der Zielfunktion. Sobald die Maschine ausgelastet ist, gehört er zusätzlich in die Kapazitätszeile:
\sum_j a_j\,x_{j,t} \;+\; \sum_j r_j\,y_{j,t} \;\le\; C_t \qquad \forall t
mit a_j Stückzeit, r_j Rüstzeit, y_{j,t}\in\{0,1\} = „Produkt j läuft in Periode t“ (gekoppelt über B1: x_{j,t} \le M\,y_{j,t}).
-
for t in perioden:
- modell.Add(sum(STUECKZEIT[j] * x[j, t] for j in produkte)
-+sum(RUESTZEIT[j] * y[j, t] for j in produkte) <= KAPAZITAET[t])
+
for t in perioden:
+ modell.Add(sum(STUECKZEIT[j] * x[j, t] for j in produkte)
++sum(RUESTZEIT[j] * y[j, t] for j in produkte) <= KAPAZITAET[t])
⚠️ Wer die Rüstzeit nur als Kosten führt, erhält Pläne, die in der Halle nicht laufen. Das Modell verteilt die Produktion dann auf viele kleine Lose, weil ein zusätzlicher Wechsel zwar etwas kostet, aber keine Zeit verbraucht — die Rechnung geht auf dem Papier auf und in der Schicht nicht.
Bedeutung. Es gibt keinen einzigen Punkt, der alle Bedingungen gleichzeitig erfüllt. Das ist eine Aussage über Ihr Modell, nicht über den Solver.
Diagnose in fünf Schritten
Schritt 1 — Trivialprüfungen von Hand. Rechnen Sie die offensichtlichen Bilanzen nach, bevor Sie den Solver befragen:
-
assert summe_angebot >= summe_bedarf, "Angebot deckt Bedarf nicht"
-assert anzahl_personal * max_schichten >= anzahl_schichten, "zu wenig Personal"
-for slot, fach inenumerate(faecher):
- qualifiziert = [p for p in personal if fach in qualifikation[p]]
-assert qualifiziert, f"Fuer Slot {slot} ({fach}) gibt es niemanden"
+
assert summe_angebot >= summe_bedarf, "Angebot deckt Bedarf nicht"
+assert anzahl_personal * max_schichten >= anzahl_schichten, "zu wenig Personal"
+for slot, fach inenumerate(faecher):
+ qualifiziert = [p for p in personal if fach in qualifikation[p]]
+assert qualifiziert, f"Fuer Slot {slot} ({fach}) gibt es niemanden"
Erfahrungsgemäß findet dieser Schritt die Mehrzahl aller Fälle.
Schritt 2 — Bedingungen einzeln abschalten. Kommentieren Sie Bedingungsgruppen nacheinander aus. Wird das Modell lösbar, ist die zuletzt entfernte Gruppe (mit-)verantwortlich.
Nach dem Lösen zeigen die Schlupfvariablen mit Wert > 0 präzise, welche Bedingung wo und um wie viel verletzt werden musste. Das ist zugleich die produktionstaugliche Lösung (Muster B18).
Schritt 4 — Zeitfenster und Erreichbarkeit prüfen. Bei Scheduling und Routing: Ist jeder Termin überhaupt physisch erreichbar?
-
for kunde, (frueh, spaet) inenumerate(zeitfenster):
-assert distanz[depot][kunde] <= spaet, f"Kunde {kunde} nicht rechtzeitig erreichbar"
+
for kunde, (frueh, spaet) inenumerate(zeitfenster):
+assert distanz[depot][kunde] <= spaet, f"Kunde {kunde} nicht rechtzeitig erreichbar"
Schritt 5 — Rundungs- und Einheitenfehler. Sind Kapazitäten in Stunden, Verbräuche aber in Minuten? Rechnen CP-SAT-Modelle mit ganzen Zahlen, wo Sie Nachkommastellen brauchen? Ein klassischer Fall: sum(w) == 1 mit Gleitkommazahlen — nutzen Sie eine Toleranz oder rechnen Sie in Ganzzahlen (Promille).
Wenn die fünf Schritte nicht reichen: den Konflikt einkreisen
Warum Schritt 2 so oft ins Leere läuft. Bedingungen einzeln abzuschalten funktioniert nur, solange es einen Schuldigen gibt. Überlagern sich zwei unabhängige Widersprüche, bleibt das Modell nach jeder einzelnen Abschaltung unlösbar — die Suche endet mit null Treffern, obwohl beide Widersprüche unverändert im Modell stehen. Genau dann steht man vor einem Modell mit tausenden Restriktionen und einem Statuscode.
Nimm eine Bedingung versuchsweise heraus. Bleibt der Rest unlösbar, wurde sie nicht gebraucht: weg damit, endgültig. Wird der Rest lösbar, war sie beteiligt: sie bleibt.
Nach genau einem Durchlauf über alle n Bedingungen ist die übrig gebliebene Menge unreduzierbar — entfernt man aus ihr irgendeine Bedingung, ist der Rest lösbar. Das ist ein Irreducible Infeasible Subset, kurz IIS. Kommerzielle Solver bieten das als fertige Funktion an (computeIIS bei Gurobi, der Conflict Refiner bei CPLEX); im Open-Source-Werkzeugkasten dieses Buchs gibt es sie nicht — sie ist aber in zwanzig Zeilen selbst geschrieben, und sie kostet n Aufrufe des Solvers statt 2^n durchprobierter Teilmengen.
-
#!/usr/bin/env python3
-
-# Konfliktsuche.py
-"""
-Anhang Fehlerdiagnose: Den Konflikt finden, der INFEASIBLE verursacht.
-
-Ein Deletion Filter (auch: IIS, Irreducible Infeasible Subset) isoliert aus
-einem unloesbaren Modell eine kleinste widerspruechliche Teilmenge von
-Bedingungen: Nimmt man aus ihr auch nur eine einzige Bedingung heraus, ist
-der Rest wieder loesbar.
-
-Das Programm zeigt sechs Dinge:
- 1. Der Solver meldet INFEASIBLE - und sonst nichts.
- 2. Die naive Suche ("jede Bedingung einmal weglassen") findet hier gar nichts.
- 3. Der Deletion Filter findet einen Konflikt in n Solveraufrufen.
- 4. Ein Konflikt ist selten einer: nach der Reparatur folgt der naechste.
- 5. "Den" kleinsten Konflikt gibt es nicht - dieses Modell enthaelt neun.
- 6. Welchen davon man zu sehen bekommt, steuert die Pruefreihenfolge.
-
-Abgrenzung zu Infeasibility_Diagnose.py (Kapitel Praxisfallen): Dort geht es
-um die Relaxation - das Modell soll trotz Widerspruch eine brauchbare Antwort
-liefern. Hier geht es um die Diagnose - welche Bedingungen widersprechen sich
-ueberhaupt. Beides zusammen ergibt den Umgang mit INFEASIBLE in Produktion.
-
-Benoetigt: numpy, scipy
-"""
-
-from __future__ import annotations
-
-import numpy as np
-from scipy.optimize import linprog
-
-PRODUKTE = ["Rahmen", "Gehaeuse", "Deckel", "Traeger", "Halter"]
-
-# Jede Bedingung traegt einen sprechenden Namen - das ist keine Kosmetik,
-# sondern die Voraussetzung dafuer, dass der Befund lesbar wird.
-# (Name, Koeffizienten je Produkt, Richtung, rechte Seite)
-BEDINGUNGEN: list[tuple[str, list[float], str, float]] = [
- ("Kapazitaet Montage", [3, 2, 1, 4, 2], "<=", 400),
- ("Kapazitaet Lackieren", [2, 3, 0, 1, 0], "<=", 150),
- ("Kapazitaet Pruefung", [1, 1, 1, 1, 1], "<=", 300),
- ("Liefervertrag Rahmen", [1, 0, 0, 0, 0], ">=", 40),
- ("Liefervertrag Gehaeuse", [0, 1, 0, 0, 0], ">=", 30),
- ("Liefervertrag Deckel", [0, 0, 1, 0, 0], ">=", 20),
- ("Liefervertrag Traeger", [0, 0, 0, 1, 0], ">=", 25),
- ("Marktgrenze Rahmen", [1, 0, 0, 0, 0], "<=", 120),
- ("Marktgrenze Gehaeuse", [0, 1, 0, 0, 0], "<=", 90),
- ("Marktgrenze Deckel", [0, 0, 1, 0, 0], "<=", 150),
- ("Marktgrenze Halter", [0, 0, 0, 0, 1], "<=", 200),
- ("Mindestumsatz", [90, 70, 30, 60, 20], ">=", 12000),
- ("Sortimentsbreite", [0, 0, 1, 1, 1], ">=", 100),
- ("Lackierbudget Schicht 2", [0, 1, 0, 1, 0], "<=", 55),
-]
-
-# Welche Bedingungen koennte ein Planer im Ernstfall wirklich veraendern?
-# Kapazitaeten lassen sich durch Sonderschichten dehnen, Liefervertraege und
-# Marktgrenzen nicht.
-VERHANDELBAR = {"Kapazitaet Montage", "Kapazitaet Lackieren",
-"Kapazitaet Pruefung", "Lackierbudget Schicht 2"}
-
-aufrufe =0# zaehlt jeden Solveraufruf mit - der Preis des Verfahrens
-
-
-def ist_loesbar(auswahl: list[int]) ->bool:
-"""Gibt es einen Punkt, der ALLE Bedingungen aus 'auswahl' erfuellt?
-
- Es wird nur Zulaessigkeit geprueft, keine Zielfunktion optimiert: die
- Zielfunktion ist konstant null. INFEASIBLE haengt nie an der Zielfunktion.
- """
-global aufrufe
- aufrufe +=1
- matrix, rechte_seite = [], []
-for i in auswahl:
- _, koeffizienten, richtung, grenze = BEDINGUNGEN[i]
- zeile = np.array(koeffizienten, dtype=float)
-# linprog kennt nur "<=", also ">=" durch Negation umdrehen
- matrix.append(zeile if richtung =="<="else-zeile)
- rechte_seite.append(grenze if richtung =="<="else-grenze)
- ergebnis = linprog(np.zeros(len(PRODUKTE)),
- A_ub=np.array(matrix), b_ub=np.array(rechte_seite),
- bounds=(0, None), method="highs")
-return ergebnis.status ==0
-
-
-def deletion_filter(auswahl: list[int]) ->list[int]:
-"""Verkleinert eine unloesbare Menge zu einer kleinsten unloesbaren Menge.
-
- Der Kern des Verfahrens ist eine einzige Regel: Nimm eine Bedingung
- versuchsweise heraus. Bleibt der Rest unloesbar, wurde sie fuer den
- Widerspruch nicht gebraucht - sie darf endgueltig weg. Wird der Rest
- loesbar, war sie beteiligt und muss bleiben.
-
- Nach genau einem Durchlauf ueber alle Bedingungen ist das Ergebnis
- unreduzierbar: n Solveraufrufe statt 2^n Teilmengen.
- """
- rest =list(auswahl)
-for i inlist(auswahl):
- probe = [j for j in rest if j != i]
-ifnot ist_loesbar(probe):
- rest = probe
-return rest
-
-
-def namen(auswahl: list[int]) ->list[str]:
-return [BEDINGUNGEN[i][0] for i in auswahl]
-
-
-def teil_1_der_befund(alle: list[int]) ->None:
-print("="*68)
-print("1. Was der Solver sagt")
-print("="*68)
-print(f"Modell: {len(PRODUKTE)} Variablen, {len(BEDINGUNGEN)} Bedingungen")
- status ="OPTIMAL"if ist_loesbar(alle) else"INFEASIBLE"
-print(f"Status: {status}")
-print("\nMehr ist es nicht. Der Solver nennt keine Ursache, weil es die eine")
-print("Ursache nicht gibt: Unloesbarkeit ist eine Eigenschaft von Mengen von")
-print("Bedingungen, nicht von einzelnen Bedingungen.")
-
-
-def teil_2_die_naive_suche(alle: list[int]) ->None:
-print("\n"+"="*68)
-print("2. Die naheliegende Idee - und warum sie hier scheitert")
-print("="*68)
-print("Jede Bedingung einmal weglassen und schauen, ob es dann geht:\n")
- treffer = [i for i in alle if ist_loesbar([j for j in alle if j != i])]
-for i in alle:
-print(f" ohne {BEDINGUNGEN[i][0]:<26}{'loesbar'if i in treffer else'weiter unloesbar'}")
-print(f"\nGefundene Schuldige: {len(treffer)}")
-print("Keine einzelne Bedingung ist schuld. Genau das ist der Normalfall -")
-print("und der Grund, warum diese Suche in der Praxis so oft im Nichts endet.")
-
-
-def teil_3_der_filter(alle: list[int]) ->list[int]:
-global aufrufe
-print("\n"+"="*68)
-print("3. Der Deletion Filter")
-print("="*68)
- vorher = aufrufe
- konflikt = deletion_filter(alle)
- kosten = aufrufe - vorher
-print(f"{kosten} Solveraufrufe -> Konflikt aus {len(konflikt)} von "
-f"{len(BEDINGUNGEN)} Bedingungen:\n")
-for name in namen(konflikt):
-print(f" * {name}")
-print("\nProbe auf Unreduzierbarkeit - jede einzelne davon weglassen:")
-for i in konflikt:
- rest_loesbar = ist_loesbar([j for j in konflikt if j != i])
-print(f" ohne {BEDINGUNGEN[i][0]:<26}{'loesbar'if rest_loesbar else'UNLOESBAR - nicht minimal!'}")
-print(f"\nAufwand: {kosten} Aufrufe. Alle Teilmengen durchzuprobieren waeren "
-f"2^{len(BEDINGUNGEN)} = {2**len(BEDINGUNGEN):,} gewesen.".replace(",", "."))
-return konflikt
-
-
-def teil_4_der_naechste_konflikt(alle: list[int], konflikt: list[int]) ->None:
-print("\n"+"="*68)
-print("4. Ein Konflikt ist selten einer")
-print("="*68)
- entfernt = konflikt[0]
- rest = [i for i in alle if i != entfernt]
-print(f"Angenommen, '{BEDINGUNGEN[entfernt][0]}' laesst sich verhandeln")
-print("und wird aus dem Modell genommen. Dann ist das Modell ...")
-if ist_loesbar(rest):
-print("... loesbar. Fertig.")
-return
-print("... immer noch unloesbar. Der Filter erneut:\n")
- zweiter = deletion_filter(rest)
-for name in namen(zweiter):
-print(f" * {name}")
- gemeinsam =set(zweiter) &set(konflikt)
-print(f"\nUeberschneidung mit dem ersten Konflikt: {len(gemeinsam)} Bedingungen")
-print("Ein zweiter, unabhaengiger Widerspruch, den der erste verdeckt hat.")
-print("Deshalb ist die Konfliktsuche eine Schleife, kein einzelner Aufruf:")
-print("reparieren, neu suchen, bis das Modell loesbar ist.")
-
-
-def teil_5_die_reihenfolge(alle: list[int]) ->None:
-print("\n"+"="*68)
-print("5. Es gibt nicht DEN Konflikt")
-print("="*68)
-print("Derselbe Filter, nur eine andere Pruefreihenfolge - 200-mal gewuerfelt:\n")
- zufall = np.random.default_rng(0)
- haeufigkeit: dict[tuple[int, ...], int] = {}
-for _ inrange(200):
- ordnung = [int(i) for i in zufall.permutation(alle)]
- schluessel =tuple(sorted(deletion_filter(ordnung)))
- haeufigkeit[schluessel] = haeufigkeit.get(schluessel, 0) +1
-for schluessel, anzahl insorted(haeufigkeit.items(), key=lambda p: -p[1]):
-print(f" {anzahl:3d}x {len(schluessel)} Bedingungen: "
-f"{', '.join(namen(list(schluessel)))}")
-print(f"\n{len(haeufigkeit)} verschiedene minimale Konflikte in EINEM Modell.")
-print("Der Filter liefert *einen* kleinsten Konflikt, nicht *den* kleinsten -")
-print("den gibt es nicht. Alle oben sind gleichermassen korrekt.")
-
-
-def teil_6_den_befund_steuern(alle: list[int]) ->None:
-print("\n"+"="*68)
-print("6. Den Befund brauchbar machen")
-print("="*68)
-print("Der Filter wirft heraus, was er zuerst in die Hand bekommt. Wer die")
-print("unveraenderlichen Bedingungen zuerst pruefen laesst, bekommt sie eher")
-print("aus dem Befund heraus - und sieht dafuer mehr Stellschrauben.\n")
- fest = [i for i in alle if BEDINGUNGEN[i][0] notin VERHANDELBAR]
- frei = [i for i in alle if BEDINGUNGEN[i][0] in VERHANDELBAR]
-for titel, ordnung in (("unveraenderliche zuerst", fest + frei),
- ("veraenderliche zuerst ", frei + fest)):
- konflikt =sorted(deletion_filter(ordnung))
- stellschrauben = [i for i in konflikt if BEDINGUNGEN[i][0] in VERHANDELBAR]
-print(f" {titel} -> {len(konflikt)} Bedingungen, davon "
-f"{len(stellschrauben)} veraenderlich")
-print(f" {', '.join(namen(konflikt))}")
-print("\nBeide Befunde sind wahr. Nur einer davon nennt dem Planer etwas,")
-print("das er tatsaechlich tun kann.")
-
-
-if__name__=="__main__":
- alle =list(range(len(BEDINGUNGEN)))
- teil_1_der_befund(alle)
- teil_2_die_naive_suche(alle)
- konflikt = teil_3_der_filter(alle)
- teil_4_der_naechste_konflikt(alle, konflikt)
- teil_5_die_reihenfolge(alle)
- teil_6_den_befund_steuern(alle)
-print("\n"+"="*68)
-print(f"Insgesamt {aufrufe} Solveraufrufe fuer die gesamte Diagnose.")
-print("="*68)
+
#!/usr/bin/env python3
+
+# Konfliktsuche.py
+"""
+Anhang Fehlerdiagnose: Den Konflikt finden, der INFEASIBLE verursacht.
+
+Ein Deletion Filter (auch: IIS, Irreducible Infeasible Subset) isoliert aus
+einem unloesbaren Modell eine kleinste widerspruechliche Teilmenge von
+Bedingungen: Nimmt man aus ihr auch nur eine einzige Bedingung heraus, ist
+der Rest wieder loesbar.
+
+Das Programm zeigt sechs Dinge:
+ 1. Der Solver meldet INFEASIBLE - und sonst nichts.
+ 2. Die naive Suche ("jede Bedingung einmal weglassen") findet hier gar nichts.
+ 3. Der Deletion Filter findet einen Konflikt in n Solveraufrufen.
+ 4. Ein Konflikt ist selten einer: nach der Reparatur folgt der naechste.
+ 5. "Den" kleinsten Konflikt gibt es nicht - dieses Modell enthaelt neun.
+ 6. Welchen davon man zu sehen bekommt, steuert die Pruefreihenfolge.
+
+Abgrenzung zu Infeasibility_Diagnose.py (Kapitel Praxisfallen): Dort geht es
+um die Relaxation - das Modell soll trotz Widerspruch eine brauchbare Antwort
+liefern. Hier geht es um die Diagnose - welche Bedingungen widersprechen sich
+ueberhaupt. Beides zusammen ergibt den Umgang mit INFEASIBLE in Produktion.
+
+Benoetigt: numpy, scipy
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from scipy.optimize import linprog
+
+PRODUKTE = ["Rahmen", "Gehaeuse", "Deckel", "Traeger", "Halter"]
+
+# Jede Bedingung traegt einen sprechenden Namen - das ist keine Kosmetik,
+# sondern die Voraussetzung dafuer, dass der Befund lesbar wird.
+# (Name, Koeffizienten je Produkt, Richtung, rechte Seite)
+BEDINGUNGEN: list[tuple[str, list[float], str, float]] = [
+ ("Kapazitaet Montage", [3, 2, 1, 4, 2], "<=", 400),
+ ("Kapazitaet Lackieren", [2, 3, 0, 1, 0], "<=", 150),
+ ("Kapazitaet Pruefung", [1, 1, 1, 1, 1], "<=", 300),
+ ("Liefervertrag Rahmen", [1, 0, 0, 0, 0], ">=", 40),
+ ("Liefervertrag Gehaeuse", [0, 1, 0, 0, 0], ">=", 30),
+ ("Liefervertrag Deckel", [0, 0, 1, 0, 0], ">=", 20),
+ ("Liefervertrag Traeger", [0, 0, 0, 1, 0], ">=", 25),
+ ("Marktgrenze Rahmen", [1, 0, 0, 0, 0], "<=", 120),
+ ("Marktgrenze Gehaeuse", [0, 1, 0, 0, 0], "<=", 90),
+ ("Marktgrenze Deckel", [0, 0, 1, 0, 0], "<=", 150),
+ ("Marktgrenze Halter", [0, 0, 0, 0, 1], "<=", 200),
+ ("Mindestumsatz", [90, 70, 30, 60, 20], ">=", 12000),
+ ("Sortimentsbreite", [0, 0, 1, 1, 1], ">=", 100),
+ ("Lackierbudget Schicht 2", [0, 1, 0, 1, 0], "<=", 55),
+]
+
+# Welche Bedingungen koennte ein Planer im Ernstfall wirklich veraendern?
+# Kapazitaeten lassen sich durch Sonderschichten dehnen, Liefervertraege und
+# Marktgrenzen nicht.
+VERHANDELBAR = {"Kapazitaet Montage", "Kapazitaet Lackieren",
+"Kapazitaet Pruefung", "Lackierbudget Schicht 2"}
+
+aufrufe =0# zaehlt jeden Solveraufruf mit - der Preis des Verfahrens
+
+
+def ist_loesbar(auswahl: list[int]) ->bool:
+"""Gibt es einen Punkt, der ALLE Bedingungen aus 'auswahl' erfuellt?
+
+ Es wird nur Zulaessigkeit geprueft, keine Zielfunktion optimiert: die
+ Zielfunktion ist konstant null. INFEASIBLE haengt nie an der Zielfunktion.
+ """
+global aufrufe
+ aufrufe +=1
+ matrix, rechte_seite = [], []
+for i in auswahl:
+ _, koeffizienten, richtung, grenze = BEDINGUNGEN[i]
+ zeile = np.array(koeffizienten, dtype=float)
+# linprog kennt nur "<=", also ">=" durch Negation umdrehen
+ matrix.append(zeile if richtung =="<="else-zeile)
+ rechte_seite.append(grenze if richtung =="<="else-grenze)
+ ergebnis = linprog(np.zeros(len(PRODUKTE)),
+ A_ub=np.array(matrix), b_ub=np.array(rechte_seite),
+ bounds=(0, None), method="highs")
+return ergebnis.status ==0
+
+
+def deletion_filter(auswahl: list[int]) ->list[int]:
+"""Verkleinert eine unloesbare Menge zu einer kleinsten unloesbaren Menge.
+
+ Der Kern des Verfahrens ist eine einzige Regel: Nimm eine Bedingung
+ versuchsweise heraus. Bleibt der Rest unloesbar, wurde sie fuer den
+ Widerspruch nicht gebraucht - sie darf endgueltig weg. Wird der Rest
+ loesbar, war sie beteiligt und muss bleiben.
+
+ Nach genau einem Durchlauf ueber alle Bedingungen ist das Ergebnis
+ unreduzierbar: n Solveraufrufe statt 2^n Teilmengen.
+ """
+ rest =list(auswahl)
+for i inlist(auswahl):
+ probe = [j for j in rest if j != i]
+ifnot ist_loesbar(probe):
+ rest = probe
+return rest
+
+
+def namen(auswahl: list[int]) ->list[str]:
+return [BEDINGUNGEN[i][0] for i in auswahl]
+
+
+def teil_1_der_befund(alle: list[int]) ->None:
+print("="*68)
+print("1. Was der Solver sagt")
+print("="*68)
+print(f"Modell: {len(PRODUKTE)} Variablen, {len(BEDINGUNGEN)} Bedingungen")
+ status ="OPTIMAL"if ist_loesbar(alle) else"INFEASIBLE"
+print(f"Status: {status}")
+print("\nMehr ist es nicht. Der Solver nennt keine Ursache, weil es die eine")
+print("Ursache nicht gibt: Unloesbarkeit ist eine Eigenschaft von Mengen von")
+print("Bedingungen, nicht von einzelnen Bedingungen.")
+
+
+def teil_2_die_naive_suche(alle: list[int]) ->None:
+print("\n"+"="*68)
+print("2. Die naheliegende Idee - und warum sie hier scheitert")
+print("="*68)
+print("Jede Bedingung einmal weglassen und schauen, ob es dann geht:\n")
+ treffer = [i for i in alle if ist_loesbar([j for j in alle if j != i])]
+for i in alle:
+print(f" ohne {BEDINGUNGEN[i][0]:<26}{'loesbar'if i in treffer else'weiter unloesbar'}")
+print(f"\nGefundene Schuldige: {len(treffer)}")
+print("Keine einzelne Bedingung ist schuld. Genau das ist der Normalfall -")
+print("und der Grund, warum diese Suche in der Praxis so oft im Nichts endet.")
+
+
+def teil_3_der_filter(alle: list[int]) ->list[int]:
+global aufrufe
+print("\n"+"="*68)
+print("3. Der Deletion Filter")
+print("="*68)
+ vorher = aufrufe
+ konflikt = deletion_filter(alle)
+ kosten = aufrufe - vorher
+print(f"{kosten} Solveraufrufe -> Konflikt aus {len(konflikt)} von "
+f"{len(BEDINGUNGEN)} Bedingungen:\n")
+for name in namen(konflikt):
+print(f" * {name}")
+print("\nProbe auf Unreduzierbarkeit - jede einzelne davon weglassen:")
+for i in konflikt:
+ rest_loesbar = ist_loesbar([j for j in konflikt if j != i])
+print(f" ohne {BEDINGUNGEN[i][0]:<26}{'loesbar'if rest_loesbar else'UNLOESBAR - nicht minimal!'}")
+print(f"\nAufwand: {kosten} Aufrufe. Alle Teilmengen durchzuprobieren waeren "
+f"2^{len(BEDINGUNGEN)} = {2**len(BEDINGUNGEN):,} gewesen.".replace(",", "."))
+return konflikt
+
+
+def teil_4_der_naechste_konflikt(alle: list[int], konflikt: list[int]) ->None:
+print("\n"+"="*68)
+print("4. Ein Konflikt ist selten einer")
+print("="*68)
+ entfernt = konflikt[0]
+ rest = [i for i in alle if i != entfernt]
+print(f"Angenommen, '{BEDINGUNGEN[entfernt][0]}' laesst sich verhandeln")
+print("und wird aus dem Modell genommen. Dann ist das Modell ...")
+if ist_loesbar(rest):
+print("... loesbar. Fertig.")
+return
+print("... immer noch unloesbar. Der Filter erneut:\n")
+ zweiter = deletion_filter(rest)
+for name in namen(zweiter):
+print(f" * {name}")
+ gemeinsam =set(zweiter) &set(konflikt)
+print(f"\nUeberschneidung mit dem ersten Konflikt: {len(gemeinsam)} Bedingungen")
+print("Ein zweiter, unabhaengiger Widerspruch, den der erste verdeckt hat.")
+print("Deshalb ist die Konfliktsuche eine Schleife, kein einzelner Aufruf:")
+print("reparieren, neu suchen, bis das Modell loesbar ist.")
+
+
+def teil_5_die_reihenfolge(alle: list[int]) ->None:
+print("\n"+"="*68)
+print("5. Es gibt nicht DEN Konflikt")
+print("="*68)
+print("Derselbe Filter, nur eine andere Pruefreihenfolge - 200-mal gewuerfelt:\n")
+ zufall = np.random.default_rng(0)
+ haeufigkeit: dict[tuple[int, ...], int] = {}
+for _ inrange(200):
+ ordnung = [int(i) for i in zufall.permutation(alle)]
+ schluessel =tuple(sorted(deletion_filter(ordnung)))
+ haeufigkeit[schluessel] = haeufigkeit.get(schluessel, 0) +1
+for schluessel, anzahl insorted(haeufigkeit.items(), key=lambda p: -p[1]):
+print(f" {anzahl:3d}x {len(schluessel)} Bedingungen: "
+f"{', '.join(namen(list(schluessel)))}")
+print(f"\n{len(haeufigkeit)} verschiedene minimale Konflikte in EINEM Modell.")
+print("Der Filter liefert *einen* kleinsten Konflikt, nicht *den* kleinsten -")
+print("den gibt es nicht. Alle oben sind gleichermassen korrekt.")
+
+
+def teil_6_den_befund_steuern(alle: list[int]) ->None:
+print("\n"+"="*68)
+print("6. Den Befund brauchbar machen")
+print("="*68)
+print("Der Filter wirft heraus, was er zuerst in die Hand bekommt. Wer die")
+print("unveraenderlichen Bedingungen zuerst pruefen laesst, bekommt sie eher")
+print("aus dem Befund heraus - und sieht dafuer mehr Stellschrauben.\n")
+ fest = [i for i in alle if BEDINGUNGEN[i][0] notin VERHANDELBAR]
+ frei = [i for i in alle if BEDINGUNGEN[i][0] in VERHANDELBAR]
+for titel, ordnung in (("unveraenderliche zuerst", fest + frei),
+ ("veraenderliche zuerst ", frei + fest)):
+ konflikt =sorted(deletion_filter(ordnung))
+ stellschrauben = [i for i in konflikt if BEDINGUNGEN[i][0] in VERHANDELBAR]
+print(f" {titel} -> {len(konflikt)} Bedingungen, davon "
+f"{len(stellschrauben)} veraenderlich")
+print(f" {', '.join(namen(konflikt))}")
+print("\nBeide Befunde sind wahr. Nur einer davon nennt dem Planer etwas,")
+print("das er tatsaechlich tun kann.")
+
+
+if__name__=="__main__":
+ alle =list(range(len(BEDINGUNGEN)))
+ teil_1_der_befund(alle)
+ teil_2_die_naive_suche(alle)
+ konflikt = teil_3_der_filter(alle)
+ teil_4_der_naechste_konflikt(alle, konflikt)
+ teil_5_die_reihenfolge(alle)
+ teil_6_den_befund_steuern(alle)
+print("\n"+"="*68)
+print(f"Insgesamt {aufrufe} Solveraufrufe fuer die gesamte Diagnose.")
+print("="*68)
Die Ausgabe:
====================================================================
1. Was der Solver sagt
@@ -30236,31 +30607,31 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.
Vorzeichenfehler. Sie minimieren, wo Sie maximieren wollten (oder umgekehrt), und der Zielwert läuft in die falsche Richtung davon.
Freie Variablen ohne Nichtnegativität.bounds=[(None, None)] statt [(0, None)].
-
# Diagnose: kuenstliche Schranke einziehen und sehen, wohin es laeuft
-bounds = [(0, 1e6)] * n
-res = linprog(...)
-gross = [j for j inrange(n) if res.x[j] >1e5]
-print(f"Diese Variablen laufen an die Kunstschranke: {gross}")
+
# Diagnose: kuenstliche Schranke einziehen und sehen, wohin es laeuft
+bounds = [(0, 1e6)] * n
+res = linprog(...)
+gross = [j for j inrange(n) if res.x[j] >1e5]
+print(f"Diese Variablen laufen an die Kunstschranke: {gross}")
Die so gefundenen Variablen sind die, denen eine echte Beschränkung fehlt.
Bei CVXPY und bei Schleifen über Szenarien ist oft der Aufbau der Engpass, nicht der Solver (siehe Abschnitt 20.6).
2. Big-M zu groß? Der häufigste Grund für explodierende MILP-Laufzeiten. Setzen Sie M auf die kleinste gültige Schranke (Abschnitt 6.5).
3. Symmetrie im Modell? Identische Maschinen, austauschbare Mitarbeitende, gleichwertige Fahrzeuge — der Solver durchsucht alle Vertauschungen. Abhilfe: Ordnungsbedingungen (Muster B20).
4. Falsche Solverfamilie? Zuordnungsprobleme mit MILP, Routing mit CP-SAT von Hand nachgebaut, konvexe Probleme mit scipy.optimize.minimize — jeweils Größenordnungen langsamer als das passende Werkzeug (Abschnitt 3.6).
5. Wird vektorisiert?
-
# langsam: # schnell:
-for s inrange(S): constraints.append(u >=-(R @ w) - gamma)
- constraints.append(u[s] >=-R[s] @ w - gamma)
+
# langsam: # schnell:
+for s inrange(S): constraints.append(u >=-(R @ w) - gamma)
+ constraints.append(u[s] >=-R[s] @ w - gamma)
Bei ±10 % Datenunsicherheit sind die letzten 2 % Optimalität verlorene Zeit.
C4 — Unsinniges Ergebnis
@@ -30274,12 +30645,12 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.
Die wirksamste Gegenmaßnahme
-
# Nach JEDEM Lösen: alle Regeln explizit nachprüfen
-assertabs(w.sum() -1) <1e-6, "Budget nicht eingehalten"
-assert w.min() >-1e-9, "negatives Gewicht"
-assert w.max() <= grenze +1e-9, "Positionsgrenze verletzt"
-for sektor, idx in sektoren.items():
-assert w[idx].sum() <= limit[sektor] +1e-9, f"Sektor {sektor} verletzt"
+
# Nach JEDEM Lösen: alle Regeln explizit nachprüfen
+assertabs(w.sum() -1) <1e-6, "Budget nicht eingehalten"
+assert w.min() >-1e-9, "negatives Gewicht"
+assert w.max() <= grenze +1e-9, "Positionsgrenze verletzt"
+for sektor, idx in sektoren.items():
+assert w[idx].sum() <= limit[sektor] +1e-9, f"Sektor {sektor} verletzt"
Diese Zeilen kosten Sekunden und fangen die Fehlerklasse ab, die der Solver nicht melden kann: dass Ihr Modell etwas anderes beschreibt, als Sie glauben.
C5 — Instabile Lösung
@@ -30321,37 +30692,37 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.
-
eig = np.linalg.eigvalsh(Sigma)
-print(f"Kondition {eig.max()/eig.min():.0f} | kleinster EW {eig.min():.2e}")
-# Faustregel: Kondition > 1000 ist bedenklich, > 10000 kritisch
+
eig = np.linalg.eigvalsh(Sigma)
+print(f"Kondition {eig.max()/eig.min():.0f} | kleinster EW {eig.min():.2e}")
+# Faustregel: Kondition > 1000 ist bedenklich, > 10000 kritisch
Derselbe Fehler außerhalb der Finanzwelt
Instabilität ist kein Portfolioproblem, sondern ein Skalierungsproblem — und es trifft jedes Modell, in dem Größen sehr verschiedener Ordnung nebeneinanderstehen. Euro-Beträge (10^7) und Tonnen-Angaben (10^{-3}) in derselben Matrix erzeugen eine Konditionszahl, die aus einer Datenunsicherheit von 0,1 % eine Lösungsänderung von 100 % machen kann. Die Konditionszahl ist genau die Obergrenze dieses Verstärkungsfaktors.
-
print(f"Kondition der Restriktionsmatrix: {np.linalg.cond(A):.1e}")
-# Grobe Peilung: Wertebereiche der Koeffizienten anschauen
-print(f"kleinster / groesster Betrag: {np.abs(A[A !=0]).min():.1e} "
-f"/ {np.abs(A).max():.1e}")
+
print(f"Kondition der Restriktionsmatrix: {np.linalg.cond(A):.1e}")
+# Grobe Peilung: Wertebereiche der Koeffizienten anschauen
+print(f"kleinster / groesster Betrag: {np.abs(A[A !=0]).min():.1e} "
+f"/ {np.abs(A).max():.1e}")
Klaffen die Beträge um mehr als drei, vier Größenordnungen auseinander, rechnen Sie in anderen Einheiten (Tausend Euro statt Euro, Kilogramm statt Tonnen) oder skalieren Sie Zeilen und Spalten automatisch. Skalierung_Kondition.py zeigt beides samt Ruiz-Equilibrierung (Abschnitt 2.7).
C6 — Falsche Dualwerte
Symptom. Schattenpreise sind negativ, wo sie positiv sein sollten — oder null bei einer offensichtlich knappen Ressource.
Die drei Prüfungen
1. Vorzeichenkonvention. Haben Sie zur Maximierung negiert?
-
schattenpreise =-res.ineqlin.marginals # bei linprog nach Negation
+
schattenpreise =-res.ineqlin.marginals # bei linprog nach Negation
Genau das ist die Vorzeichenfalle aus Abschnitt 5.7.
2. Komplementärer Schlupf.
-
for s, y inzip(res.slack, schattenpreise):
-assertabs(s * y) <1e-6, "Komplementaerer Schlupf verletzt!"
+
for s, y inzip(res.slack, schattenpreise):
+assertabs(s * y) <1e-6, "Komplementaerer Schlupf verletzt!"
Eine Ressource mit Reserve muss Schattenpreis 0 haben.
3. Numerische Gegenprobe — der zuverlässigste Test.
Dieser Test ist unabhängig von jeder Vorzeichenkonvention. Nutzen Sie ihn.
4. Wenn alle drei Prüfungen bestehen und der Wert trotzdem seltsam ist: Entartung. Die drei Prüfungen oben setzen voraus, dass es einen richtigen Schattenpreis gibt. Bei einem entarteten Optimum — mehr aktive Nebenbedingungen als Variablen — gibt es den nicht. Der Wert ist dann nicht falsch, sondern mehrdeutig: zwei korrekte Solver liefern für dasselbe Optimum verschiedene Dualwerte, und die numerische Gegenprobe aus Schritt 3 schlägt fehl, weil der Zuwachs nach links und nach rechts unterschiedlich ausfällt.
-
aktiv =int(np.sum(res.slack <1e-9)) # wie viele Bedingungen sind straff?
-if aktiv >len(res.x):
-print(f"Entartet: {aktiv} aktive Bedingungen bei {len(res.x)} Variablen.")
-print("Einen einzelnen Schattenpreis zu berichten waere hier irrefuehrend.")
+
aktiv =int(np.sum(res.slack <1e-9)) # wie viele Bedingungen sind straff?
+if aktiv >len(res.x):
+print(f"Entartet: {aktiv} aktive Bedingungen bei {len(res.x)} Variablen.")
+print("Einen einzelnen Schattenpreis zu berichten waere hier irrefuehrend.")
Die belastbare Antwort ist dann keine Zahl, sondern eine Spanne über alle optimalen Dualwerte — Toleranzen_und_Entartung.py rechnet sie vor (Abschnitt 5.9).
C7 — Widersprüchliche Solver
@@ -30407,10 +30778,10 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.
-
print(problem.is_dcp()) # False?
-for c in problem.constraints:
-print(c.is_dcp(), c) # zeigt die Schuldige
-print(problem.objective.is_dcp())
+
print(problem.is_dcp()) # False?
+for c in problem.constraints:
+print(c.is_dcp(), c) # zeigt die Schuldige
+print(problem.objective.is_dcp())
2. Zeitindizes prüfen.iloc[t] gegen iloc[t+1] — die Gewichte von heute gehören auf die Rendite von morgen.
3. Survivorship. Wurde das Universum nach Kriterien gefiltert, die erst am Ende bekannt waren (dropna() über den gesamten Zeitraum!)?
4. Kosten. Sind Gebühren, Spread und Slippage verbucht?
@@ -30434,15 +30805,15 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.
C11 — Vertauschte Spalten
Symptom. Ergebnisse sind plausibel, aber falsch beschriftet — oder eine Sektorgrenze greift auf die falschen Titel.
Ursache.yfinance und viele andere Datenquellen liefern Spalten alphabetisch sortiert, nicht in der Reihenfolge Ihrer Anfrage.
-
# IMMER nach dem Import:
-kurse = roh["Close"][tickers].dropna() # erzwingt die Reihenfolge
-assertlist(kurse.columns) == tickers, (
-f"Reihenfolge weicht ab!\n erwartet: {tickers}\n erhalten: {list(kurse.columns)}")
-
-# Und Gruppen NIE ueber Positionsindizes definieren:
-SEKTOREN = {"Technologie": ["AAPL", "MSFT", "NVDA", "AMZN"]} # gut
-tech_idx = [tickers.index(t) for t in SEKTOREN["Technologie"]] # bricht bei Tippfehler
-tech_indices = [0, 1, 2, 3] # SCHLECHT
+
# IMMER nach dem Import:
+kurse = roh["Close"][tickers].dropna() # erzwingt die Reihenfolge
+assertlist(kurse.columns) == tickers, (
+f"Reihenfolge weicht ab!\n erwartet: {tickers}\n erhalten: {list(kurse.columns)}")
+
+# Und Gruppen NIE ueber Positionsindizes definieren:
+SEKTOREN = {"Technologie": ["AAPL", "MSFT", "NVDA", "AMZN"]} # gut
+tech_idx = [tickers.index(t) for t in SEKTOREN["Technologie"]] # bricht bei Tippfehler
+tech_indices = [0, 1, 2, 3] # SCHLECHT
Die goldene Diagnoseregel
@@ -30463,51 +30834,51 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.
D1 — SciPy: linprog und milp
Wofür. Die Einstiegsschicht: keine zusätzliche Installation, HiGHS als Unterbau, ideal für lineare und gemischt-ganzzahlige Probleme in Matrixform. Wofür nicht: alles, was sich nicht als Matrix schreiben lässt, und jede nichtlineare Zielfunktion.
import numpy as np
-from scipy.optimize import milp, LinearConstraint, Bounds
-
-# Ganzzahlig: dasselbe Problem, aber x muss ganzzahlig sein
-ergebnis = milp(
- c=[-10.0, -15.0, -25.0], # auch milp MINIMIERT
- constraints=LinearConstraint([[1, 1, 2], [2, 3, 1]], -np.inf, [40, 50]),
- integrality=[1, 1, 1], # 0 = stetig, 1 = ganzzahlig
- bounds=Bounds(0, np.inf))
-
-if ergebnis.status ==0:
-print(f"optimal: {-ergebnis.fun:.2f} x = {np.round(ergebnis.x).astype(int)}")
-print(f"MIP-Gap: {ergebnis.mip_gap:.4f}")
-elif ergebnis.status ==1:
-print("Zeitlimit - beste gefundene Loesung nutzen, Gap pruefen")
-elif ergebnis.status ==2:
-print("INFEASIBLE")
-elif ergebnis.status ==3:
-print("UNBOUNDED")
-else:
-print(f"kein Ergebnis: {ergebnis.message}")
+
import numpy as np
+from scipy.optimize import milp, LinearConstraint, Bounds
+
+# Ganzzahlig: dasselbe Problem, aber x muss ganzzahlig sein
+ergebnis = milp(
+ c=[-10.0, -15.0, -25.0], # auch milp MINIMIERT
+ constraints=LinearConstraint([[1, 1, 2], [2, 3, 1]], -np.inf, [40, 50]),
+ integrality=[1, 1, 1], # 0 = stetig, 1 = ganzzahlig
+ bounds=Bounds(0, np.inf))
+
+if ergebnis.status ==0:
+print(f"optimal: {-ergebnis.fun:.2f} x = {np.round(ergebnis.x).astype(int)}")
+print(f"MIP-Gap: {ergebnis.mip_gap:.4f}")
+elif ergebnis.status ==1:
+print("Zeitlimit - beste gefundene Loesung nutzen, Gap pruefen")
+elif ergebnis.status ==2:
+print("INFEASIBLE")
+elif ergebnis.status ==3:
+print("UNBOUNDED")
+else:
+print(f"kein Ergebnis: {ergebnis.message}")
Die drei häufigsten Stolpersteine
linprog minimiert immer. Zum Maximieren c negieren — und beim Ausgeben des Zielwerts das Vorzeichen wieder zurücknehmen. Dieselbe Negation dreht auch die Schattenpreise (Abschnitt 5.7).
@@ -30517,41 +30888,41 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.
D2 — HiGHS über highspy
Wofür. Derselbe Solver wie unter SciPy, aber direkt gesteuert: Optionen, Warm-Starts, inkrementelles Ändern eines bestehenden Modells. Wofür nicht: schnelles Hinschreiben — die CSR-Matrixübergabe ist fehleranfällig.
Nicht zusammen mit ortools importieren. Beide bringen eine eigene HiGHS-Kopie mit; im selben Prozess endet das in undefined symbol (Anhang C, C9). Auch cvxpy zieht highspy bei der Solver-Erkennung mit hinein.
@@ -30561,34 +30932,34 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.
D3 — OR-Tools: pywraplp
Wofür. Bequeme algebraische Schreibweise für LP und MILP mit umschaltbarem Backend (GLOP, SCIP, CBC, SAT). Wofür nicht: Scheduling und kombinatorische Bedingungen — dafür ist CP-SAT (D4) da.
-
from ortools.linear_solver import pywraplp
-
-# "GLOP" = LP, "SCIP" oder "CBC" = MILP, "SAT" = CP-SAT als MILP-Backend
-loeser = pywraplp.Solver.CreateSolver("GLOP")
-if loeser isNone:
-raiseSystemExit("Solver nicht verfuegbar")
-loeser.SetTimeLimit(60_000) # Millisekunden!
-
-unendlich = loeser.infinity()
-x = [loeser.NumVar(0, unendlich, f"x{j}") for j inrange(3)]
-# ganzzahlig: loeser.IntVar(0, unendlich, "n") | binaer: loeser.BoolVar("b")
-
-loeser.Add(x[0] + x[1] +2* x[2] <=40)
-loeser.Add(2* x[0] +3* x[1] + x[2] <=50)
-loeser.Maximize(10* x[0] +15* x[1] +25* x[2])
-
-status = loeser.Solve()
-if status == pywraplp.Solver.OPTIMAL:
-print(f"optimal: {loeser.Objective().Value():.2f}")
-print(f"x = {[round(v.solution_value(), 4) for v in x]}")
-elif status == pywraplp.Solver.FEASIBLE:
-print("zulaessig, nicht bewiesen optimal (Zeitlimit)")
-elif status == pywraplp.Solver.INFEASIBLE:
-print("INFEASIBLE")
-elif status == pywraplp.Solver.UNBOUNDED:
-print("UNBOUNDED")
-else:
-print("ABNORMAL / NOT_SOLVED - Modell oder Solver pruefen")
+
from ortools.linear_solver import pywraplp
+
+# "GLOP" = LP, "SCIP" oder "CBC" = MILP, "SAT" = CP-SAT als MILP-Backend
+loeser = pywraplp.Solver.CreateSolver("GLOP")
+if loeser isNone:
+raiseSystemExit("Solver nicht verfuegbar")
+loeser.SetTimeLimit(60_000) # Millisekunden!
+
+unendlich = loeser.infinity()
+x = [loeser.NumVar(0, unendlich, f"x{j}") for j inrange(3)]
+# ganzzahlig: loeser.IntVar(0, unendlich, "n") | binaer: loeser.BoolVar("b")
+
+loeser.Add(x[0] + x[1] +2* x[2] <=40)
+loeser.Add(2* x[0] +3* x[1] + x[2] <=50)
+loeser.Maximize(10* x[0] +15* x[1] +25* x[2])
+
+status = loeser.Solve()
+if status == pywraplp.Solver.OPTIMAL:
+print(f"optimal: {loeser.Objective().Value():.2f}")
+print(f"x = {[round(v.solution_value(), 4) for v in x]}")
+elif status == pywraplp.Solver.FEASIBLE:
+print("zulaessig, nicht bewiesen optimal (Zeitlimit)")
+elif status == pywraplp.Solver.INFEASIBLE:
+print("INFEASIBLE")
+elif status == pywraplp.Solver.UNBOUNDED:
+print("UNBOUNDED")
+else:
+print("ABNORMAL / NOT_SOLVED - Modell oder Solver pruefen")
Die drei häufigsten Stolpersteine
CreateSolver liefert None, wenn der Backend-Name unbekannt oder nicht gebaut ist — immer prüfen, statt am None später zu scheitern.
@@ -30598,40 +30969,40 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.
D4 — CP-SAT: cp_model
Wofür. Scheduling, Zuordnung, Reihenfolgen, alles Kombinatorische mit globalen Bedingungen. Wofür nicht: stetige Größen — CP-SAT rechnet ausschließlich ganzzahlig. Wer Nachkommastellen braucht, skaliert (Cent statt Euro, Promille statt Anteil).
-
from ortools.sat.python import cp_model
-
-modell = cp_model.CpModel()
-
-# Variablen - CP-SAT rechnet ausschliesslich mit GANZEN Zahlen
-x = modell.NewIntVar(0, 100, "x") # untere, obere Schranke, Name
-y = modell.NewIntVar(0, 100, "y")
-b = modell.NewBoolVar("b") # 0/1
-
-# Nebenbedingungen
-modell.Add(2* x +3* y <=50)
-modell.Add(x >=5).OnlyEnforceIf(b) # gilt nur, wenn b wahr ist
-modell.AddAllDifferent([x, y]) # globale Bedingung
-modell.AddMaxEquality(z := modell.NewIntVar(0, 100, "z"), [x, y])
-
-modell.Maximize(10* x +15* y -3* z)
-
-loeser = cp_model.CpSolver()
-loeser.parameters.max_time_in_seconds =10.0
-loeser.parameters.num_workers =1# 1 = reproduzierbar
-loeser.parameters.random_seed =1
-status = loeser.Solve(modell)
-
-if status == cp_model.OPTIMAL:
-print(f"optimal: {loeser.ObjectiveValue():.0f} x={loeser.Value(x)} y={loeser.Value(y)}")
-elif status == cp_model.FEASIBLE:
-print(f"zulaessig, nicht bewiesen optimal - Gap-Schranke: {loeser.BestObjectiveBound()}")
-elif status == cp_model.INFEASIBLE:
-print("INFEASIBLE - Bedingungen widersprechen sich")
-elif status == cp_model.MODEL_INVALID:
-print("Modellfehler:", modell.Validate())
-else:
-print("UNKNOWN - Zeit abgelaufen, ohne eine Loesung zu finden")
-print(f"Laufzeit {loeser.WallTime():.3f}s, {loeser.NumBranches()} Verzweigungen")
+
from ortools.sat.python import cp_model
+
+modell = cp_model.CpModel()
+
+# Variablen - CP-SAT rechnet ausschliesslich mit GANZEN Zahlen
+x = modell.NewIntVar(0, 100, "x") # untere, obere Schranke, Name
+y = modell.NewIntVar(0, 100, "y")
+b = modell.NewBoolVar("b") # 0/1
+
+# Nebenbedingungen
+modell.Add(2* x +3* y <=50)
+modell.Add(x >=5).OnlyEnforceIf(b) # gilt nur, wenn b wahr ist
+modell.AddAllDifferent([x, y]) # globale Bedingung
+modell.AddMaxEquality(z := modell.NewIntVar(0, 100, "z"), [x, y])
+
+modell.Maximize(10* x +15* y -3* z)
+
+loeser = cp_model.CpSolver()
+loeser.parameters.max_time_in_seconds =10.0
+loeser.parameters.num_workers =1# 1 = reproduzierbar
+loeser.parameters.random_seed =1
+status = loeser.Solve(modell)
+
+if status == cp_model.OPTIMAL:
+print(f"optimal: {loeser.ObjectiveValue():.0f} x={loeser.Value(x)} y={loeser.Value(y)}")
+elif status == cp_model.FEASIBLE:
+print(f"zulaessig, nicht bewiesen optimal - Gap-Schranke: {loeser.BestObjectiveBound()}")
+elif status == cp_model.INFEASIBLE:
+print("INFEASIBLE - Bedingungen widersprechen sich")
+elif status == cp_model.MODEL_INVALID:
+print("Modellfehler:", modell.Validate())
+else:
+print("UNKNOWN - Zeit abgelaufen, ohne eine Loesung zu finden")
+print(f"Laufzeit {loeser.WallTime():.3f}s, {loeser.NumBranches()} Verzweigungen")
Die drei häufigsten Stolpersteine
Alles ist ganzzahlig.0.5 * x gibt es nicht. Skalieren Sie das ganze Modell auf eine feinere Einheit, statt zu runden.
@@ -30641,32 +31012,32 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.
D5 — CVXPY
Wofür. Konvexe Probleme: quadratische Ziele, Normen, CVaR, alles mit Regularisierungstermen. Wofür nicht: große kombinatorische Modelle — der Aufbau der Ausdrücke wird dann selbst zum Engpass.
-
import numpy as np
-import cvxpy as cp
-
-x = cp.Variable(3, nonneg=True) # nonneg=True statt x >= 0
-gewichte = cp.Variable(3)
-ganzzahlig = cp.Variable(3, integer=True) # macht daraus ein MIP
-
-ziel = cp.Maximize(np.array([10.0, 15.0, 25.0]) @ x)
-bedingungen = [np.array([[1, 1, 2], [2, 3, 1]]) @ x <= np.array([40.0, 50.0])]
-
-problem = cp.Problem(ziel, bedingungen)
-ifnot problem.is_dcp(): # VOR dem Loesen pruefen
-print("nicht DCP:", [c for c in bedingungen ifnot c.is_dcp()])
-problem.solve()
-
-if problem.status == cp.OPTIMAL:
-print(f"optimal: {problem.value:.2f} x = {np.round(x.value, 4)}")
-print(f"Schattenpreis: {np.round(bedingungen[0].dual_value, 4)}")
-elif problem.status == cp.OPTIMAL_INACCURATE:
-print("Loesung numerisch unsicher - Skalierung pruefen, anderen Solver testen")
-elif problem.status == cp.INFEASIBLE:
-print("INFEASIBLE")
-elif problem.status == cp.UNBOUNDED:
-print("UNBOUNDED")
-else:
-print("Solverfehler:", problem.status)
+
import numpy as np
+import cvxpy as cp
+
+x = cp.Variable(3, nonneg=True) # nonneg=True statt x >= 0
+gewichte = cp.Variable(3)
+ganzzahlig = cp.Variable(3, integer=True) # macht daraus ein MIP
+
+ziel = cp.Maximize(np.array([10.0, 15.0, 25.0]) @ x)
+bedingungen = [np.array([[1, 1, 2], [2, 3, 1]]) @ x <= np.array([40.0, 50.0])]
+
+problem = cp.Problem(ziel, bedingungen)
+ifnot problem.is_dcp(): # VOR dem Loesen pruefen
+print("nicht DCP:", [c for c in bedingungen ifnot c.is_dcp()])
+problem.solve()
+
+if problem.status == cp.OPTIMAL:
+print(f"optimal: {problem.value:.2f} x = {np.round(x.value, 4)}")
+print(f"Schattenpreis: {np.round(bedingungen[0].dual_value, 4)}")
+elif problem.status == cp.OPTIMAL_INACCURATE:
+print("Loesung numerisch unsicher - Skalierung pruefen, anderen Solver testen")
+elif problem.status == cp.INFEASIBLE:
+print("INFEASIBLE")
+elif problem.status == cp.UNBOUNDED:
+print("UNBOUNDED")
+else:
+print("Solverfehler:", problem.status)
Die drei häufigsten Stolpersteine
DCP-Regeln vor dem Lösen prüfen.problem.is_dcp() und dann die einzelnen Bedingungen — das nennt die Schuldige, statt einen DCPError ohne Ort zu werfen (Anhang C, C8).
@@ -30677,73 +31048,73 @@ Insgesamt 2874 Solveraufrufe fuer die gesamte Diagnose.
D6 — Modellierungssprachen: Pyomo und Linopy
Wofür. Beide trennen Modell von Solver: dasselbe Modell läuft ohne Änderung unter HiGHS, CBC, Gurobi. Pyomo denkt in Mengen und Indizes wie eine mathematische Formulierung; Linopy denkt in beschrifteten Arrays und baut Nebenbedingungen als Matrixoperation statt in Python-Schleifen. Wofür nicht: ein Modell mit zehn Nebenbedingungen — dort ist der Aufwand höher als der Nutzen.
Der Solver ist ein eigenes Programm.SolverFactory("appsi_highs") scheitert, wenn HiGHS nicht auffindbar ist — die Fehlermeldung nennt dann das Modell, nicht die fehlende Installation.
Kapitel 1 bis Kapitel 23 in Reihenfolge, danach ein Projekt aus der Projektwerkstatt. Empfohlen, wenn Sie OR systematisch lernen wollen. Rechnen Sie mit 4–6 Stunden je Kapitel inklusive Übungen.
Pfad B — Planung, Disposition, Personaleinsatz (ca. 25 Stunden)
Kapitel 1 (Bausteine) → Kapitel 2 (bis Abschnitt 2.5) → Kapitel 4 (vom Wunsch zum Modell) → Kapitel 5 (LP, Schattenpreise) → Kapitel 6 (Ja/Nein-Entscheidungen) → Kapitel 7 (CP-SAT, Scheduling) → Projekt P1 (Vertretungsplaner) oder P2 (Schichtplanung). Das ist der kürzeste Weg zu einem einsetzbaren Dienstplan-Optimierer.
Pfad C — Logistik und Tourenplanung (ca. 25 Stunden)
Kapitel 1 → Kapitel 2 → Kapitel 11 (QP, KKT) → Kapitel 18 (Daten, Shrinkage) → Kapitel 19 (Markowitz) → Kapitel 20 (CVaR) → Kapitel 21 (Backtest) → Projekt P6 (Portfolio-Rebalancer) oder P7 (Risikoreport). Wichtig: Überspringen Sie Kapitel 18 nicht. Wer direkt bei Markowitz einsteigt, optimiert Schätzrauschen und wundert sich über absurde Gewichte.
Pfad E — Ich habe morgen ein konkretes Problem (2–4 Stunden)
Lesen Sie Abschnitt 1.5 (die vier Bausteine), dann Anhang B ([Modellierungsmuster](anhang-modellierungsmuster.html)) und suchen Sie dort das Muster, das zu Ihrem Problem passt. Von jedem Muster führt ein Verweis in das zuständige Kapitel. Wie die gewählte Bibliothek es schreibt, steht in Anhang D ([Spickzettel](anhang-spickzettel.html)); wenn etwas nicht läuft, in Anhang C ([Fehlerdiagnose](anhang-fehlerdiagnose.html)).
Worum geht es? Bisher waren alle Parameter bekannt. In der Realität sind Nachfrage, Rendite und Fahrzeit Zufallsgrößen. Dieses Kapitel zeigt drei Wege, damit umzugehen — und warum „einfach den Mittelwert einsetzen“ der schlechteste davon ist.
+
Worum geht es? Bisher waren alle Parameter bekannt. In der Realität sind Nachfrage, Rendite und Fahrzeit Zufallsgrößen. Dieses Kapitel zeigt vier Wege, damit umzugehen — und warum „einfach den Mittelwert einsetzen“ der schlechteste davon ist.
Voraussetzungen:Kapitel 5, Kapitel 11. Grundbegriffe der Statistik (Erwartungswert, Quantil).
-
Danach können Sie: Ein zweistufiges stochastisches Modell aufstellen, Monte-Carlo zur Risikomessung einsetzen, ein robustes Modell für den Worst Case formulieren — und erkennen, wann Rechnen mit Mittelwerten zulässig ist und wann es systematisch danebengeht.
-
Zeitbedarf: ca. 5,5 Stunden.
+
Danach können Sie: Ein zweistufiges stochastisches Modell aufstellen, Monte-Carlo zur Risikomessung einsetzen, ein robustes Modell für den Worst Case formulieren, eine Wahrscheinlichkeitszusage („mit 95 % Sicherheit“) als lösbare Nebenbedingung schreiben — und erkennen, wann Rechnen mit Mittelwerten zulässig ist und wann es systematisch danebengeht.
… ein zweistufiges stochastisches Programm mit Recourse formulieren.
… Monte-Carlo einsetzen, um Verteilungen statt Punktschätzungen zu bewerten.
… ein robustes Modell mit Unsicherheitsmenge aufstellen und seinen Preis beziffern.
-
… begründen, welcher der drei Ansätze für ein gegebenes Problem passt.
+
… eine Wahrscheinlichkeitszusage („mit 95 % Sicherheit“) als Chance Constraint schreiben — analytisch als Kegelbedingung oder szenariobasiert mit Big-M — und den Preis eines Prozentpunkts Sicherheit beziffern.
+
… begründen, welcher der vier Ansätze für ein gegebenes Problem passt.
… das kritische Verhältnis eines Newsvendor-Problems aufstellen und daraus die optimale Bestellmenge als Quantil ablesen.
… eine Terminzusage als Quantil formulieren statt als Mittelwert.
@@ -341,17 +343,18 @@ nicht beim Mittelwert 225 - typisch fuer asymmetrische Kostenfunktionen.<
🎯 Merksatz Der Durchschnittskunde kauft nie. Das Durchschnittsszenario tritt nie ein. Wer mit Mittelwerten plant, optimiert eine Welt, die es nicht gibt.
-
Die drei Ansätze im Überblick
+
Die vier Ansätze im Überblick
Abb. 12.1: Ansätze für Optimierung unter Unsicherheit
-
-
-
-
+
+
+
+
+
@@ -359,6 +362,7 @@ nicht beim Mittelwert 225 - typisch fuer asymmetrische Kostenfunktionen.<
Monte-Carlo
Stochastische Programmierung
Robuste Optimierung
+
Chance Constraints
@@ -367,39 +371,46 @@ nicht beim Mittelwert 225 - typisch fuer asymmetrische Kostenfunktionen.<
Der Unterschied zwischen den letzten beiden ist der, an dem die meisten Diskussionen hängenbleiben. Die robuste Optimierung sagt: „Es darf nie schiefgehen“ — und zahlt dafür. Die Chance Constraint sagt: „Es darf in höchstens 5 % der Fälle schiefgehen“ — und macht damit verhandelbar, was die Sicherheit kosten darf.
12.4 Monte-Carlo-Simulation
Monte-Carlo heißt schlicht: Ziehe sehr viele Zufallsszenarien, werte für jedes den Plan aus, und betrachte die Verteilung der Ergebnisse statt eines einzigen Werts.
@@ -828,34 +839,375 @@ deutlich teurer ist als Leerlauf (5 EUR).
⚠️ Der Preis der Robustheit Robuste Modelle sind konservativ. Wer gegen den absoluten Worst Case absichert, zahlt im Normalfall drauf — oft erheblich. Drei Gegenmittel: 1. Unsicherheitsmenge realistisch wählen. Nicht „alles kann passieren“, sondern „Abweichungen bis zu einer Standardabweichung“. 2. Budgeted Uncertainty (Bertsimas/Sim): Man nimmt an, dass höchstens \Gamma von n Parametern gleichzeitig ihren Worst Case annehmen. \Gamma steuert die Vorsicht stufenlos. 3. Nutzen und Preis immer beziffern — genau wie im Programm oben. Ohne diese zwei Zahlen ist die Diskussion „robust oder nicht“ Glaubenssache.
Die drei bisherigen Ansätze beantworten Fragen, die im Sitzungssaal selten so gestellt werden. „Was ist im Mittel am besten?“ hört ein Vorstand als Ausflucht; „was hält auch im schlimmsten Fall?“ als Einladung, zu viel Geld auszugeben. Gefragt wird fast immer nach einer Wahrscheinlichkeit: Mit welcher Sicherheit hält der Plan?
+
Die Wahrscheinlichkeitsbeschränkung schreibt genau das ins Modell:
📐 Formel-Lesehilfe * \mathbf{a} — der unsichere Vektor: Verfügbarkeiten, Erträge, Fahrzeiten. Er ist keine Zahl, sondern eine Zufallsgröße. * \mathbf{x} — Ihre Entscheidung. Sie fällt, bevor\mathbf{a} sich zeigt. * \alpha — die Ausfallwahrscheinlichkeit, die Sie akzeptieren. \alpha = 0{,}05 heißt „in höchstens 5 % der Fälle darf es schiefgehen“.
+
Ohne Formel gesagt: „Plane so, dass die Zusage in 95 von 100 Fällen hält — und sag mir, was das kostet.“
+
+
Das Problem daran. So aufgeschrieben ist die Bedingung nicht lösbar: Ein Solver kann nichts mit einem Wahrscheinlichkeitsoperator anfangen. Es braucht einen Weg, sie in etwas zu übersetzen, das ein Optimierer versteht. Es gibt zwei, und sie unterscheiden sich in dem, was sie über die Verteilung voraussetzen.
+
Weg 1: Normalverteilung — die Bedingung wird ein Kegel
+
Ist \mathbf{a} \sim \mathcal{N}(\hat{\mathbf{a}}, \boldsymbol{\Sigma}), dann ist \mathbf{a}^\top\mathbf{x} für festes \mathbf{x} selbst normalverteilt, mit Erwartungswert \hat{\mathbf{a}}^\top\mathbf{x} und Standardabweichung \sqrt{\mathbf{x}^\top\boldsymbol{\Sigma}\mathbf{x}}. Damit lässt sich die Wahrscheinlichkeit durch ein Quantil ersetzen:
Das ist eine Second-Order-Cone-Bedingung — dieselbe Bauform, die Kapitel 11 eingeführt hat, und CVXPY löst sie ohne Umstände.
+
+
🎯 Warum eine Norm und keine Summe Der Sicherheitszuschlag \lVert\mathbf{L}^\top\mathbf{x}\rVert_2 ist keine Summe von Einzelzuschlägen je Anlage. Genau darin steckt der Nutzen der Mischung: Zwei gegenläufige Quellen schwanken gemeinsam weniger als jede für sich, und die Norm rechnet das automatisch mit. Ein fester Aufschlag je Anlage könnte das nicht — er wäre linear und würde Diversifikation nicht belohnen.
+
+
Weg 2: Szenarien — Big-M ohne Verteilungsannahme
+
Liegen statt einer Verteilung nur S beobachtete Szenarien vor, bekommt jedes eine Binärvariable z_s, die sagt, ob es verletzt werden darf:
+
+\begin{aligned}
+\mathbf{a}_s^\top\mathbf{x} &\ge b - M z_s && \forall s\\
+\sum_{s} z_s &\le \alpha S\\
+z_s &\in \{0,1\}
+\end{aligned}
+
+
Aus dem Kegelproblem wird ein MILP mit S Binärvariablen. Für M gilt, was Kapitel 6 über die Big-M-Falle sagt: so klein wie möglich. Hier ist M = b die kleinste gültige Schranke, denn \mathbf{a}_s^\top\mathbf{x} \ge 0 — größer kann eine Verletzung gar nicht ausfallen.
+
Der Fall: ein Kraftwerkspark mit 500 MW Zusage
+
#!/usr/bin/env python3
+
+# Chance_Constraints.py
+"""
+Kapitel Unsicherheit: Wahrscheinlichkeitsbeschraenkungen.
+
+Das Management fragt selten "was ist im Mittel am besten?", sondern "mit
+welcher Sicherheit haelt der Plan?". Genau das formuliert eine Chance
+Constraint: P(Versorgung >= Bedarf) >= 1 - alpha.
+
+Gezeigt werden beide Wege dorthin, an derselben Instanz:
+ (a) analytisch - unter Normalverteilungsannahme wird daraus eine
+ Second-Order-Cone-Bedingung, loesbar mit CVXPY
+ (b) szenariobasiert - Big-M mit Binaervariablen, fuer beliebige
+ empirische Verteilungen
+
+Und die beiden Messungen, auf die es ankommt: Was kostet ein Prozentpunkt
+Versorgungssicherheit - und haelt die Zusage auch dann, wenn die Verteilung
+nicht normal ist?
+
+Solver: CLARABEL (Kegel) und SciPy/HiGHS (gemischt-ganzzahlig). Weder ortools
+noch ein direkter highspy-Import, damit alles in einem Prozess laeuft.
+"""
+
+import cvxpy as cp
+import numpy as np
+from scipy.stats import norm
+
+# --- Der Kraftwerkspark ----------------------------------------------------
+# Die Verfuegbarkeit ist der Anteil, den eine installierte MW im Mittel
+# wirklich liefert: bei Wind und Sonne klein und stark schwankend, bei Gas und
+# Biomasse gross und stabil. Die Ausbaugrenze ist der Standort - Flaeche,
+# Genehmigung, Brennstoffversorgung.
+TECHNIK = ["Gaskraftwerk", "Windpark", "Solarpark", "Biomasse"]
+KOSTEN = np.array([65_000.0, 24_000.0, 12_000.0, 60_000.0]) # EUR je MW und Jahr
+VERFUEGBAR = np.array([0.92, 0.35, 0.18, 0.85])
+STREUUNG = np.array([0.05, 0.16, 0.10, 0.04])
+GRENZE = np.array([400.0, 250.0, 600.0, 350.0]) # MW
+
+# Wind und Sonne sind leicht gegenlaeufig: Ein Tiefdruckgebiet bringt Wind und
+# Wolken zugleich. Genau diese Korrelation macht die Bedingung zu einem Kegel
+# und nicht zu einer Summe unabhaengiger Einzelzuschlaege.
+KORRELATION = np.array([
+ [1.00, 0.00, 0.00, 0.05],
+ [0.00, 1.00, -0.25, 0.00],
+ [0.00, -0.25, 1.00, 0.00],
+ [0.05, 0.00, 0.00, 1.00],
+])
+SIGMA = np.diag(STREUUNG) @ KORRELATION @ np.diag(STREUUNG)
+WURZEL = np.linalg.cholesky(SIGMA) # L mit L @ L.T == SIGMA
+
+BEDARF =500.0# MW, die gesichert bereitstehen muessen
+N =len(TECHNIK)
+
+# Die Kaeltewelle mit Dunkelflaute: selten, aber sie trifft alles zugleich.
+# Wind und Sonne brechen fast vollstaendig weg - und, das ist der Punkt, das
+# Gaskraftwerk liefert ebenfalls weniger, weil bei Frost der Netzdruck faellt.
+# Eine Kovarianzmatrix mit Korrelationen um null kann das nicht ausdruecken.
+P_KAELTEWELLE =0.08
+EINBRUCH = np.array([0.72, 0.10, 0.15, 0.88])
+
+
+def mittelwertplan():
+"""Plant mit den Erwartungswerten - ignoriert die Streuung vollstaendig."""
+ x = cp.Variable(N, nonneg=True)
+ problem = cp.Problem(cp.Minimize(KOSTEN @ x),
+ [VERFUEGBAR @ x >= BEDARF, x <= GRENZE])
+ problem.solve(solver=cp.CLARABEL)
+return problem.value, x.value
+
+
+def chance_constraint_analytisch(alpha):
+"""
+ P(a^T x >= BEDARF) >= 1 - alpha unter a ~ N(VERFUEGBAR, SIGMA).
+
+ Aequivalent zu: VERFUEGBAR^T x - z * ||L^T x||_2 >= BEDARF
+ mit z = Phi^-1(1 - alpha). Das ist eine Second-Order-Cone-Bedingung: Der
+ Sicherheitszuschlag ist eine Norm ueber x, kein fester Aufschlag je Anlage.
+ Deshalb belohnt sie Mischung - zwei gegenlaeufige Quellen schwanken
+ gemeinsam weniger als jede fuer sich.
+ """
+ x = cp.Variable(N, nonneg=True)
+ z = norm.ppf(1- alpha)
+ bedingung = VERFUEGBAR @ x - z * cp.norm(WURZEL.T @ x, 2) >= BEDARF
+ problem = cp.Problem(cp.Minimize(KOSTEN @ x), [bedingung, x <= GRENZE])
+ problem.solve(solver=cp.CLARABEL)
+return problem.value, x.value
+
+
+def chance_constraint_szenarien(a, alpha):
+"""
+ Dieselbe Zusage ohne Verteilungsannahme: Fuer jedes Szenario s sagt eine
+ Binaervariable z_s, ob es verletzt werden darf.
+
+ a_s^T x >= BEDARF - M * z_s fuer alle s
+ sum_s z_s <= alpha * S
+
+ M = BEDARF ist die kleinstmoegliche gueltige Schranke, denn a_s^T x >= 0:
+ Groesser kann eine Verletzung gar nicht ausfallen. Ein unnoetig grosses M
+ wuerde die LP-Relaxierung aufweichen und die Suche verlangsamen - die
+ Big-M-Falle aus dem Kapitel Gemischt-ganzzahlige Optimierung.
+ """
+ S =len(a)
+ x = cp.Variable(N, nonneg=True)
+ z = cp.Variable(S, boolean=True)
+ problem = cp.Problem(
+ cp.Minimize(KOSTEN @ x),
+ [a @ x >= BEDARF - BEDARF * z, cp.sum(z) <= alpha * S, x <= GRENZE])
+ problem.solve(solver=cp.SCIPY)
+return problem.value, x.value
+
+
+def ziehe_wetter(anzahl, seed):
+"""Mischverteilung: normales Wetter, mit P_KAELTEWELLE ein Einbruch."""
+ rng = np.random.default_rng(seed)
+ a = rng.multivariate_normal(VERFUEGBAR, SIGMA, size=anzahl)
+ getroffen = rng.random(anzahl) < P_KAELTEWELLE
+ a[getroffen] *= EINBRUCH
+return np.clip(a, 0.0, 1.0)
+
+
+def sicherheit(x, a):
+"""Anteil der Szenarien, in denen der Plan den Bedarf deckt."""
+returnfloat(np.mean(a @ x >= BEDARF))
+
+
+KOPF = (f"{'Plan':<20}{'Kosten (EUR)':>13}{'Gas':>4}{'Wind':>4} "
+f"{'Sol':>4}{'Bio':>4}{'Normalwelt':>11}{'echte Welt':>11}")
+
+
+def zeile(name, kosten, x, in_normal, in_echt):
+ mix =" ".join(f"{w:4.0f}"for w in x)
+return (f"{name:<20}{kosten:>13,.0f}{mix} "
+f"{in_normal *100:>9.2f} % {in_echt *100:>9.2f} %")
+
+
+if__name__=="__main__":
+print("="*78)
+print(" WAHRSCHEINLICHKEITSBESCHRAENKUNGEN IM KRAFTWERKSPARK")
+print("="*78)
+print(f"Gesichert bereitzustellen: {BEDARF:.0f} MW\n")
+print(f"{'Technologie':<14}{'EUR/MW':>8}{'verfuegbar':>11}{'Streuung':>9} "
+f"{'Grenze':>8}{'EUR je erw. MW':>15}")
+print("-"*78)
+for i, name inenumerate(TECHNIK):
+print(f"{name:<14}{KOSTEN[i]:>8,.0f}{VERFUEGBAR[i]:>10.2f} "
+f"{STREUUNG[i]:>8.2f}{GRENZE[i]:>7.0f} "
+f"{KOSTEN[i] / VERFUEGBAR[i]:>15,.0f}")
+print(f"\nVollausbau liefert im Mittel {VERFUEGBAR @ GRENZE:.0f} MW, "
+f"in der Kaeltewelle {(VERFUEGBAR * EINBRUCH) @ GRENZE:.0f} MW.")
+
+# Zwei grosse, unabhaengige Testmengen. An ihnen wird JEDER Plan gemessen -
+# einmal in der unterstellten Normalwelt, einmal in der echten Verteilung.
+ echt = ziehe_wetter(200_000, seed=771)
+ normalwelt = np.random.default_rng(4711).multivariate_normal(
+ VERFUEGBAR, SIGMA, size=200_000)
+
+print("\n"+"="*78)
+print(" (1) Mittelwertplan und Chance Constraints im Vergleich")
+print("="*78)
+print(KOPF)
+print("-"*78)
+
+ k_mittel, x_mittel = mittelwertplan()
+ stufen = [("Mittelwert", k_mittel, sicherheit(x_mittel, normalwelt))]
+print(zeile("Mittelwert", k_mittel, x_mittel,
+ sicherheit(x_mittel, normalwelt), sicherheit(x_mittel, echt)))
+
+ plaene = {}
+for alpha in (0.20, 0.10, 0.05, 0.01):
+ kosten, x = chance_constraint_analytisch(alpha)
+ plaene[alpha] = (kosten, x)
+ quote_normal = sicherheit(x, normalwelt)
+ stufen.append((f"Zusage {(1- alpha) *100:.0f} %", kosten, quote_normal))
+print(zeile(f"Zusage {(1- alpha) *100:.0f} %", kosten, x,
+ quote_normal, sicherheit(x, echt)))
+
+print("\n In der Normalwelt trifft jede Zusage ihren Wert - das Verfahren")
+print(" rechnet richtig. Die Spalte 'echte Welt' kommt in Teil (3).")
+
+print("\n"+"="*78)
+print(" (2) Was kostet ein Prozentpunkt Versorgungssicherheit?")
+print("="*78)
+print(f"{'von -> nach':<26}{'Prozentpunkte':>13}{'Mehrkosten':>13} "
+f"{'EUR je Punkt':>13}")
+print("-"*78)
+for (n1, k1, q1), (n2, k2, q2) inzip(stufen, stufen[1:]):
+ d_punkte = (q2 - q1) *100
+ d_kosten = k2 - k1
+print(f"{n1 +' -> '+ n2:<26}{d_punkte:>13.2f}{d_kosten:>13,.0f} "
+f"{d_kosten / d_punkte:>13,.0f}")
+ erst = (stufen[1][1] - stufen[0][1]) / ((stufen[1][2] - stufen[0][2]) *100)
+ letzt = (stufen[-1][1] - stufen[-2][1]) / ((stufen[-1][2] - stufen[-2][2]) *100)
+print(f"\n Der letzte Prozentpunkt kostet das {letzt / erst:.1f}-fache des ersten.")
+print(" Sicherheit ist konvex bepreist - genau deshalb muss jemand")
+print(" entscheiden, wie viel davon das Unternehmen kaufen will.")
+
+print("\n"+"="*78)
+print(" (3) Und wenn die Verteilung nicht normal ist?")
+print("="*78)
+ k_soc, x_soc = plaene[0.05]
+print(f"Die echte Welt kennt die Kaeltewelle mit Dunkelflaute: in "
+f"{P_KAELTEWELLE *100:.0f} % der Faelle liefern")
+print(f"Wind {EINBRUCH[1] *100:.0f} %, Sonne {EINBRUCH[2] *100:.0f} % "
+f"und - entscheidend - auch das Gaskraftwerk nur "
+f"{EINBRUCH[0] *100:.0f} %")
+print("des Ueblichen. Alles bricht gleichzeitig ein.\n")
+
+ a_bau = ziehe_wetter(400, seed=20260908)
+ k_sz, x_sz = chance_constraint_szenarien(a_bau, 0.05)
+
+print(KOPF)
+print("-"*78)
+print(zeile("SOC, Zusage 95 %", k_soc, x_soc,
+ sicherheit(x_soc, normalwelt), sicherheit(x_soc, echt)))
+print(zeile("Szenarien, 95 %", k_sz, x_sz,
+ sicherheit(x_sz, normalwelt), sicherheit(x_sz, echt)))
+
+print(f"\n Der SOC-Plan verspricht 95 % und haelt "
+f"{sicherheit(x_soc, echt) *100:.1f} %. Nicht das Verfahren ist")
+print(f" falsch, sondern die Annahme: In der Normalwelt liefert derselbe")
+print(f" Plan {sicherheit(x_soc, normalwelt) *100:.1f} %.")
+print(f"\n Der Szenarioplan ({len(a_bau)} Szenarien, {len(a_bau)} "
+f"Binaervariablen) kommt auf")
+print(f" {sicherheit(x_sz, echt) *100:.1f} % - und kostet dafuer "
+f"{(k_sz / k_soc -1) *100:+.1f} %. Er kauft keine Windkraft mehr:")
+print(" Was im Ernstfall ausfaellt, hilft der Zusage nicht.")
+print("="*78)
+
Ausgabe:
+
==============================================================================
+ WAHRSCHEINLICHKEITSBESCHRAENKUNGEN IM KRAFTWERKSPARK
+==============================================================================
+Gesichert bereitzustellen: 500 MW
+
+Technologie EUR/MW verfuegbar Streuung Grenze EUR je erw. MW
+------------------------------------------------------------------------------
+Gaskraftwerk 65,000 0.92 0.05 400 70,652
+Windpark 24,000 0.35 0.16 250 68,571
+Solarpark 12,000 0.18 0.10 600 66,667
+Biomasse 60,000 0.85 0.04 350 70,588
+
+Vollausbau liefert im Mittel 861 MW, in der Kaeltewelle 552 MW.
+
+==============================================================================
+ (1) Mittelwertplan und Chance Constraints im Vergleich
+==============================================================================
+Plan Kosten (EUR) Gas Wind Sol Bio Normalwelt echte Welt
+------------------------------------------------------------------------------
+Mittelwert 34,694,565 8 250 600 350 50.08 % 46.18 %
+Zusage 80 % 36,382,277 226 25 44 343 80.06 % 73.61 %
+Zusage 90 % 36,994,777 233 20 35 349 89.99 % 82.82 %
+Zusage 95 % 37,514,307 241 19 31 350 94.97 % 87.44 %
+Zusage 99 % 38,529,698 258 18 28 350 98.97 % 91.05 %
+
+ In der Normalwelt trifft jede Zusage ihren Wert - das Verfahren
+ rechnet richtig. Die Spalte 'echte Welt' kommt in Teil (3).
+
+==============================================================================
+ (2) Was kostet ein Prozentpunkt Versorgungssicherheit?
+==============================================================================
+von -> nach Prozentpunkte Mehrkosten EUR je Punkt
+------------------------------------------------------------------------------
+Mittelwert -> Zusage 80 % 29.98 1,687,712 56,289
+Zusage 80 % -> Zusage 90 % 9.93 612,500 61,685
+Zusage 90 % -> Zusage 95 % 4.98 519,530 104,365
+Zusage 95 % -> Zusage 99 % 4.00 1,015,390 253,848
+
+ Der letzte Prozentpunkt kostet das 4.5-fache des ersten.
+ Sicherheit ist konvex bepreist - genau deshalb muss jemand
+ entscheiden, wie viel davon das Unternehmen kaufen will.
+
+==============================================================================
+ (3) Und wenn die Verteilung nicht normal ist?
+==============================================================================
+Die echte Welt kennt die Kaeltewelle mit Dunkelflaute: in 8 % der Faelle liefern
+Wind 10 %, Sonne 15 % und - entscheidend - auch das Gaskraftwerk nur 72 %
+des Ueblichen. Alles bricht gleichzeitig ein.
+
+Plan Kosten (EUR) Gas Wind Sol Bio Normalwelt echte Welt
+------------------------------------------------------------------------------
+SOC, Zusage 95 % 37,514,307 241 19 31 350 94.97 % 87.44 %
+Szenarien, 95 % 43,760,709 350 0 0 350 100.00 % 94.84 %
+
+ Der SOC-Plan verspricht 95 % und haelt 87.4 %. Nicht das Verfahren ist
+ falsch, sondern die Annahme: In der Normalwelt liefert derselbe
+ Plan 95.0 %.
+
+ Der Szenarioplan (400 Szenarien, 400 Binaervariablen) kommt auf
+ 94.8 % - und kostet dafuer +16.7 %. Er kauft keine Windkraft mehr:
+ Was im Ernstfall ausfaellt, hilft der Zusage nicht.
+==============================================================================
+
Drei Befunde, die man dem Plan nicht ansieht.
+
Erstens: Der Mittelwertplan fällt in der Hälfte aller Fälle aus. Das ist kein Fehler, sondern die Definition des Erwartungswerts — und dieselbe Falle wie in Abschnitt 12.3, nur teurer. Er kauft die billigsten Quellen je erwarteter Megawattstunde und maximiert Wind und Sonne bis an die Ausbaugrenze. Sobald die Zusage im Modell steht, dreht sich das Bild: Gas und Biomasse tragen den Plan, Wind und Sonne schrumpfen auf einen Rest.
+
Zweitens: Sicherheit ist konvex bepreist. Die ersten dreißig Prozentpunkte kosten rund 56 000 € pro Punkt, die letzten vier rund 254 000 € — das 4,5-fache. Das ist die Zahl, die in die Vorlage gehört: Nicht „was kostet Versorgungssicherheit“, sondern „was kostet der nächste Prozentpunkt“. Wer 99,9 % fordert, ohne diese Kurve gesehen zu haben, fordert ins Blaue.
+
Drittens — und das ist der unangenehme Befund: Die Zusage gilt nur für die unterstellte Verteilung. Der Plan verspricht 95 % und hält 87,4 %. Nicht weil das Verfahren falsch rechnet: In einer reinen Normalwelt liefert derselbe Plan exakt seine 95,0 %. Sondern weil die Normalverteilung eine Kältewelle mit Dunkelflaute nicht kennt. In der bricht alles zugleich ein — auch das Gaskraftwerk, weil bei Frost der Netzdruck fällt. Eine Kovarianzmatrix mit Korrelationen um null kann so etwas nicht ausdrücken.
+
Der szenariobasierte Weg kennt diese Fälle, weil sie in den Daten stehen. Er kommt auf 94,8 %, kostet dafür 16,7 % mehr — und kauft keine Windkraft mehr. Die Logik dahinter ist hart und richtig: Was im Ernstfall ausfällt, hilft der Zusage nicht.
+
+
⚠️ Die Szenariomethode überanpasst — und mehr Szenarien helfen nicht verlässlich Bei S Szenarien und \alpha = 5\,\% darf das Modell genau 0{,}05 \cdot S davon ignorieren — und es sucht sich die teuersten aus. In der Stichprobe trifft es seine Quote dadurch immer; außerhalb streut sie.
+
Gemessen an zwölf Läufen dieses Modells (je vier Ziehungen bei S = 200, 400, 800, geprüft an 200 000 unabhängigen Szenarien): Die tatsächliche Quote lag zwischen 93,1 % und 96,7 %. Die Spanne wurde von S = 200 auf 400 enger (3,5 → 1,2 Prozentpunkte), bei S = 800 aber wieder breiter (3,1) — die Zahl der Szenarien allein schließt die Lücke also nicht.
+
Konsequenz für die Praxis: Eine mit Szenarien erkaufte Zusage immer an einer zurückgehaltenen Stichprobe nachmessen, so wie es das Programm oben tut. Wer nur die Trefferquote in den Baudaten berichtet, berichtet eine Tautologie — dieselbe Falle wie beim Backtest in Kapitel 21.
+
+
+
🎯 Merksatz Die robuste Optimierung fragt „was, wenn alles schiefgeht?“, die Chance Constraint „wie oft darf es schiefgehen?“. Nur die zweite Frage hat eine Antwort, über die man verhandeln kann — und nur sie zwingt dazu, den Preis eines Prozentpunkts Sicherheit zu beziffern.
Aufgabe 12.1 ⭐ — Fluch des Durchschnitts erkennen. Ein Bauunternehmer plant mit der durchschnittlichen Bauzeit von 8 Monaten. Warum ist die erwartete Fertigstellung trotzdem später als 8 Monate, wenn Verzögerungen wahrscheinlicher sind als Beschleunigungen? Nennen Sie zwei weitere Alltagsbeispiele.
-
Aufgabe 12.2 ⭐ — Ansatz wählen. Welcher der drei Ansätze passt? Begründen Sie: (a) Wie viele Notstromaggregate für ein Krankenhaus? (b) Wie viel Weizen einkaufen bei bekannter Preisverteilung? (c) Wie hoch ein Deich gebaut werden muss? (d) Wie viele Saisonkräfte einstellen bei bekannten Nachfrageszenarien?
+
Aufgabe 12.2 ⭐ — Ansatz wählen. Welcher der vier Ansätze passt? Begründen Sie: (a) Wie viele Notstromaggregate für ein Krankenhaus? (b) Wie viel Weizen einkaufen bei bekannter Preisverteilung? (c) Wie hoch ein Deich gebaut werden muss? (d) Wie viele Saisonkräfte einstellen bei bekannten Nachfrageszenarien? (e) Wie viel Speicherkapazität, damit ein Gasnetz an 99 % aller Wintertage hält?
Aufgabe 12.3 ⭐⭐ — Zweistufiges Modell rechnen. Wiederholen Sie die Handrechnung Warum der Mittelwert in die Irre führt mit geänderten Kosten: Spot 60 € statt 120 €. (a) Wie ändert sich die optimale Kapazität? (b) Erklären Sie die Richtung der Änderung. (c) Bei welchem Spot-Preis wäre die Mittelwert-Planung optimal?
Aufgabe 12.4 ⭐⭐ — EVPI interpretieren. Ihr Modell liefert EVPI = 8 400 € pro Jahr. Ein Anbieter verlangt 15 000 € jährlich für eine Prognoselösung, die „80 % Treffsicherheit“ verspricht. Wie argumentieren Sie?
Aufgabe 12.5 ⭐⭐⭐ — Monte-Carlo erweitern. Erweitern Sie Monte_Carlo.py: (a) Fügen Sie ein Servicelevel-Kriterium hinzu: „Die Unterdeckung darf höchstens in 5 % der Fälle auftreten.“ Welche Kapazität ist dafür nötig, und was kostet sie zusätzlich? (b) Berechnen Sie den CVaR der Kosten (Mittelwert der schlechtesten 5 %). (c) Zeichnen Sie ein Histogramm der Kosten für drei Kapazitäten.
-
Aufgabe 12.6 ⭐⭐⭐ — Budgeted Uncertainty. Erweitern Sie Robuste_Optimierung.py um den Ansatz von Bertsimas/Sim: Höchstens \Gamma Titel nehmen gleichzeitig ihren Worst Case an. Variieren Sie \Gamma \in \{0, 1, 2, 3, 4\} und stellen Sie den Verlauf von Ertrag und Absicherung dar. (Hinweis: Bei \Gamma ganzzahlig genügt es, die \Gamma größten \delta_i w_i abzuziehen — das lässt sich in CVXPY mit cp.sum_largest formulieren.)
+
Aufgabe 12.6 ⭐⭐ — Den Preis der Zusage selbst bestimmen.Chance_Constraints.py beziffert die Kosten für 80, 90, 95 und 99 %. (a) Ergänzen Sie die Stufen 99,5 % und 99,9 % und setzen Sie die Kosten je Prozentpunkt fort. Was passiert mit dem Anlagenmix? (b) Drosseln Sie die Ausbaugrenze der Biomasse von 350 auf 200 MW. Das szenariobasierte Modell meldet dann bei 95 % infeasible, das analytische dagegen optimal. Erklären Sie den Unterschied. Welchem der beiden würden Sie glauben? (c) Ein Vorstand fordert „99,99 % Versorgungssicherheit“. Formulieren Sie in drei Sätzen, was Sie ihm mit dieser Kurve antworten.
+
Aufgabe 12.7 ⭐⭐⭐ — Budgeted Uncertainty. Erweitern Sie Robuste_Optimierung.py um den Ansatz von Bertsimas/Sim: Höchstens \Gamma Titel nehmen gleichzeitig ihren Worst Case an. Variieren Sie \Gamma \in \{0, 1, 2, 3, 4\} und stellen Sie den Verlauf von Ertrag und Absicherung dar. (Hinweis: Bei \Gamma ganzzahlig genügt es, die \Gamma größten \delta_i w_i abzuziehen — das lässt sich in CVXPY mit cp.sum_largest formulieren.)
-
12.8 Finde den Denkfehler
+
12.9 Finde den Denkfehler
🐛 Finde den Denkfehler 9.1: Warum jedes Projekt zu spät fertig wird
Ein Projektleiter plant eine Umbaumaßnahme. Fünf Gewerke arbeiten parallel und unabhängig voneinander; fertig ist der Umbau, wenn das letzte fertig ist. Für jedes Gewerk liegt eine Schätzung vor: mindestens 6 Tage, wahrscheinlich 10, im schlechtesten Fall 18.
Er rechnet: „Jedes Gewerk braucht im Mittel (6 + 10 + 18)/3 = 11{,}3 Tage. Alle laufen gleichzeitig. Also ist der Umbau nach 11,3 Tagen fertig — ich plane großzügig 12 ein.“
Nachgerechnet mit 200 000 simulierten Umbauten:
-
import numpy as np
-
-rng = np.random.default_rng(3)
-dauer = rng.triangular(6, 10, 18, size=(200_000, 5)) # 5 Gewerke, je eine Schaetzung
-projekt = dauer.max(axis=1) # fertig, wenn das LETZTE fertig ist
-
-print(f"Mittelwert einer Einzeldauer: {dauer.mean():.2f} Tage")
-print(f"Tatsaechliche Projektdauer: {projekt.mean():.2f} Tage")
-print(f"90-%-Quantil: {np.quantile(projekt, 0.9):.2f} Tage")
-print(f"Anteil ueber 11,34 Tage: {(projekt >11.34).mean() *100:.1f} %")
+
import numpy as np
+
+rng = np.random.default_rng(3)
+dauer = rng.triangular(6, 10, 18, size=(200_000, 5)) # 5 Gewerke, je eine Schaetzung
+projekt = dauer.max(axis=1) # fertig, wenn das LETZTE fertig ist
+
+print(f"Mittelwert einer Einzeldauer: {dauer.mean():.2f} Tage")
+print(f"Tatsaechliche Projektdauer: {projekt.mean():.2f} Tage")
+print(f"90-%-Quantil: {np.quantile(projekt, 0.9):.2f} Tage")
+print(f"Anteil ueber 11,34 Tage: {(projekt >11.34).mean() *100:.1f} %")
Ausgabe:
Mittelwert einer Einzeldauer: 11.34 Tage
Tatsaechliche Projektdauer: 14.38 Tage
@@ -869,7 +1221,7 @@ Anteil ueber 11,34 Tage: 95.5 %
🎯 Merksatz Der Fluch des Durchschnitts hat zwei Gesichter. Beim Ersatzteil aus Abschnitt 12.1 führt der Mittelwert in die Irre, weil die Kosten asymmetrisch sind. Hier führt er in die Irre, weil die Verknüpfung asymmetrisch ist: Ein Gewerk, das früher fertig wird, hilft niemandem — ein Gewerk, das sich verspätet, verzögert alles. Beide Male gilt: Der Mittelwert einer Funktion ist nicht die Funktion des Mittelwerts.
Was misst der EVPI, und wofür ist er praktisch nützlich?
Warum braucht robuste Optimierung keine Wahrscheinlichkeiten?
Wie macht man ein Min-Max-Problem mit Box-Unsicherheit lösbar?
+
Warum wird aus \mathbb{P}(\mathbf{a}^\top\mathbf{x} \ge b) \ge 1-\alpha unter Normalverteilung eine Norm und nicht ein fester Zuschlag je Variable?
+
Ein Plan mit Chance Constraint verspricht 95 % und hält gemessen 87 %. Woran liegt das — und was ändert die szenariobasierte Formulierung daran?
-
12.11 Zusammenfassung
+
12.12 Zusammenfassung
Der Fluch des Durchschnitts ist kein Randphänomen und hat zwei Gesichter. Bei asymmetrischen Kosten liegt das Optimum systematisch neben dem Mittelwert — das Newsvendor-Verhältnis c_{-}/(c_{-}+c_{+}) sagt, wie weit. Bei asymmetrischer Verknüpfung (Maximum über parallele Vorgänge) ist der Erwartungswert des Ganzen größer als das Ganze der Erwartungswerte. Beide Male gilt: Der Mittelwert einer Funktion ist nicht die Funktion des Mittelwerts (Jensensche Ungleichung).
Eine Terminzusage ist ein Quantil, kein Mittelwert. Wer den Erwartungswert zusagt, reißt den Termin bei fünf parallelen Vorgängen in 95 % der Fälle.
Zweistufige stochastische Programme trennen die Festlegung von der Reaktion — ein \mathbf{x}, aber je Szenario ein \mathbf{y}_s.
Der EVPI begrenzt, was eine perfekte Prognose wert sein darf.
Robuste Optimierung braucht keine Wahrscheinlichkeiten, nur eine Unsicherheitsmenge — und wird für einfache Mengen zu einem gewöhnlichen Problem mit Abzugsterm.
+
Chance Constraints schreiben die Zusage selbst ins Modell. Unter Normalverteilung wird daraus eine Kegelbedingung (der Sicherheitszuschlag ist eine Norm, deshalb belohnt sie Mischung), szenariobasiert ein MILP mit Big-M ohne jede Verteilungsannahme.
+
Sicherheit ist konvex bepreist. Im Beispiel kostet der letzte Prozentpunkt das 4,5-fache des ersten. Die richtige Managementfrage lautet nicht „was kostet Sicherheit“, sondern „was kostet der nächste Prozentpunkt“.
+
Eine Zusage gilt nur für die unterstellte Verteilung. Ein Plan, der unter Normalverteilung 95 % verspricht, hielt gemessen 87 %, weil das Modell die Kältewelle nicht kannte, in der alles zugleich ausfällt. Szenariobasierte Zusagen wiederum überanpassen — beide müssen an zurückgehaltenen Daten nachgemessen werden.
Beziffern Sie immer Preis und Nutzen der Absicherung. Ohne beide Zahlen ist die Entscheidung nicht begründbar.
Ausblick.Kapitel 13 fügt die Zeitdimension hinzu: Entscheidungen, die über viele Perioden aufeinander aufbauen — gelöst mit der Bellman-Gleichung.
diff --git a/OR_HTML_04/vorwort.html b/OR_HTML_04/vorwort.html
index e09ecab..753f40f 100644
--- a/OR_HTML_04/vorwort.html
+++ b/OR_HTML_04/vorwort.html
@@ -609,141 +609,146 @@ Alles bereit — Sie können mit dem ersten Kapitel beginnen.
diff --git a/Operations_Research_mit_Python_Version_04.md b/Operations_Research_mit_Python_Version_04.md
index 1c1efc3..121f42c 100644
--- a/Operations_Research_mit_Python_Version_04.md
+++ b/Operations_Research_mit_Python_Version_04.md
@@ -359,6 +359,7 @@ Alle Beispielprogramme dieses Buchs im Überblick, sortiert nach Kapitel — pra
| `Monte_Carlo.py` | Monte-Carlo-Bewertung | [Kapitel 12](#kap-unsicherheit) |
| `Stochastische_Optimierung.py` | Two-Stage mit Recourse | [Kapitel 12](#kap-unsicherheit) |
| `Robuste_Optimierung.py` | Worst-Case-Absicherung | [Kapitel 12](#kap-unsicherheit) |
+| `Chance_Constraints.py` | Zusage „mit 95 % Sicherheit“, SOC und Big-M | [Kapitel 12](#kap-unsicherheit) |
| `Bellman_Minimalbeispiel.py` | Rückwärtsinduktion | [Kapitel 13](#kap-dynamische-programmierung) |
| `Mehrziel_Pareto.py` | Pareto-Front, ε-Constraint, Gewichtslücke | [Kapitel 14](#kap-mehrziel) |
| `Predict_then_Optimize.py` | MSE gegen Entscheidungskosten | [Kapitel 15](#kap-prognose) |
@@ -12773,19 +12774,20 @@ else:
> **📌 Kapitel auf einen Blick**
>
-> **Worum geht es?** Bisher waren alle Parameter bekannt. In der Realität sind Nachfrage, Rendite und Fahrzeit **Zufallsgrößen**. Dieses Kapitel zeigt drei Wege, damit umzugehen — und warum „einfach den Mittelwert einsetzen“ der schlechteste davon ist.
+> **Worum geht es?** Bisher waren alle Parameter bekannt. In der Realität sind Nachfrage, Rendite und Fahrzeit **Zufallsgrößen**. Dieses Kapitel zeigt vier Wege, damit umzugehen — und warum „einfach den Mittelwert einsetzen“ der schlechteste davon ist.
>
> **Voraussetzungen:** [Kapitel 5](#kap-lp), [Kapitel 11](#kap-qp-nlp). Grundbegriffe der Statistik (Erwartungswert, Quantil).
>
-> **Danach können Sie:** Ein zweistufiges stochastisches Modell aufstellen, Monte-Carlo zur Risikomessung einsetzen, ein robustes Modell für den Worst Case formulieren — und erkennen, wann Rechnen mit Mittelwerten zulässig ist und wann es systematisch danebengeht.
+> **Danach können Sie:** Ein zweistufiges stochastisches Modell aufstellen, Monte-Carlo zur Risikomessung einsetzen, ein robustes Modell für den Worst Case formulieren, eine Wahrscheinlichkeitszusage („mit 95 % Sicherheit“) als lösbare Nebenbedingung schreiben — und erkennen, wann Rechnen mit Mittelwerten zulässig ist und wann es systematisch danebengeht.
>
-> **Zeitbedarf:** ca. 5,5 Stunden.
+> **Zeitbedarf:** ca. 6,5 Stunden.
>
> **Programme:**\
> `Fluch_des_Durchschnitts.py`\
> `Monte_Carlo.py`\
> `Stochastische_Optimierung.py`\
-> `Robuste_Optimierung.py`
+> `Robuste_Optimierung.py`\
+> `Chance_Constraints.py`
>
> **Notebook:** `Notebooks_04/unsicherheit.ipynb`\
> [In Google Colab öffnen](https://colab.research.google.com/github/dschlueter/or-mit-python/blob/main/Notebooks_04/unsicherheit.ipynb)
@@ -12867,9 +12869,10 @@ Nach diesem Kapitel können Sie …
2. … ein **zweistufiges stochastisches Programm** mit Recourse formulieren.
3. … Monte-Carlo einsetzen, um Verteilungen statt Punktschätzungen zu bewerten.
4. … ein **robustes** Modell mit Unsicherheitsmenge aufstellen und seinen Preis beziffern.
-5. … begründen, welcher der drei Ansätze für ein gegebenes Problem passt.
-6. … das **kritische Verhältnis** eines Newsvendor-Problems aufstellen und daraus die optimale Bestellmenge als Quantil ablesen.
-7. … eine Terminzusage als Quantil formulieren statt als Mittelwert.
+5. … eine **Wahrscheinlichkeitszusage** („mit 95 % Sicherheit“) als Chance Constraint schreiben — analytisch als Kegelbedingung oder szenariobasiert mit Big-M — und den Preis eines Prozentpunkts Sicherheit beziffern.
+6. … begründen, welcher der vier Ansätze für ein gegebenes Problem passt.
+7. … das **kritische Verhältnis** eines Newsvendor-Problems aufstellen und daraus die optimale Bestellmenge als Quantil ablesen.
+8. … eine Terminzusage als Quantil formulieren statt als Mittelwert.
---
@@ -12993,18 +12996,20 @@ Kein Rechentrick, sondern brute-force über alle ganzzahligen $x$ von 0 bis 500
> **🎯 Merksatz** Der Durchschnittskunde kauft nie. Das Durchschnittsszenario tritt nie ein. Wer mit Mittelwerten plant, optimiert eine Welt, die es nicht gibt.
-### Die drei Ansätze im Überblick
+### Die vier Ansätze im Überblick

-| | **Monte-Carlo** | **Stochastische Programmierung** | **Robuste Optimierung** |
-| --- | --- | --- | --- |
-| Braucht | Verteilung | Szenarien **mit Wahrscheinlichkeiten** | nur eine **Menge** möglicher Werte |
-| Optimiert | nichts (bewertet nur) | **Erwartungswert** über Szenarien | **Worst Case** in der Menge |
-| Typische Frage | „Wie riskant ist dieser Plan?“ | „Was ist im Mittel am besten?“ | „Was hält auch im schlimmsten Fall?“ |
-| Stärke | beliebige Kennzahlen, sehr flexibel | nutzt Wahrscheinlichkeiten voll aus | keine Verteilungsannahme nötig |
-| Schwäche | keine Optimierung | Wahrscheinlichkeiten müssen stimmen | oft übervorsichtig, kostet Ertrag |
-| Kapitel | 9.2 | 9.3 | 9.4 |
+| | **Monte-Carlo** | **Stochastische Programmierung** | **Robuste Optimierung** | **Chance Constraints** |
+| --- | --- | --- | --- | --- |
+| Braucht | Verteilung | Szenarien **mit Wahrscheinlichkeiten** | nur eine **Menge** möglicher Werte | Verteilung **oder** Szenarien |
+| Optimiert | nichts (bewertet nur) | **Erwartungswert** über Szenarien | **Worst Case** in der Menge | Kosten bei **zugesicherter Quote** |
+| Typische Frage | „Wie riskant ist dieser Plan?“ | „Was ist im Mittel am besten?“ | „Was hält auch im schlimmsten Fall?“ | „Was hält in 95 % der Fälle?“ |
+| Stärke | beliebige Kennzahlen, sehr flexibel | nutzt Wahrscheinlichkeiten voll aus | keine Verteilungsannahme nötig | die Zusage steht **im Modell** |
+| Schwäche | keine Optimierung | Wahrscheinlichkeiten müssen stimmen | oft übervorsichtig, kostet Ertrag | Zusage gilt nur für die unterstellte Verteilung |
+| Nachzulesen in | [Abschnitt 12.4](#sec:unsicherheit-monte-carlo-simulation) | [Abschnitt 12.5](#sec:unsicherheit-zweistufige-stochastische-programmierung) | [Abschnitt 12.6](#sec:unsicherheit-robuste-optimierung-gegen-den-worst-case) | [Abschnitt 12.7](#sec:unsicherheit-chance-constraints) |
+
+**Der Unterschied zwischen den letzten beiden** ist der, an dem die meisten Diskussionen hängenbleiben. Die robuste Optimierung sagt: *„Es darf **nie** schiefgehen“* — und zahlt dafür. Die Chance Constraint sagt: *„Es darf in höchstens 5 % der Fälle schiefgehen“* — und macht damit verhandelbar, was die Sicherheit kosten darf.
---
@@ -13477,13 +13482,382 @@ if __name__ == "__main__":
---
-## 12.7 Übungsaufgaben {#sec:unsicherheit-uebungsaufgaben}
+## 12.7 Wahrscheinlichkeitsbeschränkungen: „mit 95 % Sicherheit“ {#sec:unsicherheit-chance-constraints}
+
+Die drei bisherigen Ansätze beantworten Fragen, die im Sitzungssaal selten so gestellt werden. „Was ist im Mittel am besten?“ hört ein Vorstand als Ausflucht; „was hält auch im schlimmsten Fall?“ als Einladung, zu viel Geld auszugeben. Gefragt wird fast immer nach einer **Wahrscheinlichkeit**: *Mit welcher Sicherheit hält der Plan?*
+
+Die **Wahrscheinlichkeitsbeschränkung**`\index{Chance Constraint}`{=latex} schreibt genau das ins Modell:
+
+$$
+\mathbb{P}\!\left(\mathbf{a}^\top\mathbf{x} \ge b\right) \ge 1 - \alpha
+$$
+
+> **📐 Formel-Lesehilfe**
+> * $\mathbf{a}$ — der **unsichere** Vektor: Verfügbarkeiten, Erträge, Fahrzeiten. Er ist keine Zahl, sondern eine Zufallsgröße.
+> * $\mathbf{x}$ — Ihre Entscheidung. Sie fällt, **bevor** $\mathbf{a}$ sich zeigt.
+> * $\alpha$ — die Ausfallwahrscheinlichkeit, die Sie akzeptieren. $\alpha = 0{,}05$ heißt „in höchstens 5 % der Fälle darf es schiefgehen“.
+>
+> **Ohne Formel gesagt:** „Plane so, dass die Zusage in 95 von 100 Fällen hält — und sag mir, was das kostet.“
+
+**Das Problem daran.** So aufgeschrieben ist die Bedingung nicht lösbar: Ein Solver kann nichts mit einem Wahrscheinlichkeitsoperator anfangen. Es braucht einen Weg, sie in etwas zu übersetzen, das ein Optimierer versteht. Es gibt zwei, und sie unterscheiden sich in dem, was sie über die Verteilung voraussetzen.
+
+### Weg 1: Normalverteilung — die Bedingung wird ein Kegel
+
+Ist $\mathbf{a} \sim \mathcal{N}(\hat{\mathbf{a}}, \boldsymbol{\Sigma})$, dann ist $\mathbf{a}^\top\mathbf{x}$ für festes $\mathbf{x}$ selbst normalverteilt, mit Erwartungswert $\hat{\mathbf{a}}^\top\mathbf{x}$ und Standardabweichung $\sqrt{\mathbf{x}^\top\boldsymbol{\Sigma}\mathbf{x}}$. Damit lässt sich die Wahrscheinlichkeit durch ein Quantil ersetzen:
+
+$$
+\hat{\mathbf{a}}^\top\mathbf{x} \;-\; z_{1-\alpha}\,\lVert \mathbf{L}^\top\mathbf{x}\rVert_2
+\;\ge\; b
+\qquad\text{mit}\qquad
+\boldsymbol{\Sigma} = \mathbf{L}\mathbf{L}^\top,\quad
+z_{1-\alpha} = \Phi^{-1}(1-\alpha)
+$$
+
+Das ist eine **Second-Order-Cone-Bedingung**`\index{Konische Optimierung (SOCP, SDP)}`{=latex} — dieselbe Bauform, die [Kapitel 11](#kap-qp-nlp) eingeführt hat, und CVXPY löst sie ohne Umstände.
+
+> **🎯 Warum eine Norm und keine Summe** Der Sicherheitszuschlag $\lVert\mathbf{L}^\top\mathbf{x}\rVert_2$ ist **keine** Summe von Einzelzuschlägen je Anlage. Genau darin steckt der Nutzen der Mischung: Zwei gegenläufige Quellen schwanken **gemeinsam** weniger als jede für sich, und die Norm rechnet das automatisch mit. Ein fester Aufschlag je Anlage könnte das nicht — er wäre linear und würde Diversifikation nicht belohnen.
+
+### Weg 2: Szenarien — Big-M ohne Verteilungsannahme
+
+Liegen statt einer Verteilung nur $S$ beobachtete Szenarien vor, bekommt jedes eine Binärvariable $z_s$, die sagt, ob es verletzt werden **darf**:
+
+$$
+\begin{aligned}
+\mathbf{a}_s^\top\mathbf{x} &\ge b - M z_s && \forall s\\
+\sum_{s} z_s &\le \alpha S\\
+z_s &\in \{0,1\}
+\end{aligned}
+$$
+
+Aus dem Kegelproblem wird ein MILP mit $S$ Binärvariablen. Für $M$ gilt, was [Kapitel 6](#kap-milp) über die Big-M-Falle sagt: **so klein wie möglich**. Hier ist $M = b$ die kleinste gültige Schranke, denn $\mathbf{a}_s^\top\mathbf{x} \ge 0$ — größer kann eine Verletzung gar nicht ausfallen.
+
+### Der Fall: ein Kraftwerkspark mit 500 MW Zusage
+
+```python
+#!/usr/bin/env python3
+
+# Chance_Constraints.py
+"""
+Kapitel Unsicherheit: Wahrscheinlichkeitsbeschraenkungen.
+
+Das Management fragt selten "was ist im Mittel am besten?", sondern "mit
+welcher Sicherheit haelt der Plan?". Genau das formuliert eine Chance
+Constraint: P(Versorgung >= Bedarf) >= 1 - alpha.
+
+Gezeigt werden beide Wege dorthin, an derselben Instanz:
+ (a) analytisch - unter Normalverteilungsannahme wird daraus eine
+ Second-Order-Cone-Bedingung, loesbar mit CVXPY
+ (b) szenariobasiert - Big-M mit Binaervariablen, fuer beliebige
+ empirische Verteilungen
+
+Und die beiden Messungen, auf die es ankommt: Was kostet ein Prozentpunkt
+Versorgungssicherheit - und haelt die Zusage auch dann, wenn die Verteilung
+nicht normal ist?
+
+Solver: CLARABEL (Kegel) und SciPy/HiGHS (gemischt-ganzzahlig). Weder ortools
+noch ein direkter highspy-Import, damit alles in einem Prozess laeuft.
+"""
+
+import cvxpy as cp
+import numpy as np
+from scipy.stats import norm
+
+# --- Der Kraftwerkspark ----------------------------------------------------
+# Die Verfuegbarkeit ist der Anteil, den eine installierte MW im Mittel
+# wirklich liefert: bei Wind und Sonne klein und stark schwankend, bei Gas und
+# Biomasse gross und stabil. Die Ausbaugrenze ist der Standort - Flaeche,
+# Genehmigung, Brennstoffversorgung.
+TECHNIK = ["Gaskraftwerk", "Windpark", "Solarpark", "Biomasse"]
+KOSTEN = np.array([65_000.0, 24_000.0, 12_000.0, 60_000.0]) # EUR je MW und Jahr
+VERFUEGBAR = np.array([0.92, 0.35, 0.18, 0.85])
+STREUUNG = np.array([0.05, 0.16, 0.10, 0.04])
+GRENZE = np.array([400.0, 250.0, 600.0, 350.0]) # MW
+
+# Wind und Sonne sind leicht gegenlaeufig: Ein Tiefdruckgebiet bringt Wind und
+# Wolken zugleich. Genau diese Korrelation macht die Bedingung zu einem Kegel
+# und nicht zu einer Summe unabhaengiger Einzelzuschlaege.
+KORRELATION = np.array([
+ [1.00, 0.00, 0.00, 0.05],
+ [0.00, 1.00, -0.25, 0.00],
+ [0.00, -0.25, 1.00, 0.00],
+ [0.05, 0.00, 0.00, 1.00],
+])
+SIGMA = np.diag(STREUUNG) @ KORRELATION @ np.diag(STREUUNG)
+WURZEL = np.linalg.cholesky(SIGMA) # L mit L @ L.T == SIGMA
+
+BEDARF = 500.0 # MW, die gesichert bereitstehen muessen
+N = len(TECHNIK)
+
+# Die Kaeltewelle mit Dunkelflaute: selten, aber sie trifft alles zugleich.
+# Wind und Sonne brechen fast vollstaendig weg - und, das ist der Punkt, das
+# Gaskraftwerk liefert ebenfalls weniger, weil bei Frost der Netzdruck faellt.
+# Eine Kovarianzmatrix mit Korrelationen um null kann das nicht ausdruecken.
+P_KAELTEWELLE = 0.08
+EINBRUCH = np.array([0.72, 0.10, 0.15, 0.88])
+
+
+def mittelwertplan():
+ """Plant mit den Erwartungswerten - ignoriert die Streuung vollstaendig."""
+ x = cp.Variable(N, nonneg=True)
+ problem = cp.Problem(cp.Minimize(KOSTEN @ x),
+ [VERFUEGBAR @ x >= BEDARF, x <= GRENZE])
+ problem.solve(solver=cp.CLARABEL)
+ return problem.value, x.value
+
+
+def chance_constraint_analytisch(alpha):
+ """
+ P(a^T x >= BEDARF) >= 1 - alpha unter a ~ N(VERFUEGBAR, SIGMA).
+
+ Aequivalent zu: VERFUEGBAR^T x - z * ||L^T x||_2 >= BEDARF
+ mit z = Phi^-1(1 - alpha). Das ist eine Second-Order-Cone-Bedingung: Der
+ Sicherheitszuschlag ist eine Norm ueber x, kein fester Aufschlag je Anlage.
+ Deshalb belohnt sie Mischung - zwei gegenlaeufige Quellen schwanken
+ gemeinsam weniger als jede fuer sich.
+ """
+ x = cp.Variable(N, nonneg=True)
+ z = norm.ppf(1 - alpha)
+ bedingung = VERFUEGBAR @ x - z * cp.norm(WURZEL.T @ x, 2) >= BEDARF
+ problem = cp.Problem(cp.Minimize(KOSTEN @ x), [bedingung, x <= GRENZE])
+ problem.solve(solver=cp.CLARABEL)
+ return problem.value, x.value
+
+
+def chance_constraint_szenarien(a, alpha):
+ """
+ Dieselbe Zusage ohne Verteilungsannahme: Fuer jedes Szenario s sagt eine
+ Binaervariable z_s, ob es verletzt werden darf.
+
+ a_s^T x >= BEDARF - M * z_s fuer alle s
+ sum_s z_s <= alpha * S
+
+ M = BEDARF ist die kleinstmoegliche gueltige Schranke, denn a_s^T x >= 0:
+ Groesser kann eine Verletzung gar nicht ausfallen. Ein unnoetig grosses M
+ wuerde die LP-Relaxierung aufweichen und die Suche verlangsamen - die
+ Big-M-Falle aus dem Kapitel Gemischt-ganzzahlige Optimierung.
+ """
+ S = len(a)
+ x = cp.Variable(N, nonneg=True)
+ z = cp.Variable(S, boolean=True)
+ problem = cp.Problem(
+ cp.Minimize(KOSTEN @ x),
+ [a @ x >= BEDARF - BEDARF * z, cp.sum(z) <= alpha * S, x <= GRENZE])
+ problem.solve(solver=cp.SCIPY)
+ return problem.value, x.value
+
+
+def ziehe_wetter(anzahl, seed):
+ """Mischverteilung: normales Wetter, mit P_KAELTEWELLE ein Einbruch."""
+ rng = np.random.default_rng(seed)
+ a = rng.multivariate_normal(VERFUEGBAR, SIGMA, size=anzahl)
+ getroffen = rng.random(anzahl) < P_KAELTEWELLE
+ a[getroffen] *= EINBRUCH
+ return np.clip(a, 0.0, 1.0)
+
+
+def sicherheit(x, a):
+ """Anteil der Szenarien, in denen der Plan den Bedarf deckt."""
+ return float(np.mean(a @ x >= BEDARF))
+
+
+KOPF = (f"{'Plan':<20} {'Kosten (EUR)':>13} {'Gas':>4} {'Wind':>4} "
+ f"{'Sol':>4} {'Bio':>4} {'Normalwelt':>11} {'echte Welt':>11}")
+
+
+def zeile(name, kosten, x, in_normal, in_echt):
+ mix = " ".join(f"{w:4.0f}" for w in x)
+ return (f"{name:<20} {kosten:>13,.0f} {mix} "
+ f"{in_normal * 100:>9.2f} % {in_echt * 100:>9.2f} %")
+
+
+if __name__ == "__main__":
+ print("=" * 78)
+ print(" WAHRSCHEINLICHKEITSBESCHRAENKUNGEN IM KRAFTWERKSPARK")
+ print("=" * 78)
+ print(f"Gesichert bereitzustellen: {BEDARF:.0f} MW\n")
+ print(f"{'Technologie':<14} {'EUR/MW':>8} {'verfuegbar':>11} {'Streuung':>9} "
+ f"{'Grenze':>8} {'EUR je erw. MW':>15}")
+ print("-" * 78)
+ for i, name in enumerate(TECHNIK):
+ print(f"{name:<14} {KOSTEN[i]:>8,.0f} {VERFUEGBAR[i]:>10.2f} "
+ f"{STREUUNG[i]:>8.2f} {GRENZE[i]:>7.0f} "
+ f"{KOSTEN[i] / VERFUEGBAR[i]:>15,.0f}")
+ print(f"\nVollausbau liefert im Mittel {VERFUEGBAR @ GRENZE:.0f} MW, "
+ f"in der Kaeltewelle {(VERFUEGBAR * EINBRUCH) @ GRENZE:.0f} MW.")
+
+ # Zwei grosse, unabhaengige Testmengen. An ihnen wird JEDER Plan gemessen -
+ # einmal in der unterstellten Normalwelt, einmal in der echten Verteilung.
+ echt = ziehe_wetter(200_000, seed=771)
+ normalwelt = np.random.default_rng(4711).multivariate_normal(
+ VERFUEGBAR, SIGMA, size=200_000)
+
+ print("\n" + "=" * 78)
+ print(" (1) Mittelwertplan und Chance Constraints im Vergleich")
+ print("=" * 78)
+ print(KOPF)
+ print("-" * 78)
+
+ k_mittel, x_mittel = mittelwertplan()
+ stufen = [("Mittelwert", k_mittel, sicherheit(x_mittel, normalwelt))]
+ print(zeile("Mittelwert", k_mittel, x_mittel,
+ sicherheit(x_mittel, normalwelt), sicherheit(x_mittel, echt)))
+
+ plaene = {}
+ for alpha in (0.20, 0.10, 0.05, 0.01):
+ kosten, x = chance_constraint_analytisch(alpha)
+ plaene[alpha] = (kosten, x)
+ quote_normal = sicherheit(x, normalwelt)
+ stufen.append((f"Zusage {(1 - alpha) * 100:.0f} %", kosten, quote_normal))
+ print(zeile(f"Zusage {(1 - alpha) * 100:.0f} %", kosten, x,
+ quote_normal, sicherheit(x, echt)))
+
+ print("\n In der Normalwelt trifft jede Zusage ihren Wert - das Verfahren")
+ print(" rechnet richtig. Die Spalte 'echte Welt' kommt in Teil (3).")
+
+ print("\n" + "=" * 78)
+ print(" (2) Was kostet ein Prozentpunkt Versorgungssicherheit?")
+ print("=" * 78)
+ print(f"{'von -> nach':<26} {'Prozentpunkte':>13} {'Mehrkosten':>13} "
+ f"{'EUR je Punkt':>13}")
+ print("-" * 78)
+ for (n1, k1, q1), (n2, k2, q2) in zip(stufen, stufen[1:]):
+ d_punkte = (q2 - q1) * 100
+ d_kosten = k2 - k1
+ print(f"{n1 + ' -> ' + n2:<26} {d_punkte:>13.2f} {d_kosten:>13,.0f} "
+ f"{d_kosten / d_punkte:>13,.0f}")
+ erst = (stufen[1][1] - stufen[0][1]) / ((stufen[1][2] - stufen[0][2]) * 100)
+ letzt = (stufen[-1][1] - stufen[-2][1]) / ((stufen[-1][2] - stufen[-2][2]) * 100)
+ print(f"\n Der letzte Prozentpunkt kostet das {letzt / erst:.1f}-fache des ersten.")
+ print(" Sicherheit ist konvex bepreist - genau deshalb muss jemand")
+ print(" entscheiden, wie viel davon das Unternehmen kaufen will.")
+
+ print("\n" + "=" * 78)
+ print(" (3) Und wenn die Verteilung nicht normal ist?")
+ print("=" * 78)
+ k_soc, x_soc = plaene[0.05]
+ print(f"Die echte Welt kennt die Kaeltewelle mit Dunkelflaute: in "
+ f"{P_KAELTEWELLE * 100:.0f} % der Faelle liefern")
+ print(f"Wind {EINBRUCH[1] * 100:.0f} %, Sonne {EINBRUCH[2] * 100:.0f} % "
+ f"und - entscheidend - auch das Gaskraftwerk nur "
+ f"{EINBRUCH[0] * 100:.0f} %")
+ print("des Ueblichen. Alles bricht gleichzeitig ein.\n")
+
+ a_bau = ziehe_wetter(400, seed=20260908)
+ k_sz, x_sz = chance_constraint_szenarien(a_bau, 0.05)
+
+ print(KOPF)
+ print("-" * 78)
+ print(zeile("SOC, Zusage 95 %", k_soc, x_soc,
+ sicherheit(x_soc, normalwelt), sicherheit(x_soc, echt)))
+ print(zeile("Szenarien, 95 %", k_sz, x_sz,
+ sicherheit(x_sz, normalwelt), sicherheit(x_sz, echt)))
+
+ print(f"\n Der SOC-Plan verspricht 95 % und haelt "
+ f"{sicherheit(x_soc, echt) * 100:.1f} %. Nicht das Verfahren ist")
+ print(f" falsch, sondern die Annahme: In der Normalwelt liefert derselbe")
+ print(f" Plan {sicherheit(x_soc, normalwelt) * 100:.1f} %.")
+ print(f"\n Der Szenarioplan ({len(a_bau)} Szenarien, {len(a_bau)} "
+ f"Binaervariablen) kommt auf")
+ print(f" {sicherheit(x_sz, echt) * 100:.1f} % - und kostet dafuer "
+ f"{(k_sz / k_soc - 1) * 100:+.1f} %. Er kauft keine Windkraft mehr:")
+ print(" Was im Ernstfall ausfaellt, hilft der Zusage nicht.")
+ print("=" * 78)
+```
+
+**Ausgabe:**
+
+```
+==============================================================================
+ WAHRSCHEINLICHKEITSBESCHRAENKUNGEN IM KRAFTWERKSPARK
+==============================================================================
+Gesichert bereitzustellen: 500 MW
+
+Technologie EUR/MW verfuegbar Streuung Grenze EUR je erw. MW
+------------------------------------------------------------------------------
+Gaskraftwerk 65,000 0.92 0.05 400 70,652
+Windpark 24,000 0.35 0.16 250 68,571
+Solarpark 12,000 0.18 0.10 600 66,667
+Biomasse 60,000 0.85 0.04 350 70,588
+
+Vollausbau liefert im Mittel 861 MW, in der Kaeltewelle 552 MW.
+
+==============================================================================
+ (1) Mittelwertplan und Chance Constraints im Vergleich
+==============================================================================
+Plan Kosten (EUR) Gas Wind Sol Bio Normalwelt echte Welt
+------------------------------------------------------------------------------
+Mittelwert 34,694,565 8 250 600 350 50.08 % 46.18 %
+Zusage 80 % 36,382,277 226 25 44 343 80.06 % 73.61 %
+Zusage 90 % 36,994,777 233 20 35 349 89.99 % 82.82 %
+Zusage 95 % 37,514,307 241 19 31 350 94.97 % 87.44 %
+Zusage 99 % 38,529,698 258 18 28 350 98.97 % 91.05 %
+
+ In der Normalwelt trifft jede Zusage ihren Wert - das Verfahren
+ rechnet richtig. Die Spalte 'echte Welt' kommt in Teil (3).
+
+==============================================================================
+ (2) Was kostet ein Prozentpunkt Versorgungssicherheit?
+==============================================================================
+von -> nach Prozentpunkte Mehrkosten EUR je Punkt
+------------------------------------------------------------------------------
+Mittelwert -> Zusage 80 % 29.98 1,687,712 56,289
+Zusage 80 % -> Zusage 90 % 9.93 612,500 61,685
+Zusage 90 % -> Zusage 95 % 4.98 519,530 104,365
+Zusage 95 % -> Zusage 99 % 4.00 1,015,390 253,848
+
+ Der letzte Prozentpunkt kostet das 4.5-fache des ersten.
+ Sicherheit ist konvex bepreist - genau deshalb muss jemand
+ entscheiden, wie viel davon das Unternehmen kaufen will.
+
+==============================================================================
+ (3) Und wenn die Verteilung nicht normal ist?
+==============================================================================
+Die echte Welt kennt die Kaeltewelle mit Dunkelflaute: in 8 % der Faelle liefern
+Wind 10 %, Sonne 15 % und - entscheidend - auch das Gaskraftwerk nur 72 %
+des Ueblichen. Alles bricht gleichzeitig ein.
+
+Plan Kosten (EUR) Gas Wind Sol Bio Normalwelt echte Welt
+------------------------------------------------------------------------------
+SOC, Zusage 95 % 37,514,307 241 19 31 350 94.97 % 87.44 %
+Szenarien, 95 % 43,760,709 350 0 0 350 100.00 % 94.84 %
+
+ Der SOC-Plan verspricht 95 % und haelt 87.4 %. Nicht das Verfahren ist
+ falsch, sondern die Annahme: In der Normalwelt liefert derselbe
+ Plan 95.0 %.
+
+ Der Szenarioplan (400 Szenarien, 400 Binaervariablen) kommt auf
+ 94.8 % - und kostet dafuer +16.7 %. Er kauft keine Windkraft mehr:
+ Was im Ernstfall ausfaellt, hilft der Zusage nicht.
+==============================================================================
+```
+
+**Drei Befunde, die man dem Plan nicht ansieht.**
+
+**Erstens: Der Mittelwertplan fällt in der Hälfte aller Fälle aus.** Das ist kein Fehler, sondern die Definition des Erwartungswerts — und dieselbe Falle wie in [Abschnitt 12.3](#sec:unsicherheit-der-fluch-des-durchschnitts), nur teurer. Er kauft die billigsten Quellen je *erwarteter* Megawattstunde und maximiert Wind und Sonne bis an die Ausbaugrenze. Sobald die Zusage im Modell steht, dreht sich das Bild: Gas und Biomasse tragen den Plan, Wind und Sonne schrumpfen auf einen Rest.
+
+**Zweitens: Sicherheit ist konvex bepreist.** Die ersten dreißig Prozentpunkte kosten rund 56 000 € pro Punkt, die letzten vier rund 254 000 € — das **4,5-fache**. Das ist die Zahl, die in die Vorlage gehört: Nicht „was kostet Versorgungssicherheit“, sondern „was kostet der *nächste* Prozentpunkt“. Wer 99,9 % fordert, ohne diese Kurve gesehen zu haben, fordert ins Blaue.
+
+**Drittens — und das ist der unangenehme Befund: Die Zusage gilt nur für die unterstellte Verteilung.** Der Plan verspricht 95 % und hält **87,4 %**. Nicht weil das Verfahren falsch rechnet: In einer reinen Normalwelt liefert derselbe Plan exakt seine 95,0 %. Sondern weil die Normalverteilung eine Kältewelle mit Dunkelflaute nicht kennt. In der bricht **alles zugleich** ein — auch das Gaskraftwerk, weil bei Frost der Netzdruck fällt. Eine Kovarianzmatrix mit Korrelationen um null kann so etwas nicht ausdrücken.
+
+Der szenariobasierte Weg kennt diese Fälle, weil sie in den Daten stehen. Er kommt auf 94,8 %, kostet dafür 16,7 % mehr — und kauft **keine Windkraft mehr**. Die Logik dahinter ist hart und richtig: Was im Ernstfall ausfällt, hilft der Zusage nicht.
+
+> **⚠️ Die Szenariomethode überanpasst — und mehr Szenarien helfen nicht verlässlich** Bei $S$ Szenarien und $\alpha = 5\,\%$ darf das Modell genau $0{,}05 \cdot S$ davon ignorieren — und es sucht sich die **teuersten** aus. In der Stichprobe trifft es seine Quote dadurch immer; außerhalb streut sie.
+>
+> Gemessen an zwölf Läufen dieses Modells (je vier Ziehungen bei $S = 200$, $400$, $800$, geprüft an 200 000 unabhängigen Szenarien): Die tatsächliche Quote lag zwischen **93,1 % und 96,7 %**. Die Spanne wurde von $S = 200$ auf $400$ enger (3,5 → 1,2 Prozentpunkte), bei $S = 800$ aber wieder breiter (3,1) — die Zahl der Szenarien allein schließt die Lücke also nicht.
+>
+> **Konsequenz für die Praxis:** Eine mit Szenarien erkaufte Zusage immer an einer **zurückgehaltenen** Stichprobe nachmessen, so wie es das Programm oben tut. Wer nur die Trefferquote in den Baudaten berichtet, berichtet eine Tautologie — dieselbe Falle wie beim Backtest in [Kapitel 21](#kap-handelsmaschine).
+
+> **🎯 Merksatz** Die robuste Optimierung fragt „was, wenn alles schiefgeht?“, die Chance Constraint „wie oft darf es schiefgehen?“. Nur die zweite Frage hat eine Antwort, über die man verhandeln kann — und nur sie zwingt dazu, den Preis eines Prozentpunkts Sicherheit zu beziffern.
+
+---
+
+## 12.8 Übungsaufgaben {#sec:unsicherheit-uebungsaufgaben}
> Lösungen: [Abschnitt A.12](#sec:loesungen-unsicherheit).
**Aufgabe 12.1 ⭐ — Fluch des Durchschnitts erkennen.** Ein Bauunternehmer plant mit der *durchschnittlichen* Bauzeit von 8 Monaten. Warum ist die *erwartete* Fertigstellung trotzdem später als 8 Monate, wenn Verzögerungen wahrscheinlicher sind als Beschleunigungen? Nennen Sie zwei weitere Alltagsbeispiele.
-**Aufgabe 12.2 ⭐ — Ansatz wählen.** Welcher der drei Ansätze passt? Begründen Sie: (a) Wie viele Notstromaggregate für ein Krankenhaus? (b) Wie viel Weizen einkaufen bei bekannter Preisverteilung? (c) Wie hoch ein Deich gebaut werden muss? (d) Wie viele Saisonkräfte einstellen bei bekannten Nachfrageszenarien?
+**Aufgabe 12.2 ⭐ — Ansatz wählen.** Welcher der vier Ansätze passt? Begründen Sie: (a) Wie viele Notstromaggregate für ein Krankenhaus? (b) Wie viel Weizen einkaufen bei bekannter Preisverteilung? (c) Wie hoch ein Deich gebaut werden muss? (d) Wie viele Saisonkräfte einstellen bei bekannten Nachfrageszenarien? (e) Wie viel Speicherkapazität, damit ein Gasnetz an 99 % aller Wintertage hält?
**Aufgabe 12.3 ⭐⭐ — Zweistufiges Modell rechnen.** Wiederholen Sie die Handrechnung *Warum der Mittelwert in die Irre führt* mit geänderten Kosten: Spot 60 € statt 120 €. (a) Wie ändert sich die optimale Kapazität? (b) Erklären Sie die Richtung der Änderung. (c) Bei welchem Spot-Preis wäre die Mittelwert-Planung optimal?
@@ -13491,11 +13865,13 @@ if __name__ == "__main__":
**Aufgabe 12.5 ⭐⭐⭐ — Monte-Carlo erweitern.** Erweitern Sie `Monte_Carlo.py`: (a) Fügen Sie ein Servicelevel-Kriterium hinzu: „Die Unterdeckung darf höchstens in 5 % der Fälle auftreten.“ Welche Kapazität ist dafür nötig, und was kostet sie zusätzlich? (b) Berechnen Sie den CVaR der Kosten (Mittelwert der schlechtesten 5 %). (c) Zeichnen Sie ein Histogramm der Kosten für drei Kapazitäten.
-**Aufgabe 12.6 ⭐⭐⭐ — Budgeted Uncertainty.** Erweitern Sie `Robuste_Optimierung.py` um den Ansatz von Bertsimas/Sim: Höchstens $\Gamma$ Titel nehmen gleichzeitig ihren Worst Case an. Variieren Sie $\Gamma \in \{0, 1, 2, 3, 4\}$ und stellen Sie den Verlauf von Ertrag und Absicherung dar. (Hinweis: Bei $\Gamma$ ganzzahlig genügt es, die $\Gamma$ größten $\delta_i w_i$ abzuziehen — das lässt sich in CVXPY mit `cp.sum_largest` formulieren.)
+**Aufgabe 12.6 ⭐⭐ — Den Preis der Zusage selbst bestimmen.** `Chance_Constraints.py` beziffert die Kosten für 80, 90, 95 und 99 %. (a) Ergänzen Sie die Stufen 99,5 % und 99,9 % und setzen Sie die Kosten je Prozentpunkt fort. Was passiert mit dem Anlagenmix? (b) Drosseln Sie die Ausbaugrenze der Biomasse von 350 auf 200 MW. Das **szenariobasierte** Modell meldet dann bei 95 % `infeasible`, das **analytische** dagegen `optimal`. Erklären Sie den Unterschied. Welchem der beiden würden Sie glauben? (c) Ein Vorstand fordert „99,99 % Versorgungssicherheit“. Formulieren Sie in drei Sätzen, was Sie ihm mit dieser Kurve antworten.
+
+**Aufgabe 12.7 ⭐⭐⭐ — Budgeted Uncertainty.** Erweitern Sie `Robuste_Optimierung.py` um den Ansatz von Bertsimas/Sim: Höchstens $\Gamma$ Titel nehmen gleichzeitig ihren Worst Case an. Variieren Sie $\Gamma \in \{0, 1, 2, 3, 4\}$ und stellen Sie den Verlauf von Ertrag und Absicherung dar. (Hinweis: Bei $\Gamma$ ganzzahlig genügt es, die $\Gamma$ größten $\delta_i w_i$ abzuziehen — das lässt sich in CVXPY mit `cp.sum_largest` formulieren.)
---
-## 12.8 Finde den Denkfehler {#sec:unsicherheit-denkfehler}
+## 12.9 Finde den Denkfehler {#sec:unsicherheit-denkfehler}
> **🐛 Finde den Denkfehler 9.1: Warum jedes Projekt zu spät fertig wird**
>
@@ -13535,7 +13911,7 @@ if __name__ == "__main__":
---
-## 12.9 Micro-Quiz {#sec:unsicherheit-quiz}
+## 12.10 Micro-Quiz {#sec:unsicherheit-quiz}
> **❓ Micro-Quiz 9: Drei Fragen zum Selbstcheck**
>
@@ -13549,7 +13925,7 @@ if __name__ == "__main__":
---
-## 12.10 Selbsttest {#sec:unsicherheit-selbsttest}
+## 12.11 Selbsttest {#sec:unsicherheit-selbsttest}
> Antworten: [Anhang A](#selbsttest-loesung-unsicherheit).
@@ -13558,10 +13934,12 @@ if __name__ == "__main__":
3. Was misst der EVPI, und wofür ist er praktisch nützlich?
4. Warum braucht robuste Optimierung keine Wahrscheinlichkeiten?
5. Wie macht man ein Min-Max-Problem mit Box-Unsicherheit lösbar?
+6. Warum wird aus $\mathbb{P}(\mathbf{a}^\top\mathbf{x} \ge b) \ge 1-\alpha$ unter Normalverteilung eine **Norm** und nicht ein fester Zuschlag je Variable?
+7. Ein Plan mit Chance Constraint verspricht 95 % und hält gemessen 87 %. Woran liegt das — und was ändert die szenariobasierte Formulierung daran?
---
-## 12.11 Zusammenfassung {#sec:unsicherheit-zusammenfassung}
+## 12.12 Zusammenfassung {#sec:unsicherheit-zusammenfassung}
* **Der Fluch des Durchschnitts** ist kein Randphänomen und hat zwei Gesichter. Bei asymmetrischen **Kosten** liegt das Optimum systematisch neben dem Mittelwert — das Newsvendor-Verhältnis $c_{-}/(c_{-}+c_{+})$ sagt, wie weit. Bei asymmetrischer **Verknüpfung** (Maximum über parallele Vorgänge) ist der Erwartungswert des Ganzen größer als das Ganze der Erwartungswerte. Beide Male gilt: Der Mittelwert einer Funktion ist nicht die Funktion des Mittelwerts (Jensensche Ungleichung).
* **Eine Terminzusage ist ein Quantil, kein Mittelwert.** Wer den Erwartungswert zusagt, reißt den Termin bei fünf parallelen Vorgängen in 95 % der Fälle.
@@ -13569,6 +13947,9 @@ if __name__ == "__main__":
* **Zweistufige stochastische Programme** trennen die Festlegung von der Reaktion — ein $\mathbf{x}$, aber je Szenario ein $\mathbf{y}_s$.
* **Der EVPI** begrenzt, was eine perfekte Prognose wert sein darf.
* **Robuste Optimierung** braucht keine Wahrscheinlichkeiten, nur eine Unsicherheitsmenge — und wird für einfache Mengen zu einem gewöhnlichen Problem mit Abzugsterm.
+* **Chance Constraints** schreiben die Zusage selbst ins Modell. Unter Normalverteilung wird daraus eine **Kegelbedingung** (der Sicherheitszuschlag ist eine Norm, deshalb belohnt sie Mischung), szenariobasiert ein **MILP mit Big-M** ohne jede Verteilungsannahme.
+* **Sicherheit ist konvex bepreist.** Im Beispiel kostet der letzte Prozentpunkt das 4,5-fache des ersten. Die richtige Managementfrage lautet nicht „was kostet Sicherheit“, sondern „was kostet der *nächste* Prozentpunkt“.
+* **Eine Zusage gilt nur für die unterstellte Verteilung.** Ein Plan, der unter Normalverteilung 95 % verspricht, hielt gemessen 87 %, weil das Modell die Kältewelle nicht kannte, in der *alles* zugleich ausfällt. Szenariobasierte Zusagen wiederum überanpassen — beide müssen an zurückgehaltenen Daten nachgemessen werden.
* **Beziffern Sie immer Preis und Nutzen der Absicherung.** Ohne beide Zahlen ist die Entscheidung nicht begründbar.
**Ausblick.** [Kapitel 13](#kap-dynamische-programmierung) fügt die Zeitdimension hinzu: Entscheidungen, die über viele Perioden aufeinander aufbauen — gelöst mit der Bellman-Gleichung.
@@ -24556,7 +24937,7 @@ Der Ausweg auf `scipy` hat das Warnsystem umgangen, nicht das Problem gelöst. R
**9.1 — Fluch des Durchschnitts.** Weil sich Verzögerungen **fortpflanzen** und die Verteilung rechtsschief ist: Ein Gewerk, das früher fertig wird, beschleunigt selten den Gesamtablauf (der nächste Schritt ist noch nicht bereit), ein verspätetes verzögert alles. Weitere Beispiele: Wartezeiten in Warteschlangen (nichtlinear in der Auslastung), Projektkosten mit Nachträgen.
-**9.2 — Ansatz wählen.** (a) robust (Ausfall ist existenziell, Wahrscheinlichkeiten unzuverlässig). (b) stochastisch (Verteilung bekannt). (c) robust bzw. Extremwertstatistik (seltene Ereignisse, katastrophale Folgen). (d) stochastisch (Szenarien mit Wahrscheinlichkeiten liegen vor).
+**9.2 — Ansatz wählen.** (a) robust (Ausfall ist existenziell, Wahrscheinlichkeiten unzuverlässig). (b) stochastisch (Verteilung bekannt). (c) robust bzw. Extremwertstatistik (seltene Ereignisse, katastrophale Folgen). (d) stochastisch (Szenarien mit Wahrscheinlichkeiten liegen vor). (e) Chance Constraint — die Zusage ist bereits als Quote formuliert („an 99 % aller Wintertage“). Robust wäre hier zu teuer (es gibt immer einen kälteren Tag), der Erwartungswert zu schwach (er sagt nichts über die Zusage).
**9.3 — Zweistufig rechnen.** (a) Mit Spot = 60: Ableitung bei $x \in (100,250)$: $40 - 60\cdot0{,}5 + 5\cdot0{,}5 = 40-30+2{,}5 = +12{,}5 > 0$ → Kapazität **senken**. Bei $x < 100$: $40 - 60 = -20 < 0$ → erhöhen. Optimum daher $x^* = 100$. (b) Billige Nachbesserung macht Vorhalten unattraktiv. (c) Bei $x = 225$ optimal müsste die Ableitung dort das Vorzeichen wechseln — das ist bei diskreten Szenarien nur zufällig der Fall; der Mittelwert ist nur bei symmetrischen Kosten und stetiger Verteilung optimal.
@@ -24564,7 +24945,9 @@ Der Ausweg auf `scipy` hat das Warnsystem umgangen, nicht das Problem gelöst. R
**9.5 — Monte-Carlo erweitern.** (a) Servicelevel 95 %: Kapazität = 95 %-Quantil des Bedarfs (ca. 485). Die Zusatzkosten gegenüber dem Kostenoptimum (180) betragen ein Vielfaches — Servicelevel ist teuer, und genau diese Zahl braucht die Geschäftsleitung für die Entscheidung. (b) `cvar = kosten[kosten >= np.quantile(kosten, 0.95)].mean()`. (c) Erwartetes Bild: Bei kleiner Kapazität ist die Kostenverteilung stark rechtsschief (seltene, teure Notzukäufe); bei großer Kapazität schmal und nach rechts verschoben.
-**9.6 — Budgeted Uncertainty.**
+**9.6 — Den Preis der Zusage selbst bestimmen.** (a) Die Kosten je Prozentpunkt steigen weiter steil an; der Mix verschiebt sich dabei fast nur noch zugunsten des Gaskraftwerks, weil Biomasse längst an ihrer Ausbaugrenze liegt. Wind und Sonne bleiben bei einem kleinen Rest — sie senken über ihre Gegenläufigkeit die Norm, tragen zur gesicherten Leistung aber kaum bei. (b) Der Unterschied ist **nicht** numerisch, sondern inhaltlich. Bei 200 MW Biomasse liefert selbst der **Vollausbau** in der Kältewelle nur rund 440 MW — 7,5 % der Szenarien sind schlicht nicht bedienbar, egal wie viel Geld man ausgibt. Das Szenariomodell sieht diese Fälle in den Daten und meldet korrekt `infeasible`, sobald die Zusage über 92 % steigt. Das analytische Modell kennt sie nicht: Für eine Normalverteilung ist jede Zusage unter 100 % erfüllbar, wenn man nur genug Streuung wegkauft. Es meldet `optimal` — und der Plan hält gemessen 87,4 %. Glauben sollte man dem Szenariomodell: Ein `infeasible` ist hier die **richtige** Antwort. Es sagt, dass die Zusage nicht am Budget scheitert, sondern am Kraftwerkspark, und dass nicht mehr Geld hilft, sondern nur eine andere Anlage oder eine kleinere Zusage. (c) Sinngemäß: „99,99 % kosten nicht 5 % mehr als 99 %, sondern ein Vielfaches — der letzte Prozentpunkt ist bereits 4,5-mal so teuer wie der erste. Sagen Sie mir, was ein Ausfalltag das Unternehmen kostet, dann rechne ich aus, welche Quote sich lohnt. Und die Zusage gilt nur für die Wetterlagen, die wir modelliert haben — die Kältewelle gehört hinein.“
+
+**9.7 — Budgeted Uncertainty.**
```python
abzug = cp.sum_largest(cp.multiply(UNSICHERHEIT, w), Gamma)
ziel = cp.Maximize(MU_SCHAETZUNG @ w - abzug - 0.5*LAMBDA*cp.quad_form(w, SIGMA))
@@ -24628,6 +25011,8 @@ Und die Formulierung gegenüber dem Auftraggeber lautet nicht „der Umbau dauer
3. Die Differenz zwischen Kosten unter Unsicherheit und Kosten bei perfektem Wissen — eine **Obergrenze** für den Wert jeder Prognoseverbesserung.
4. Weil sie nur eine **Menge** möglicher Werte braucht, nicht deren Verteilung — sie optimiert gegen das schlechteste Element dieser Menge.
5. Für einfache Mengen (Box, Ellipsoid) lässt sich das innere Maximum geschlossen ausrechnen und wird zu einem Abzugsterm; das Gesamtproblem bleibt konvex.
+6. Weil die Streuung der Summe $\mathbf{a}^\top\mathbf{x}$ nicht die Summe der Streuungen ist: $\sqrt{\mathbf{x}^\top\boldsymbol{\Sigma}\mathbf{x}}$ enthält die **Kovarianzen**. Ein fester Zuschlag je Variable wäre linear und würde deshalb übersehen, dass sich gegenläufige Größen teilweise aufheben — die Norm belohnt Mischung, ein linearer Aufschlag nicht.
+7. Weil die Zusage nur so gut ist wie die unterstellte Verteilung. Die Normalverteilung kennt keine Ereignisse, in denen alle Quellen **gleichzeitig** ausfallen; ihre schwachen Korrelationen unterschätzen genau den Fall, der die Zusage bricht. Die szenariobasierte Formulierung hat diese Fälle in den Daten und trifft die Quote — erkauft mit Mehrkosten und mit dem Risiko, sich an die verwendete Stichprobe anzupassen.
---
diff --git a/Operations_Research_mit_Python_Version_04.pdf b/Operations_Research_mit_Python_Version_04.pdf
index 3c608b7..d5e8848 100644
Binary files a/Operations_Research_mit_Python_Version_04.pdf and b/Operations_Research_mit_Python_Version_04.pdf differ
diff --git a/Operations_Research_mit_Python_Version_04/00_Vorwort_und_Lesehilfe.md b/Operations_Research_mit_Python_Version_04/00_Vorwort_und_Lesehilfe.md
index 9d6ced9..aabc7f4 100644
--- a/Operations_Research_mit_Python_Version_04/00_Vorwort_und_Lesehilfe.md
+++ b/Operations_Research_mit_Python_Version_04/00_Vorwort_und_Lesehilfe.md
@@ -449,6 +449,7 @@ Pfad E oder um gezielt nach einem Thema zu suchen:
| `Monte_Carlo.py` | Monte-Carlo-Bewertung | {ref:kap:unsicherheit} |
| `Stochastische_Optimierung.py` | Two-Stage mit Recourse | {ref:kap:unsicherheit} |
| `Robuste_Optimierung.py` | Worst-Case-Absicherung | {ref:kap:unsicherheit} |
+| `Chance_Constraints.py` | Zusage „mit 95 % Sicherheit“, SOC und Big-M | {ref:kap:unsicherheit} |
| `Bellman_Minimalbeispiel.py` | Rückwärtsinduktion | {ref:kap:dynamische-programmierung} |
| `Mehrziel_Pareto.py` | Pareto-Front, ε-Constraint, Gewichtslücke | {ref:kap:mehrziel} |
| `Predict_then_Optimize.py` | MSE gegen Entscheidungskosten | {ref:kap:prognose} |
diff --git a/Operations_Research_mit_Python_Version_04/31_Unsicherheit.md b/Operations_Research_mit_Python_Version_04/31_Unsicherheit.md
index 84932ee..8855759 100644
--- a/Operations_Research_mit_Python_Version_04/31_Unsicherheit.md
+++ b/Operations_Research_mit_Python_Version_04/31_Unsicherheit.md
@@ -3,22 +3,25 @@
> **📌 Kapitel auf einen Blick**
>
> **Worum geht es?** Bisher waren alle Parameter bekannt. In der Realität sind Nachfrage,
-> Rendite und Fahrzeit **Zufallsgrößen**. Dieses Kapitel zeigt drei Wege, damit umzugehen —
+> Rendite und Fahrzeit **Zufallsgrößen**. Dieses Kapitel zeigt vier Wege, damit umzugehen —
> und warum „einfach den Mittelwert einsetzen“ der schlechteste davon ist.
>
> **Voraussetzungen:** {ref:kap:lp}, {ref:kap:qp-nlp}. Grundbegriffe der Statistik (Erwartungswert, Quantil).
>
> **Danach können Sie:** Ein zweistufiges stochastisches Modell aufstellen, Monte-Carlo zur
-> Risikomessung einsetzen, ein robustes Modell für den Worst Case formulieren — und
-> erkennen, wann Rechnen mit Mittelwerten zulässig ist und wann es systematisch danebengeht.
+> Risikomessung einsetzen, ein robustes Modell für den Worst Case formulieren, eine
+> Wahrscheinlichkeitszusage („mit 95 % Sicherheit“) als lösbare Nebenbedingung schreiben —
+> und erkennen, wann Rechnen mit Mittelwerten zulässig ist und wann es systematisch
+> danebengeht.
>
-> **Zeitbedarf:** ca. 5,5 Stunden.
+> **Zeitbedarf:** ca. 6,5 Stunden.
>
> **Programme:**\
> `Fluch_des_Durchschnitts.py`\
> `Monte_Carlo.py`\
> `Stochastische_Optimierung.py`\
-> `Robuste_Optimierung.py`
+> `Robuste_Optimierung.py`\
+> `Chance_Constraints.py`
---
@@ -112,10 +115,13 @@ Nach diesem Kapitel können Sie …
2. … ein **zweistufiges stochastisches Programm** mit Recourse formulieren.
3. … Monte-Carlo einsetzen, um Verteilungen statt Punktschätzungen zu bewerten.
4. … ein **robustes** Modell mit Unsicherheitsmenge aufstellen und seinen Preis beziffern.
-5. … begründen, welcher der drei Ansätze für ein gegebenes Problem passt.
-6. … das **kritische Verhältnis** eines Newsvendor-Problems aufstellen und daraus die
+5. … eine **Wahrscheinlichkeitszusage** („mit 95 % Sicherheit“) als Chance Constraint
+ schreiben — analytisch als Kegelbedingung oder szenariobasiert mit Big-M — und den Preis
+ eines Prozentpunkts Sicherheit beziffern.
+6. … begründen, welcher der vier Ansätze für ein gegebenes Problem passt.
+7. … das **kritische Verhältnis** eines Newsvendor-Problems aufstellen und daraus die
optimale Bestellmenge als Quantil ablesen.
-7. … eine Terminzusage als Quantil formulieren statt als Mittelwert.
+8. … eine Terminzusage als Quantil formulieren statt als Mittelwert.
---
@@ -256,18 +262,23 @@ Erwartungswert der Kosten ist größer als die Kosten des Erwartungswerts.
> Der Durchschnittskunde kauft nie. Das Durchschnittsszenario tritt nie ein. Wer mit
> Mittelwerten plant, optimiert eine Welt, die es nicht gibt.
-### Die drei Ansätze im Überblick
+### Die vier Ansätze im Überblick

-| | **Monte-Carlo** | **Stochastische Programmierung** | **Robuste Optimierung** |
-| --- | --- | --- | --- |
-| Braucht | Verteilung | Szenarien **mit Wahrscheinlichkeiten** | nur eine **Menge** möglicher Werte |
-| Optimiert | nichts (bewertet nur) | **Erwartungswert** über Szenarien | **Worst Case** in der Menge |
-| Typische Frage | „Wie riskant ist dieser Plan?“ | „Was ist im Mittel am besten?“ | „Was hält auch im schlimmsten Fall?“ |
-| Stärke | beliebige Kennzahlen, sehr flexibel | nutzt Wahrscheinlichkeiten voll aus | keine Verteilungsannahme nötig |
-| Schwäche | keine Optimierung | Wahrscheinlichkeiten müssen stimmen | oft übervorsichtig, kostet Ertrag |
-| Nachzulesen in | {ref:sec:unsicherheit-monte-carlo-simulation} | {ref:sec:unsicherheit-zweistufige-stochastische-programmierung} | {ref:sec:unsicherheit-robuste-optimierung-gegen-den-worst-case} |
+| | **Monte-Carlo** | **Stochastische Programmierung** | **Robuste Optimierung** | **Chance Constraints** |
+| --- | --- | --- | --- | --- |
+| Braucht | Verteilung | Szenarien **mit Wahrscheinlichkeiten** | nur eine **Menge** möglicher Werte | Verteilung **oder** Szenarien |
+| Optimiert | nichts (bewertet nur) | **Erwartungswert** über Szenarien | **Worst Case** in der Menge | Kosten bei **zugesicherter Quote** |
+| Typische Frage | „Wie riskant ist dieser Plan?“ | „Was ist im Mittel am besten?“ | „Was hält auch im schlimmsten Fall?“ | „Was hält in 95 % der Fälle?“ |
+| Stärke | beliebige Kennzahlen, sehr flexibel | nutzt Wahrscheinlichkeiten voll aus | keine Verteilungsannahme nötig | die Zusage steht **im Modell** |
+| Schwäche | keine Optimierung | Wahrscheinlichkeiten müssen stimmen | oft übervorsichtig, kostet Ertrag | Zusage gilt nur für die unterstellte Verteilung |
+| Nachzulesen in | {ref:sec:unsicherheit-monte-carlo-simulation} | {ref:sec:unsicherheit-zweistufige-stochastische-programmierung} | {ref:sec:unsicherheit-robuste-optimierung-gegen-den-worst-case} | {ref:sec:unsicherheit-chance-constraints} |
+
+**Der Unterschied zwischen den letzten beiden** ist der, an dem die meisten Diskussionen
+hängenbleiben. Die robuste Optimierung sagt: *„Es darf **nie** schiefgehen“* — und zahlt
+dafür. Die Chance Constraint sagt: *„Es darf in höchstens 5 % der Fälle schiefgehen“* — und
+macht damit verhandelbar, was die Sicherheit kosten darf.
---
@@ -791,6 +802,427 @@ if __name__ == "__main__":
---
+## Wahrscheinlichkeitsbeschränkungen: „mit 95 % Sicherheit“ {#sec:unsicherheit-chance-constraints}
+
+Die drei bisherigen Ansätze beantworten Fragen, die im Sitzungssaal selten so gestellt
+werden. „Was ist im Mittel am besten?“ hört ein Vorstand als Ausflucht; „was hält auch im
+schlimmsten Fall?“ als Einladung, zu viel Geld auszugeben. Gefragt wird fast immer nach einer
+**Wahrscheinlichkeit**: *Mit welcher Sicherheit hält der Plan?*
+
+Die **Wahrscheinlichkeitsbeschränkung**{idx:Chance Constraint} schreibt genau das ins Modell:
+
+$$
+\mathbb{P}\!\left(\mathbf{a}^\top\mathbf{x} \ge b\right) \ge 1 - \alpha
+$$
+
+> **📐 Formel-Lesehilfe**
+> * $\mathbf{a}$ — der **unsichere** Vektor: Verfügbarkeiten, Erträge, Fahrzeiten. Er ist
+> keine Zahl, sondern eine Zufallsgröße.
+> * $\mathbf{x}$ — Ihre Entscheidung. Sie fällt, **bevor** $\mathbf{a}$ sich zeigt.
+> * $\alpha$ — die Ausfallwahrscheinlichkeit, die Sie akzeptieren. $\alpha = 0{,}05$ heißt
+> „in höchstens 5 % der Fälle darf es schiefgehen“.
+>
+> **Ohne Formel gesagt:** „Plane so, dass die Zusage in 95 von 100 Fällen hält — und sag mir,
+> was das kostet.“
+
+**Das Problem daran.** So aufgeschrieben ist die Bedingung nicht lösbar: Ein Solver kann
+nichts mit einem Wahrscheinlichkeitsoperator anfangen. Es braucht einen Weg, sie in etwas zu
+übersetzen, das ein Optimierer versteht. Es gibt zwei, und sie unterscheiden sich in dem,
+was sie über die Verteilung voraussetzen.
+
+### Weg 1: Normalverteilung — die Bedingung wird ein Kegel
+
+Ist $\mathbf{a} \sim \mathcal{N}(\hat{\mathbf{a}}, \boldsymbol{\Sigma})$, dann ist
+$\mathbf{a}^\top\mathbf{x}$ für festes $\mathbf{x}$ selbst normalverteilt, mit Erwartungswert
+$\hat{\mathbf{a}}^\top\mathbf{x}$ und Standardabweichung
+$\sqrt{\mathbf{x}^\top\boldsymbol{\Sigma}\mathbf{x}}$. Damit lässt sich die Wahrscheinlichkeit
+durch ein Quantil ersetzen:
+
+$$
+\hat{\mathbf{a}}^\top\mathbf{x} \;-\; z_{1-\alpha}\,\lVert \mathbf{L}^\top\mathbf{x}\rVert_2
+\;\ge\; b
+\qquad\text{mit}\qquad
+\boldsymbol{\Sigma} = \mathbf{L}\mathbf{L}^\top,\quad
+z_{1-\alpha} = \Phi^{-1}(1-\alpha)
+$$
+
+Das ist eine **Second-Order-Cone-Bedingung**{idx:Konische Optimierung (SOCP, SDP)} — dieselbe
+Bauform, die {ref:kap:qp-nlp} eingeführt hat, und CVXPY löst sie ohne Umstände.
+
+> **🎯 Warum eine Norm und keine Summe**
+> Der Sicherheitszuschlag $\lVert\mathbf{L}^\top\mathbf{x}\rVert_2$ ist **keine** Summe von
+> Einzelzuschlägen je Anlage. Genau darin steckt der Nutzen der Mischung: Zwei gegenläufige
+> Quellen schwanken **gemeinsam** weniger als jede für sich, und die Norm rechnet das
+> automatisch mit. Ein fester Aufschlag je Anlage könnte das nicht — er wäre linear und
+> würde Diversifikation nicht belohnen.
+
+### Weg 2: Szenarien — Big-M ohne Verteilungsannahme
+
+Liegen statt einer Verteilung nur $S$ beobachtete Szenarien vor, bekommt jedes eine
+Binärvariable $z_s$, die sagt, ob es verletzt werden **darf**:
+
+$$
+\begin{aligned}
+\mathbf{a}_s^\top\mathbf{x} &\ge b - M z_s && \forall s\\
+\sum_{s} z_s &\le \alpha S\\
+z_s &\in \{0,1\}
+\end{aligned}
+$$
+
+Aus dem Kegelproblem wird ein MILP mit $S$ Binärvariablen. Für $M$ gilt, was
+{ref:kap:milp} über die Big-M-Falle sagt: **so klein wie möglich**. Hier ist $M = b$ die
+kleinste gültige Schranke, denn $\mathbf{a}_s^\top\mathbf{x} \ge 0$ — größer kann eine
+Verletzung gar nicht ausfallen.
+
+### Der Fall: ein Kraftwerkspark mit 500 MW Zusage
+
+```python
+#!/usr/bin/env python3
+
+# Chance_Constraints.py
+"""
+Kapitel Unsicherheit: Wahrscheinlichkeitsbeschraenkungen.
+
+Das Management fragt selten "was ist im Mittel am besten?", sondern "mit
+welcher Sicherheit haelt der Plan?". Genau das formuliert eine Chance
+Constraint: P(Versorgung >= Bedarf) >= 1 - alpha.
+
+Gezeigt werden beide Wege dorthin, an derselben Instanz:
+ (a) analytisch - unter Normalverteilungsannahme wird daraus eine
+ Second-Order-Cone-Bedingung, loesbar mit CVXPY
+ (b) szenariobasiert - Big-M mit Binaervariablen, fuer beliebige
+ empirische Verteilungen
+
+Und die beiden Messungen, auf die es ankommt: Was kostet ein Prozentpunkt
+Versorgungssicherheit - und haelt die Zusage auch dann, wenn die Verteilung
+nicht normal ist?
+
+Solver: CLARABEL (Kegel) und SciPy/HiGHS (gemischt-ganzzahlig). Weder ortools
+noch ein direkter highspy-Import, damit alles in einem Prozess laeuft.
+"""
+
+import cvxpy as cp
+import numpy as np
+from scipy.stats import norm
+
+# --- Der Kraftwerkspark ----------------------------------------------------
+# Die Verfuegbarkeit ist der Anteil, den eine installierte MW im Mittel
+# wirklich liefert: bei Wind und Sonne klein und stark schwankend, bei Gas und
+# Biomasse gross und stabil. Die Ausbaugrenze ist der Standort - Flaeche,
+# Genehmigung, Brennstoffversorgung.
+TECHNIK = ["Gaskraftwerk", "Windpark", "Solarpark", "Biomasse"]
+KOSTEN = np.array([65_000.0, 24_000.0, 12_000.0, 60_000.0]) # EUR je MW und Jahr
+VERFUEGBAR = np.array([0.92, 0.35, 0.18, 0.85])
+STREUUNG = np.array([0.05, 0.16, 0.10, 0.04])
+GRENZE = np.array([400.0, 250.0, 600.0, 350.0]) # MW
+
+# Wind und Sonne sind leicht gegenlaeufig: Ein Tiefdruckgebiet bringt Wind und
+# Wolken zugleich. Genau diese Korrelation macht die Bedingung zu einem Kegel
+# und nicht zu einer Summe unabhaengiger Einzelzuschlaege.
+KORRELATION = np.array([
+ [1.00, 0.00, 0.00, 0.05],
+ [0.00, 1.00, -0.25, 0.00],
+ [0.00, -0.25, 1.00, 0.00],
+ [0.05, 0.00, 0.00, 1.00],
+])
+SIGMA = np.diag(STREUUNG) @ KORRELATION @ np.diag(STREUUNG)
+WURZEL = np.linalg.cholesky(SIGMA) # L mit L @ L.T == SIGMA
+
+BEDARF = 500.0 # MW, die gesichert bereitstehen muessen
+N = len(TECHNIK)
+
+# Die Kaeltewelle mit Dunkelflaute: selten, aber sie trifft alles zugleich.
+# Wind und Sonne brechen fast vollstaendig weg - und, das ist der Punkt, das
+# Gaskraftwerk liefert ebenfalls weniger, weil bei Frost der Netzdruck faellt.
+# Eine Kovarianzmatrix mit Korrelationen um null kann das nicht ausdruecken.
+P_KAELTEWELLE = 0.08
+EINBRUCH = np.array([0.72, 0.10, 0.15, 0.88])
+
+
+def mittelwertplan():
+ """Plant mit den Erwartungswerten - ignoriert die Streuung vollstaendig."""
+ x = cp.Variable(N, nonneg=True)
+ problem = cp.Problem(cp.Minimize(KOSTEN @ x),
+ [VERFUEGBAR @ x >= BEDARF, x <= GRENZE])
+ problem.solve(solver=cp.CLARABEL)
+ return problem.value, x.value
+
+
+def chance_constraint_analytisch(alpha):
+ """
+ P(a^T x >= BEDARF) >= 1 - alpha unter a ~ N(VERFUEGBAR, SIGMA).
+
+ Aequivalent zu: VERFUEGBAR^T x - z * ||L^T x||_2 >= BEDARF
+ mit z = Phi^-1(1 - alpha). Das ist eine Second-Order-Cone-Bedingung: Der
+ Sicherheitszuschlag ist eine Norm ueber x, kein fester Aufschlag je Anlage.
+ Deshalb belohnt sie Mischung - zwei gegenlaeufige Quellen schwanken
+ gemeinsam weniger als jede fuer sich.
+ """
+ x = cp.Variable(N, nonneg=True)
+ z = norm.ppf(1 - alpha)
+ bedingung = VERFUEGBAR @ x - z * cp.norm(WURZEL.T @ x, 2) >= BEDARF
+ problem = cp.Problem(cp.Minimize(KOSTEN @ x), [bedingung, x <= GRENZE])
+ problem.solve(solver=cp.CLARABEL)
+ return problem.value, x.value
+
+
+def chance_constraint_szenarien(a, alpha):
+ """
+ Dieselbe Zusage ohne Verteilungsannahme: Fuer jedes Szenario s sagt eine
+ Binaervariable z_s, ob es verletzt werden darf.
+
+ a_s^T x >= BEDARF - M * z_s fuer alle s
+ sum_s z_s <= alpha * S
+
+ M = BEDARF ist die kleinstmoegliche gueltige Schranke, denn a_s^T x >= 0:
+ Groesser kann eine Verletzung gar nicht ausfallen. Ein unnoetig grosses M
+ wuerde die LP-Relaxierung aufweichen und die Suche verlangsamen - die
+ Big-M-Falle aus dem Kapitel Gemischt-ganzzahlige Optimierung.
+ """
+ S = len(a)
+ x = cp.Variable(N, nonneg=True)
+ z = cp.Variable(S, boolean=True)
+ problem = cp.Problem(
+ cp.Minimize(KOSTEN @ x),
+ [a @ x >= BEDARF - BEDARF * z, cp.sum(z) <= alpha * S, x <= GRENZE])
+ problem.solve(solver=cp.SCIPY)
+ return problem.value, x.value
+
+
+def ziehe_wetter(anzahl, seed):
+ """Mischverteilung: normales Wetter, mit P_KAELTEWELLE ein Einbruch."""
+ rng = np.random.default_rng(seed)
+ a = rng.multivariate_normal(VERFUEGBAR, SIGMA, size=anzahl)
+ getroffen = rng.random(anzahl) < P_KAELTEWELLE
+ a[getroffen] *= EINBRUCH
+ return np.clip(a, 0.0, 1.0)
+
+
+def sicherheit(x, a):
+ """Anteil der Szenarien, in denen der Plan den Bedarf deckt."""
+ return float(np.mean(a @ x >= BEDARF))
+
+
+KOPF = (f"{'Plan':<20} {'Kosten (EUR)':>13} {'Gas':>4} {'Wind':>4} "
+ f"{'Sol':>4} {'Bio':>4} {'Normalwelt':>11} {'echte Welt':>11}")
+
+
+def zeile(name, kosten, x, in_normal, in_echt):
+ mix = " ".join(f"{w:4.0f}" for w in x)
+ return (f"{name:<20} {kosten:>13,.0f} {mix} "
+ f"{in_normal * 100:>9.2f} % {in_echt * 100:>9.2f} %")
+
+
+if __name__ == "__main__":
+ print("=" * 78)
+ print(" WAHRSCHEINLICHKEITSBESCHRAENKUNGEN IM KRAFTWERKSPARK")
+ print("=" * 78)
+ print(f"Gesichert bereitzustellen: {BEDARF:.0f} MW\n")
+ print(f"{'Technologie':<14} {'EUR/MW':>8} {'verfuegbar':>11} {'Streuung':>9} "
+ f"{'Grenze':>8} {'EUR je erw. MW':>15}")
+ print("-" * 78)
+ for i, name in enumerate(TECHNIK):
+ print(f"{name:<14} {KOSTEN[i]:>8,.0f} {VERFUEGBAR[i]:>10.2f} "
+ f"{STREUUNG[i]:>8.2f} {GRENZE[i]:>7.0f} "
+ f"{KOSTEN[i] / VERFUEGBAR[i]:>15,.0f}")
+ print(f"\nVollausbau liefert im Mittel {VERFUEGBAR @ GRENZE:.0f} MW, "
+ f"in der Kaeltewelle {(VERFUEGBAR * EINBRUCH) @ GRENZE:.0f} MW.")
+
+ # Zwei grosse, unabhaengige Testmengen. An ihnen wird JEDER Plan gemessen -
+ # einmal in der unterstellten Normalwelt, einmal in der echten Verteilung.
+ echt = ziehe_wetter(200_000, seed=771)
+ normalwelt = np.random.default_rng(4711).multivariate_normal(
+ VERFUEGBAR, SIGMA, size=200_000)
+
+ print("\n" + "=" * 78)
+ print(" (1) Mittelwertplan und Chance Constraints im Vergleich")
+ print("=" * 78)
+ print(KOPF)
+ print("-" * 78)
+
+ k_mittel, x_mittel = mittelwertplan()
+ stufen = [("Mittelwert", k_mittel, sicherheit(x_mittel, normalwelt))]
+ print(zeile("Mittelwert", k_mittel, x_mittel,
+ sicherheit(x_mittel, normalwelt), sicherheit(x_mittel, echt)))
+
+ plaene = {}
+ for alpha in (0.20, 0.10, 0.05, 0.01):
+ kosten, x = chance_constraint_analytisch(alpha)
+ plaene[alpha] = (kosten, x)
+ quote_normal = sicherheit(x, normalwelt)
+ stufen.append((f"Zusage {(1 - alpha) * 100:.0f} %", kosten, quote_normal))
+ print(zeile(f"Zusage {(1 - alpha) * 100:.0f} %", kosten, x,
+ quote_normal, sicherheit(x, echt)))
+
+ print("\n In der Normalwelt trifft jede Zusage ihren Wert - das Verfahren")
+ print(" rechnet richtig. Die Spalte 'echte Welt' kommt in Teil (3).")
+
+ print("\n" + "=" * 78)
+ print(" (2) Was kostet ein Prozentpunkt Versorgungssicherheit?")
+ print("=" * 78)
+ print(f"{'von -> nach':<26} {'Prozentpunkte':>13} {'Mehrkosten':>13} "
+ f"{'EUR je Punkt':>13}")
+ print("-" * 78)
+ for (n1, k1, q1), (n2, k2, q2) in zip(stufen, stufen[1:]):
+ d_punkte = (q2 - q1) * 100
+ d_kosten = k2 - k1
+ print(f"{n1 + ' -> ' + n2:<26} {d_punkte:>13.2f} {d_kosten:>13,.0f} "
+ f"{d_kosten / d_punkte:>13,.0f}")
+ erst = (stufen[1][1] - stufen[0][1]) / ((stufen[1][2] - stufen[0][2]) * 100)
+ letzt = (stufen[-1][1] - stufen[-2][1]) / ((stufen[-1][2] - stufen[-2][2]) * 100)
+ print(f"\n Der letzte Prozentpunkt kostet das {letzt / erst:.1f}-fache des ersten.")
+ print(" Sicherheit ist konvex bepreist - genau deshalb muss jemand")
+ print(" entscheiden, wie viel davon das Unternehmen kaufen will.")
+
+ print("\n" + "=" * 78)
+ print(" (3) Und wenn die Verteilung nicht normal ist?")
+ print("=" * 78)
+ k_soc, x_soc = plaene[0.05]
+ print(f"Die echte Welt kennt die Kaeltewelle mit Dunkelflaute: in "
+ f"{P_KAELTEWELLE * 100:.0f} % der Faelle liefern")
+ print(f"Wind {EINBRUCH[1] * 100:.0f} %, Sonne {EINBRUCH[2] * 100:.0f} % "
+ f"und - entscheidend - auch das Gaskraftwerk nur "
+ f"{EINBRUCH[0] * 100:.0f} %")
+ print("des Ueblichen. Alles bricht gleichzeitig ein.\n")
+
+ a_bau = ziehe_wetter(400, seed=20260908)
+ k_sz, x_sz = chance_constraint_szenarien(a_bau, 0.05)
+
+ print(KOPF)
+ print("-" * 78)
+ print(zeile("SOC, Zusage 95 %", k_soc, x_soc,
+ sicherheit(x_soc, normalwelt), sicherheit(x_soc, echt)))
+ print(zeile("Szenarien, 95 %", k_sz, x_sz,
+ sicherheit(x_sz, normalwelt), sicherheit(x_sz, echt)))
+
+ print(f"\n Der SOC-Plan verspricht 95 % und haelt "
+ f"{sicherheit(x_soc, echt) * 100:.1f} %. Nicht das Verfahren ist")
+ print(f" falsch, sondern die Annahme: In der Normalwelt liefert derselbe")
+ print(f" Plan {sicherheit(x_soc, normalwelt) * 100:.1f} %.")
+ print(f"\n Der Szenarioplan ({len(a_bau)} Szenarien, {len(a_bau)} "
+ f"Binaervariablen) kommt auf")
+ print(f" {sicherheit(x_sz, echt) * 100:.1f} % - und kostet dafuer "
+ f"{(k_sz / k_soc - 1) * 100:+.1f} %. Er kauft keine Windkraft mehr:")
+ print(" Was im Ernstfall ausfaellt, hilft der Zusage nicht.")
+ print("=" * 78)
+```
+
+**Ausgabe:**
+
+```
+==============================================================================
+ WAHRSCHEINLICHKEITSBESCHRAENKUNGEN IM KRAFTWERKSPARK
+==============================================================================
+Gesichert bereitzustellen: 500 MW
+
+Technologie EUR/MW verfuegbar Streuung Grenze EUR je erw. MW
+------------------------------------------------------------------------------
+Gaskraftwerk 65,000 0.92 0.05 400 70,652
+Windpark 24,000 0.35 0.16 250 68,571
+Solarpark 12,000 0.18 0.10 600 66,667
+Biomasse 60,000 0.85 0.04 350 70,588
+
+Vollausbau liefert im Mittel 861 MW, in der Kaeltewelle 552 MW.
+
+==============================================================================
+ (1) Mittelwertplan und Chance Constraints im Vergleich
+==============================================================================
+Plan Kosten (EUR) Gas Wind Sol Bio Normalwelt echte Welt
+------------------------------------------------------------------------------
+Mittelwert 34,694,565 8 250 600 350 50.08 % 46.18 %
+Zusage 80 % 36,382,277 226 25 44 343 80.06 % 73.61 %
+Zusage 90 % 36,994,777 233 20 35 349 89.99 % 82.82 %
+Zusage 95 % 37,514,307 241 19 31 350 94.97 % 87.44 %
+Zusage 99 % 38,529,698 258 18 28 350 98.97 % 91.05 %
+
+ In der Normalwelt trifft jede Zusage ihren Wert - das Verfahren
+ rechnet richtig. Die Spalte 'echte Welt' kommt in Teil (3).
+
+==============================================================================
+ (2) Was kostet ein Prozentpunkt Versorgungssicherheit?
+==============================================================================
+von -> nach Prozentpunkte Mehrkosten EUR je Punkt
+------------------------------------------------------------------------------
+Mittelwert -> Zusage 80 % 29.98 1,687,712 56,289
+Zusage 80 % -> Zusage 90 % 9.93 612,500 61,685
+Zusage 90 % -> Zusage 95 % 4.98 519,530 104,365
+Zusage 95 % -> Zusage 99 % 4.00 1,015,390 253,848
+
+ Der letzte Prozentpunkt kostet das 4.5-fache des ersten.
+ Sicherheit ist konvex bepreist - genau deshalb muss jemand
+ entscheiden, wie viel davon das Unternehmen kaufen will.
+
+==============================================================================
+ (3) Und wenn die Verteilung nicht normal ist?
+==============================================================================
+Die echte Welt kennt die Kaeltewelle mit Dunkelflaute: in 8 % der Faelle liefern
+Wind 10 %, Sonne 15 % und - entscheidend - auch das Gaskraftwerk nur 72 %
+des Ueblichen. Alles bricht gleichzeitig ein.
+
+Plan Kosten (EUR) Gas Wind Sol Bio Normalwelt echte Welt
+------------------------------------------------------------------------------
+SOC, Zusage 95 % 37,514,307 241 19 31 350 94.97 % 87.44 %
+Szenarien, 95 % 43,760,709 350 0 0 350 100.00 % 94.84 %
+
+ Der SOC-Plan verspricht 95 % und haelt 87.4 %. Nicht das Verfahren ist
+ falsch, sondern die Annahme: In der Normalwelt liefert derselbe
+ Plan 95.0 %.
+
+ Der Szenarioplan (400 Szenarien, 400 Binaervariablen) kommt auf
+ 94.8 % - und kostet dafuer +16.7 %. Er kauft keine Windkraft mehr:
+ Was im Ernstfall ausfaellt, hilft der Zusage nicht.
+==============================================================================
+```
+
+**Drei Befunde, die man dem Plan nicht ansieht.**
+
+**Erstens: Der Mittelwertplan fällt in der Hälfte aller Fälle aus.** Das ist kein Fehler,
+sondern die Definition des Erwartungswerts — und dieselbe Falle wie in
+{ref:sec:unsicherheit-der-fluch-des-durchschnitts}, nur teurer. Er kauft die billigsten
+Quellen je *erwarteter* Megawattstunde und maximiert Wind und Sonne bis an die Ausbaugrenze.
+Sobald die Zusage im Modell steht, dreht sich das Bild: Gas und Biomasse tragen den Plan,
+Wind und Sonne schrumpfen auf einen Rest.
+
+**Zweitens: Sicherheit ist konvex bepreist.** Die ersten dreißig Prozentpunkte kosten rund
+56 000 € pro Punkt, die letzten vier rund 254 000 € — das **4,5-fache**. Das ist die Zahl,
+die in die Vorlage gehört: Nicht „was kostet Versorgungssicherheit“, sondern „was kostet der
+*nächste* Prozentpunkt“. Wer 99,9 % fordert, ohne diese Kurve gesehen zu haben, fordert ins
+Blaue.
+
+**Drittens — und das ist der unangenehme Befund: Die Zusage gilt nur für die unterstellte
+Verteilung.** Der Plan verspricht 95 % und hält **87,4 %**. Nicht weil das Verfahren falsch
+rechnet: In einer reinen Normalwelt liefert derselbe Plan exakt seine 95,0 %. Sondern weil
+die Normalverteilung eine Kältewelle mit Dunkelflaute nicht kennt. In der bricht **alles
+zugleich** ein — auch das Gaskraftwerk, weil bei Frost der Netzdruck fällt. Eine
+Kovarianzmatrix mit Korrelationen um null kann so etwas nicht ausdrücken.
+
+Der szenariobasierte Weg kennt diese Fälle, weil sie in den Daten stehen. Er kommt auf
+94,8 %, kostet dafür 16,7 % mehr — und kauft **keine Windkraft mehr**. Die Logik dahinter ist
+hart und richtig: Was im Ernstfall ausfällt, hilft der Zusage nicht.
+
+> **⚠️ Die Szenariomethode überanpasst — und mehr Szenarien helfen nicht verlässlich**
+> Bei $S$ Szenarien und $\alpha = 5\,\%$ darf das Modell genau $0{,}05 \cdot S$ davon
+> ignorieren — und es sucht sich die **teuersten** aus. In der Stichprobe trifft es seine
+> Quote dadurch immer; außerhalb streut sie.
+>
+> Gemessen an zwölf Läufen dieses Modells (je vier Ziehungen bei $S = 200$, $400$, $800$,
+> geprüft an 200 000 unabhängigen Szenarien): Die tatsächliche Quote lag zwischen
+> **93,1 % und 96,7 %**. Die Spanne wurde von $S = 200$ auf $400$ enger (3,5 → 1,2
+> Prozentpunkte), bei $S = 800$ aber wieder breiter (3,1) — die Zahl der Szenarien allein
+> schließt die Lücke also nicht.
+>
+> **Konsequenz für die Praxis:** Eine mit Szenarien erkaufte Zusage immer an einer
+> **zurückgehaltenen** Stichprobe nachmessen, so wie es das Programm oben tut. Wer nur die
+> Trefferquote in den Baudaten berichtet, berichtet eine Tautologie — dieselbe Falle wie beim
+> Backtest in {ref:kap:handelsmaschine}.
+
+> **🎯 Merksatz**
+> Die robuste Optimierung fragt „was, wenn alles schiefgeht?“, die Chance Constraint „wie oft
+> darf es schiefgehen?“. Nur die zweite Frage hat eine Antwort, über die man verhandeln
+> kann — und nur sie zwingt dazu, den Preis eines Prozentpunkts Sicherheit zu beziffern.
+
+---
+
## Übungsaufgaben {#sec:unsicherheit-uebungsaufgaben}
> Lösungen: {ref:sec:loesungen-unsicherheit}.
@@ -801,11 +1233,12 @@ Ein Bauunternehmer plant mit der *durchschnittlichen* Bauzeit von 8 Monaten. War
sind als Beschleunigungen? Nennen Sie zwei weitere Alltagsbeispiele.
**Aufgabe ⭐ — Ansatz wählen.**
-Welcher der drei Ansätze passt? Begründen Sie:
+Welcher der vier Ansätze passt? Begründen Sie:
(a) Wie viele Notstromaggregate für ein Krankenhaus?
(b) Wie viel Weizen einkaufen bei bekannter Preisverteilung?
(c) Wie hoch ein Deich gebaut werden muss?
(d) Wie viele Saisonkräfte einstellen bei bekannten Nachfrageszenarien?
+(e) Wie viel Speicherkapazität, damit ein Gasnetz an 99 % aller Wintertage hält?
**Aufgabe ⭐⭐ — Zweistufiges Modell rechnen.**
Wiederholen Sie die Handrechnung *Warum der Mittelwert in die Irre führt* mit geänderten Kosten: Spot 60 € statt 120 €.
@@ -824,6 +1257,16 @@ Erweitern Sie `Monte_Carlo.py`:
(b) Berechnen Sie den CVaR der Kosten (Mittelwert der schlechtesten 5 %).
(c) Zeichnen Sie ein Histogramm der Kosten für drei Kapazitäten.
+**Aufgabe ⭐⭐ — Den Preis der Zusage selbst bestimmen.**
+`Chance_Constraints.py` beziffert die Kosten für 80, 90, 95 und 99 %.
+(a) Ergänzen Sie die Stufen 99,5 % und 99,9 % und setzen Sie die Kosten je Prozentpunkt fort.
+ Was passiert mit dem Anlagenmix?
+(b) Drosseln Sie die Ausbaugrenze der Biomasse von 350 auf 200 MW. Das **szenariobasierte**
+ Modell meldet dann bei 95 % `infeasible`, das **analytische** dagegen `optimal`. Erklären
+ Sie den Unterschied. Welchem der beiden würden Sie glauben?
+(c) Ein Vorstand fordert „99,99 % Versorgungssicherheit“. Formulieren Sie in drei Sätzen,
+ was Sie ihm mit dieser Kurve antworten.
+
**Aufgabe ⭐⭐⭐ — Budgeted Uncertainty.**
Erweitern Sie `Robuste_Optimierung.py` um den Ansatz von Bertsimas/Sim: Höchstens
$\Gamma$ Titel nehmen gleichzeitig ihren Worst Case an. Variieren Sie
@@ -931,6 +1374,10 @@ das lässt sich in CVXPY mit `cp.sum_largest` formulieren.)
3. Was misst der EVPI, und wofür ist er praktisch nützlich?
4. Warum braucht robuste Optimierung keine Wahrscheinlichkeiten?
5. Wie macht man ein Min-Max-Problem mit Box-Unsicherheit lösbar?
+6. Warum wird aus $\mathbb{P}(\mathbf{a}^\top\mathbf{x} \ge b) \ge 1-\alpha$ unter
+ Normalverteilung eine **Norm** und nicht ein fester Zuschlag je Variable?
+7. Ein Plan mit Chance Constraint verspricht 95 % und hält gemessen 87 %. Woran liegt das —
+ und was ändert die szenariobasierte Formulierung daran?
---
@@ -951,6 +1398,16 @@ das lässt sich in CVXPY mit `cp.sum_largest` formulieren.)
* **Der EVPI** begrenzt, was eine perfekte Prognose wert sein darf.
* **Robuste Optimierung** braucht keine Wahrscheinlichkeiten, nur eine Unsicherheitsmenge —
und wird für einfache Mengen zu einem gewöhnlichen Problem mit Abzugsterm.
+* **Chance Constraints** schreiben die Zusage selbst ins Modell. Unter Normalverteilung wird
+ daraus eine **Kegelbedingung** (der Sicherheitszuschlag ist eine Norm, deshalb belohnt sie
+ Mischung), szenariobasiert ein **MILP mit Big-M** ohne jede Verteilungsannahme.
+* **Sicherheit ist konvex bepreist.** Im Beispiel kostet der letzte Prozentpunkt das
+ 4,5-fache des ersten. Die richtige Managementfrage lautet nicht „was kostet Sicherheit“,
+ sondern „was kostet der *nächste* Prozentpunkt“.
+* **Eine Zusage gilt nur für die unterstellte Verteilung.** Ein Plan, der unter
+ Normalverteilung 95 % verspricht, hielt gemessen 87 %, weil das Modell die Kältewelle
+ nicht kannte, in der *alles* zugleich ausfällt. Szenariobasierte Zusagen wiederum
+ überanpassen — beide müssen an zurückgehaltenen Daten nachgemessen werden.
* **Beziffern Sie immer Preis und Nutzen der Absicherung.** Ohne beide Zahlen ist die
Entscheidung nicht begründbar.
diff --git a/Operations_Research_mit_Python_Version_04/90_Anhang_Loesungen.md b/Operations_Research_mit_Python_Version_04/90_Anhang_Loesungen.md
index e5e8b9b..bdcd9c0 100644
--- a/Operations_Research_mit_Python_Version_04/90_Anhang_Loesungen.md
+++ b/Operations_Research_mit_Python_Version_04/90_Anhang_Loesungen.md
@@ -1492,6 +1492,9 @@ Projektkosten mit Nachträgen.
(b) stochastisch (Verteilung bekannt).
(c) robust bzw. Extremwertstatistik (seltene Ereignisse, katastrophale Folgen).
(d) stochastisch (Szenarien mit Wahrscheinlichkeiten liegen vor).
+(e) Chance Constraint — die Zusage ist bereits als Quote formuliert („an 99 % aller
+Wintertage“). Robust wäre hier zu teuer (es gibt immer einen kälteren Tag), der
+Erwartungswert zu schwach (er sagt nichts über die Zusage).
**9.3 — Zweistufig rechnen.**
(a) Mit Spot = 60: Ableitung bei $x \in (100,250)$: $40 - 60\cdot0{,}5 + 5\cdot0{,}5 =
@@ -1514,7 +1517,27 @@ genau diese Zahl braucht die Geschäftsleitung für die Entscheidung.
(c) Erwartetes Bild: Bei kleiner Kapazität ist die Kostenverteilung stark rechtsschief
(seltene, teure Notzukäufe); bei großer Kapazität schmal und nach rechts verschoben.
-**9.6 — Budgeted Uncertainty.**
+**9.6 — Den Preis der Zusage selbst bestimmen.**
+(a) Die Kosten je Prozentpunkt steigen weiter steil an; der Mix verschiebt sich dabei fast
+nur noch zugunsten des Gaskraftwerks, weil Biomasse längst an ihrer Ausbaugrenze liegt.
+Wind und Sonne bleiben bei einem kleinen Rest — sie senken über ihre Gegenläufigkeit die
+Norm, tragen zur gesicherten Leistung aber kaum bei.
+(b) Der Unterschied ist **nicht** numerisch, sondern inhaltlich. Bei 200 MW Biomasse liefert
+selbst der **Vollausbau** in der Kältewelle nur rund 440 MW — 7,5 % der Szenarien sind
+schlicht nicht bedienbar, egal wie viel Geld man ausgibt. Das Szenariomodell sieht diese
+Fälle in den Daten und meldet korrekt `infeasible`, sobald die Zusage über 92 % steigt. Das
+analytische Modell kennt sie nicht: Für eine Normalverteilung ist jede Zusage unter 100 %
+erfüllbar, wenn man nur genug Streuung wegkauft. Es meldet `optimal` — und der Plan hält
+gemessen 87,4 %.
+Glauben sollte man dem Szenariomodell: Ein `infeasible` ist hier die **richtige** Antwort.
+Es sagt, dass die Zusage nicht am Budget scheitert, sondern am Kraftwerkspark, und dass
+nicht mehr Geld hilft, sondern nur eine andere Anlage oder eine kleinere Zusage.
+(c) Sinngemäß: „99,99 % kosten nicht 5 % mehr als 99 %, sondern ein Vielfaches — der letzte
+Prozentpunkt ist bereits 4,5-mal so teuer wie der erste. Sagen Sie mir, was ein Ausfalltag
+das Unternehmen kostet, dann rechne ich aus, welche Quote sich lohnt. Und die Zusage gilt
+nur für die Wetterlagen, die wir modelliert haben — die Kältewelle gehört hinein.“
+
+**9.7 — Budgeted Uncertainty.**
```python
abzug = cp.sum_largest(cp.multiply(UNSICHERHEIT, w), Gamma)
ziel = cp.Maximize(MU_SCHAETZUNG @ w - abzug - 0.5*LAMBDA*cp.quad_form(w, SIGMA))
@@ -1625,6 +1648,16 @@ Fall, in dem es tatsächlich zulässig ist.
optimiert gegen das schlechteste Element dieser Menge.
5. Für einfache Mengen (Box, Ellipsoid) lässt sich das innere Maximum geschlossen ausrechnen
und wird zu einem Abzugsterm; das Gesamtproblem bleibt konvex.
+6. Weil die Streuung der Summe $\mathbf{a}^\top\mathbf{x}$ nicht die Summe der Streuungen
+ ist: $\sqrt{\mathbf{x}^\top\boldsymbol{\Sigma}\mathbf{x}}$ enthält die **Kovarianzen**.
+ Ein fester Zuschlag je Variable wäre linear und würde deshalb übersehen, dass sich
+ gegenläufige Größen teilweise aufheben — die Norm belohnt Mischung, ein linearer
+ Aufschlag nicht.
+7. Weil die Zusage nur so gut ist wie die unterstellte Verteilung. Die Normalverteilung
+ kennt keine Ereignisse, in denen alle Quellen **gleichzeitig** ausfallen; ihre schwachen
+ Korrelationen unterschätzen genau den Fall, der die Zusage bricht. Die szenariobasierte
+ Formulierung hat diese Fälle in den Daten und trifft die Quote — erkauft mit Mehrkosten
+ und mit dem Risiko, sich an die verwendete Stichprobe anzupassen.
---
diff --git a/Operations_Research_mit_Python_Version_04_Programme/Chance_Constraints.py b/Operations_Research_mit_Python_Version_04_Programme/Chance_Constraints.py
new file mode 100644
index 0000000..6cd90c1
--- /dev/null
+++ b/Operations_Research_mit_Python_Version_04_Programme/Chance_Constraints.py
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+
+# Chance_Constraints.py
+"""
+Kapitel Unsicherheit: Wahrscheinlichkeitsbeschraenkungen.
+
+Das Management fragt selten "was ist im Mittel am besten?", sondern "mit
+welcher Sicherheit haelt der Plan?". Genau das formuliert eine Chance
+Constraint: P(Versorgung >= Bedarf) >= 1 - alpha.
+
+Gezeigt werden beide Wege dorthin, an derselben Instanz:
+ (a) analytisch - unter Normalverteilungsannahme wird daraus eine
+ Second-Order-Cone-Bedingung, loesbar mit CVXPY
+ (b) szenariobasiert - Big-M mit Binaervariablen, fuer beliebige
+ empirische Verteilungen
+
+Und die beiden Messungen, auf die es ankommt: Was kostet ein Prozentpunkt
+Versorgungssicherheit - und haelt die Zusage auch dann, wenn die Verteilung
+nicht normal ist?
+
+Solver: CLARABEL (Kegel) und SciPy/HiGHS (gemischt-ganzzahlig). Weder ortools
+noch ein direkter highspy-Import, damit alles in einem Prozess laeuft.
+"""
+
+import cvxpy as cp
+import numpy as np
+from scipy.stats import norm
+
+# --- Der Kraftwerkspark ----------------------------------------------------
+# Die Verfuegbarkeit ist der Anteil, den eine installierte MW im Mittel
+# wirklich liefert: bei Wind und Sonne klein und stark schwankend, bei Gas und
+# Biomasse gross und stabil. Die Ausbaugrenze ist der Standort - Flaeche,
+# Genehmigung, Brennstoffversorgung.
+TECHNIK = ["Gaskraftwerk", "Windpark", "Solarpark", "Biomasse"]
+KOSTEN = np.array([65_000.0, 24_000.0, 12_000.0, 60_000.0]) # EUR je MW und Jahr
+VERFUEGBAR = np.array([0.92, 0.35, 0.18, 0.85])
+STREUUNG = np.array([0.05, 0.16, 0.10, 0.04])
+GRENZE = np.array([400.0, 250.0, 600.0, 350.0]) # MW
+
+# Wind und Sonne sind leicht gegenlaeufig: Ein Tiefdruckgebiet bringt Wind und
+# Wolken zugleich. Genau diese Korrelation macht die Bedingung zu einem Kegel
+# und nicht zu einer Summe unabhaengiger Einzelzuschlaege.
+KORRELATION = np.array([
+ [1.00, 0.00, 0.00, 0.05],
+ [0.00, 1.00, -0.25, 0.00],
+ [0.00, -0.25, 1.00, 0.00],
+ [0.05, 0.00, 0.00, 1.00],
+])
+SIGMA = np.diag(STREUUNG) @ KORRELATION @ np.diag(STREUUNG)
+WURZEL = np.linalg.cholesky(SIGMA) # L mit L @ L.T == SIGMA
+
+BEDARF = 500.0 # MW, die gesichert bereitstehen muessen
+N = len(TECHNIK)
+
+# Die Kaeltewelle mit Dunkelflaute: selten, aber sie trifft alles zugleich.
+# Wind und Sonne brechen fast vollstaendig weg - und, das ist der Punkt, das
+# Gaskraftwerk liefert ebenfalls weniger, weil bei Frost der Netzdruck faellt.
+# Eine Kovarianzmatrix mit Korrelationen um null kann das nicht ausdruecken.
+P_KAELTEWELLE = 0.08
+EINBRUCH = np.array([0.72, 0.10, 0.15, 0.88])
+
+
+def mittelwertplan():
+ """Plant mit den Erwartungswerten - ignoriert die Streuung vollstaendig."""
+ x = cp.Variable(N, nonneg=True)
+ problem = cp.Problem(cp.Minimize(KOSTEN @ x),
+ [VERFUEGBAR @ x >= BEDARF, x <= GRENZE])
+ problem.solve(solver=cp.CLARABEL)
+ return problem.value, x.value
+
+
+def chance_constraint_analytisch(alpha):
+ """
+ P(a^T x >= BEDARF) >= 1 - alpha unter a ~ N(VERFUEGBAR, SIGMA).
+
+ Aequivalent zu: VERFUEGBAR^T x - z * ||L^T x||_2 >= BEDARF
+ mit z = Phi^-1(1 - alpha). Das ist eine Second-Order-Cone-Bedingung: Der
+ Sicherheitszuschlag ist eine Norm ueber x, kein fester Aufschlag je Anlage.
+ Deshalb belohnt sie Mischung - zwei gegenlaeufige Quellen schwanken
+ gemeinsam weniger als jede fuer sich.
+ """
+ x = cp.Variable(N, nonneg=True)
+ z = norm.ppf(1 - alpha)
+ bedingung = VERFUEGBAR @ x - z * cp.norm(WURZEL.T @ x, 2) >= BEDARF
+ problem = cp.Problem(cp.Minimize(KOSTEN @ x), [bedingung, x <= GRENZE])
+ problem.solve(solver=cp.CLARABEL)
+ return problem.value, x.value
+
+
+def chance_constraint_szenarien(a, alpha):
+ """
+ Dieselbe Zusage ohne Verteilungsannahme: Fuer jedes Szenario s sagt eine
+ Binaervariable z_s, ob es verletzt werden darf.
+
+ a_s^T x >= BEDARF - M * z_s fuer alle s
+ sum_s z_s <= alpha * S
+
+ M = BEDARF ist die kleinstmoegliche gueltige Schranke, denn a_s^T x >= 0:
+ Groesser kann eine Verletzung gar nicht ausfallen. Ein unnoetig grosses M
+ wuerde die LP-Relaxierung aufweichen und die Suche verlangsamen - die
+ Big-M-Falle aus dem Kapitel Gemischt-ganzzahlige Optimierung.
+ """
+ S = len(a)
+ x = cp.Variable(N, nonneg=True)
+ z = cp.Variable(S, boolean=True)
+ problem = cp.Problem(
+ cp.Minimize(KOSTEN @ x),
+ [a @ x >= BEDARF - BEDARF * z, cp.sum(z) <= alpha * S, x <= GRENZE])
+ problem.solve(solver=cp.SCIPY)
+ return problem.value, x.value
+
+
+def ziehe_wetter(anzahl, seed):
+ """Mischverteilung: normales Wetter, mit P_KAELTEWELLE ein Einbruch."""
+ rng = np.random.default_rng(seed)
+ a = rng.multivariate_normal(VERFUEGBAR, SIGMA, size=anzahl)
+ getroffen = rng.random(anzahl) < P_KAELTEWELLE
+ a[getroffen] *= EINBRUCH
+ return np.clip(a, 0.0, 1.0)
+
+
+def sicherheit(x, a):
+ """Anteil der Szenarien, in denen der Plan den Bedarf deckt."""
+ return float(np.mean(a @ x >= BEDARF))
+
+
+KOPF = (f"{'Plan':<20} {'Kosten (EUR)':>13} {'Gas':>4} {'Wind':>4} "
+ f"{'Sol':>4} {'Bio':>4} {'Normalwelt':>11} {'echte Welt':>11}")
+
+
+def zeile(name, kosten, x, in_normal, in_echt):
+ mix = " ".join(f"{w:4.0f}" for w in x)
+ return (f"{name:<20} {kosten:>13,.0f} {mix} "
+ f"{in_normal * 100:>9.2f} % {in_echt * 100:>9.2f} %")
+
+
+if __name__ == "__main__":
+ print("=" * 78)
+ print(" WAHRSCHEINLICHKEITSBESCHRAENKUNGEN IM KRAFTWERKSPARK")
+ print("=" * 78)
+ print(f"Gesichert bereitzustellen: {BEDARF:.0f} MW\n")
+ print(f"{'Technologie':<14} {'EUR/MW':>8} {'verfuegbar':>11} {'Streuung':>9} "
+ f"{'Grenze':>8} {'EUR je erw. MW':>15}")
+ print("-" * 78)
+ for i, name in enumerate(TECHNIK):
+ print(f"{name:<14} {KOSTEN[i]:>8,.0f} {VERFUEGBAR[i]:>10.2f} "
+ f"{STREUUNG[i]:>8.2f} {GRENZE[i]:>7.0f} "
+ f"{KOSTEN[i] / VERFUEGBAR[i]:>15,.0f}")
+ print(f"\nVollausbau liefert im Mittel {VERFUEGBAR @ GRENZE:.0f} MW, "
+ f"in der Kaeltewelle {(VERFUEGBAR * EINBRUCH) @ GRENZE:.0f} MW.")
+
+ # Zwei grosse, unabhaengige Testmengen. An ihnen wird JEDER Plan gemessen -
+ # einmal in der unterstellten Normalwelt, einmal in der echten Verteilung.
+ echt = ziehe_wetter(200_000, seed=771)
+ normalwelt = np.random.default_rng(4711).multivariate_normal(
+ VERFUEGBAR, SIGMA, size=200_000)
+
+ print("\n" + "=" * 78)
+ print(" (1) Mittelwertplan und Chance Constraints im Vergleich")
+ print("=" * 78)
+ print(KOPF)
+ print("-" * 78)
+
+ k_mittel, x_mittel = mittelwertplan()
+ stufen = [("Mittelwert", k_mittel, sicherheit(x_mittel, normalwelt))]
+ print(zeile("Mittelwert", k_mittel, x_mittel,
+ sicherheit(x_mittel, normalwelt), sicherheit(x_mittel, echt)))
+
+ plaene = {}
+ for alpha in (0.20, 0.10, 0.05, 0.01):
+ kosten, x = chance_constraint_analytisch(alpha)
+ plaene[alpha] = (kosten, x)
+ quote_normal = sicherheit(x, normalwelt)
+ stufen.append((f"Zusage {(1 - alpha) * 100:.0f} %", kosten, quote_normal))
+ print(zeile(f"Zusage {(1 - alpha) * 100:.0f} %", kosten, x,
+ quote_normal, sicherheit(x, echt)))
+
+ print("\n In der Normalwelt trifft jede Zusage ihren Wert - das Verfahren")
+ print(" rechnet richtig. Die Spalte 'echte Welt' kommt in Teil (3).")
+
+ print("\n" + "=" * 78)
+ print(" (2) Was kostet ein Prozentpunkt Versorgungssicherheit?")
+ print("=" * 78)
+ print(f"{'von -> nach':<26} {'Prozentpunkte':>13} {'Mehrkosten':>13} "
+ f"{'EUR je Punkt':>13}")
+ print("-" * 78)
+ for (n1, k1, q1), (n2, k2, q2) in zip(stufen, stufen[1:]):
+ d_punkte = (q2 - q1) * 100
+ d_kosten = k2 - k1
+ print(f"{n1 + ' -> ' + n2:<26} {d_punkte:>13.2f} {d_kosten:>13,.0f} "
+ f"{d_kosten / d_punkte:>13,.0f}")
+ erst = (stufen[1][1] - stufen[0][1]) / ((stufen[1][2] - stufen[0][2]) * 100)
+ letzt = (stufen[-1][1] - stufen[-2][1]) / ((stufen[-1][2] - stufen[-2][2]) * 100)
+ print(f"\n Der letzte Prozentpunkt kostet das {letzt / erst:.1f}-fache des ersten.")
+ print(" Sicherheit ist konvex bepreist - genau deshalb muss jemand")
+ print(" entscheiden, wie viel davon das Unternehmen kaufen will.")
+
+ print("\n" + "=" * 78)
+ print(" (3) Und wenn die Verteilung nicht normal ist?")
+ print("=" * 78)
+ k_soc, x_soc = plaene[0.05]
+ print(f"Die echte Welt kennt die Kaeltewelle mit Dunkelflaute: in "
+ f"{P_KAELTEWELLE * 100:.0f} % der Faelle liefern")
+ print(f"Wind {EINBRUCH[1] * 100:.0f} %, Sonne {EINBRUCH[2] * 100:.0f} % "
+ f"und - entscheidend - auch das Gaskraftwerk nur "
+ f"{EINBRUCH[0] * 100:.0f} %")
+ print("des Ueblichen. Alles bricht gleichzeitig ein.\n")
+
+ a_bau = ziehe_wetter(400, seed=20260908)
+ k_sz, x_sz = chance_constraint_szenarien(a_bau, 0.05)
+
+ print(KOPF)
+ print("-" * 78)
+ print(zeile("SOC, Zusage 95 %", k_soc, x_soc,
+ sicherheit(x_soc, normalwelt), sicherheit(x_soc, echt)))
+ print(zeile("Szenarien, 95 %", k_sz, x_sz,
+ sicherheit(x_sz, normalwelt), sicherheit(x_sz, echt)))
+
+ print(f"\n Der SOC-Plan verspricht 95 % und haelt "
+ f"{sicherheit(x_soc, echt) * 100:.1f} %. Nicht das Verfahren ist")
+ print(f" falsch, sondern die Annahme: In der Normalwelt liefert derselbe")
+ print(f" Plan {sicherheit(x_soc, normalwelt) * 100:.1f} %.")
+ print(f"\n Der Szenarioplan ({len(a_bau)} Szenarien, {len(a_bau)} "
+ f"Binaervariablen) kommt auf")
+ print(f" {sicherheit(x_sz, echt) * 100:.1f} % - und kostet dafuer "
+ f"{(k_sz / k_soc - 1) * 100:+.1f} %. Er kauft keine Windkraft mehr:")
+ print(" Was im Ernstfall ausfaellt, hilft der Zusage nicht.")
+ print("=" * 78)
diff --git a/Operations_Research_mit_Python_Version_04_Programme/README.md b/Operations_Research_mit_Python_Version_04_Programme/README.md
index 67ae8bc..a47a71c 100644
--- a/Operations_Research_mit_Python_Version_04_Programme/README.md
+++ b/Operations_Research_mit_Python_Version_04_Programme/README.md
@@ -1,6 +1,6 @@
# Beispielprogramme zu „Optimierte Entscheidungsfindung mit Python" (Version 04)
-Diese 73 Dateien sind automatisch aus den Kapiteldateien in
+Diese 74 Dateien sind automatisch aus den Kapiteldateien in
`../Operations_Research_mit_Python_Version_04/` extrahiert — jede Datei entspricht
genau einem im Buch vollständig abgedruckten Programm. Sie sind hier zusätzlich als
eigenständige, direkt ausführbare `.py`-Dateien verfügbar, damit man sie ohne
@@ -15,7 +15,7 @@ python3 Operations_Research_mit_Python_Version_04/extract_programme_04.py
```
Das Verzeichnis „Verzeichnis der Beispielprogramme" im Vorwort des Buchs
-(`00_Vorwort_und_Lesehilfe.md`) listet dieselben 73 Programme mit Kurzbeschreibung.
+(`00_Vorwort_und_Lesehilfe.md`) listet dieselben 74 Programme mit Kurzbeschreibung.
Auch dieses README ist selbst generiert: `extract_programme.py` erzeugt es bei jedem
Lauf aus der Vorlage `README_Programme.md` im Quellverzeichnis und setzt die
diff --git a/PROGRESS.md b/PROGRESS.md
index 8cacb7d..2699eee 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -77,14 +77,14 @@ sie unerwartet ab, ist etwas kaputtgegangen.
| Kapiteldateien | 22 | **31** |
| Kapitel | 15 | **23** |
| Anhänge | 4 | **5** |
-| Zeilen im Gesamtdokument | 11 082 | **27 015** |
-| Größe des Gesamtdokuments | 606 KB | **1 512 KB** |
-| Hauptüberschriften | 131 | **276** |
-| registrierte Abschnitte | 122 | **292** |
-| aufgelöste Querverweise | 314 | **706** (0 unaufgelöst) |
-| Indexmarken | 295 | **325** |
-| Beispielprogramme | 41 | **73** (alle lauffähig) |
-| PDF-Seiten | — | **715** |
+| Zeilen im Gesamtdokument | 11 082 | **27 400** |
+| Größe des Gesamtdokuments | 606 KB | **1 536 KB** |
+| Hauptüberschriften | 131 | **289** |
+| registrierte Abschnitte | 122 | **293** |
+| aufgelöste Querverweise | 314 | **712** (0 unaufgelöst) |
+| Indexmarken | 295 | **327** |
+| Beispielprogramme | 41 | **74** (alle lauffähig) |
+| PDF-Seiten | — | **725** |
| Notebooks | — | **25** |
| Plotly-Figuren | — | **4** |
| Diagramme (SVG) | 26 | **32**, davon **18** mit Generatorskript (15 Skripte) |
@@ -1329,18 +1329,25 @@ pytest-Tests grün, Version 03 unberührt.
### Der konkret nächste Handgriff
-**Alle sechs Phasen sind abgeschlossen, und Version 04 liegt getrennt in `Version_04/`.**
-23 Kapitel, 5 Anhänge, 73 lauffähige Programme, 715 PDF-Seiten, Website mit vier
-interaktiven Figuren.
+**Alle sechs Phasen sind abgeschlossen. Seit dem 08.09.2026 ist `Version_04/` ein eigenes
+Git-Repository**, und Phase 6 (Umsetzung von `Verbesserungen_02/_03`) hat begonnen.
+23 Kapitel, 5 Anhänge, 74 lauffähige Programme, Website mit vier interaktiven Figuren.
-Offen ist nur noch, was von außen kommt:
+**Zum eigenen Repository — eine Korrektur der früheren Angabe hier:** `git subtree split -P
+Version_04` hätte die Historie **nicht** mitgenommen. Der Pfad `Version_04/` existiert erst
+seit der Verzeichnistrennung; der Split hätte 7 statt 41 einschlägiger Commits gebracht — eine
+Teilhistorie, die vollständig aussieht und es nicht ist. Volle Historie ginge nur über
+`git-filter-repo` (nicht installiert). Deshalb: frischer `git init -b main`, das
+übergeordnete Repository bleibt das Archiv der 71 Commits und trägt `Version_04/` in seiner
+`.gitignore`.
+
+Offen ist weiterhin, was von außen kommt:
* **Veröffentlichen.** `Version_04/OR_HTML_04/` ist selbstgenügsam und direkt auf einen
Webserver kopierbar.
-* **Eigenes Repository.** `git subtree split -P Version_04 -b v04-only` nimmt die Historie
- mit. Danach **`COLAB_BASIS_URL`** in `build_version_04.py` auf Kontoname und
- Repository-Name setzen — die 25 Colab-Badges hängen daran, und ein falscher Wert fällt
- erst beim Klicken auf.
+* **Remote-Repository anlegen.** Danach **`COLAB_BASIS_URL`** in `build_version_04.py` auf
+ Kontoname und Repository-Name setzen — die 25 Colab-Badges hängen daran, und ein falscher
+ Wert fällt erst beim Klicken auf.
Was einer späteren Sitzung als Erstes auffallen wird und **kein Fehler** ist:
@@ -1363,6 +1370,106 @@ Was einer späteren Sitzung als Erstes auffallen wird und **kein Fehler** ist:
---
+## 6d. Phase 6 — Umsetzung von `Verbesserungen_02` / `_03` 🔄
+
+Grundlage: `Kritik_und_Verbesserungsvorschlaege/Verbesserungen_02.md` und `_03.md`, zwölf
+Arbeitspakete. Nach Ertrag geordnet umgesetzt; **Streamlit-Apps sind auf Wunsch des Autors
+zurückgestellt**, ebenso Folium-Karten (die VRP-Instanz ist ein synthetisches Raster ohne
+Geokoordinaten) und Zielgruppen-Callouts in allen Kapiteln.
+
+### ✅ Repository und `pyproject.toml`
+
+`git init -b main` in `Version_04`, `Version_04` im übergeordneten Repository ausgetragen.
+Neu: `pyproject.toml` mit den Gruppen `finance`, `large-scale`, `api`, `figures`, `dev`,
+`empfehlungen` — die abgedruckte `requirements.txt` bleibt unverändert daneben bestehen.
+
+**`ortools` steht in der Grundausstattung, `highspy` erst in `[large-scale]`.** Damit kann
+der HiGHS-Symbolkonflikt bei der schlanken Installation gar nicht erst auftreten; `cvxpy`
+zieht `highspy` nicht nach.
+
+Zwei Funde beim Abgleich der Importe gegen `requirements.txt`:
+
+* **`graphviz` fehlte** — `bilder_04/erzeuge_architektur_diagramme.py` importiert es, die
+ Leserliste kennt es nicht. Es gehört nicht in die abgedruckte Datei (kein
+ Beispielprogramm braucht es), steht jetzt aber in `[figures]`.
+* **`pymoo` steht in `requirements.txt`, wird aber von keinem der 74 Programme importiert** —
+ es ist nur eine Empfehlung im Kapitel Metaheuristiken. Jetzt in `[empfehlungen]`.
+
+Später korrigiert: **`cvxpy` lag zuerst in `[finance]`** — falsch, es wird von 13 Programmen
+gebraucht, darunter dreien im Kapitel Unsicherheit, lange vor dem Finanzteil. Jetzt in der
+Grundausstattung.
+
+### ✅ 6.1 Chance Constraints ({ref:kap:unsicherheit})
+
+Neuer Abschnitt {ref:sec:unsicherheit-chance-constraints} 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
+tatsächlich fragt. Beide Wege an **derselben** Instanz: analytisch als
+Second-Order-Cone-Bedingung (CLARABEL) und szenariobasiert als Big-M-MILP (SciPy/HiGHS),
+beide in einem Prozess ohne `ortools`- oder `highspy`-Import.
+
+Der Fall ist ein Kraftwerkspark mit 500 MW Zusage. **Drei Befunde, alle gemessen:**
+
+1. **Der Mittelwertplan hält 50,08 %.** Kein Fehler, sondern die Definition des
+ Erwartungswerts — dieselbe Falle wie im Kapitelanfang, nur teurer.
+2. **Sicherheit ist konvex bepreist.** 56 289 € je Prozentpunkt auf dem Weg zu 80 %,
+ 253 848 € zwischen 95 % und 99 % — das **4,5-fache**. Das Programm rechnet die Tabelle
+ selbst aus, statt sie zu behaupten.
+3. **Die Zusage gilt nur für die unterstellte Verteilung.** Der 95-%-Plan hält gemessen
+ **87,44 %**, sobald die Testverteilung eine Kältewelle mit Dunkelflaute enthält, in der
+ *alles zugleich* einbricht — auch das Gaskraftwerk. In einer reinen Normalwelt liefert
+ derselbe Plan 94,97 %. Nicht das Verfahren ist falsch, sondern die Annahme.
+
+**Zwei eigene Fehlgriffe unterwegs, beide durch Messen aufgefallen:**
+
+* Die erste Kostentarierung ergab eine **entartete Lösung** (alles ins Gaskraftwerk), womit
+ die Kovarianzmatrix wirkungslos war — und gerade sie ist der Grund für die Kegelform. Erst
+ Kosten, die je *erwarteter* MW vergleichbar sind, machen den Konflikt echt.
+* Der erste Kälteeinbruch traf nur Wind und Sonne. Der SOC-Plan hatte die aber ohnehin fast
+ herausgehalten und war zufällig robust (94,40 %) — das Argument trug nicht. Physikalisch
+ richtig ist, dass bei Frost auch der Gasdruck fällt; damit trifft der Einbruch alles
+ zugleich, und genau das kann eine Kovarianzmatrix mit Korrelationen um null nicht
+ ausdrücken.
+
+**Ehrlich berichtet statt geglättet:** Die Szenariomethode **überanpasst**. Über zwölf Läufe
+(je vier Ziehungen bei S = 200, 400, 800, geprüft an 200 000 unabhängigen Szenarien) lag die
+tatsächliche Quote zwischen **93,14 % und 96,71 %**, und die Spanne wurde von S = 400 auf
+S = 800 wieder **breiter**. Beide Solver (SciPy und HiGHS) bestätigten `optimal` bei
+identischen Kosten — es ist echte Überanpassung, kein Solverartefakt. Steht als
+⚠️-Kasten im Abschnitt.
+
+**Aus der Ausgabe entfernt: die Laufzeitmessung.** Eine Wanduhrzeit ist nie byteidentisch
+reproduzierbar (2,2 s / 2,3 s zwischen zwei Läufen) und hätte Regel 4 dauerhaft gebrochen.
+Die Beobachtung steht jetzt im Fließtext, wo eine Spanne ehrlich ist. Danach: drei Läufe
+zeichengleich, und die abgedruckte Ausgabe stimmt mit dem Lauf des **extrahierten**
+Programms überein.
+
+Eine Aufgabenprämisse musste ich verwerfen: „Ab welcher Zusage wird das Modell unlösbar?“ —
+das analytische Modell bleibt bis praktisch 100 % lösbar. Die Aufgabe fragt jetzt nach dem
+Fall mit gedrosselter Biomasse, in dem das **szenariobasierte** Modell `infeasible` meldet
+(7,50 % der Szenarien deckt selbst der Vollausbau nicht), das analytische aber `optimal`
+liefert — und dann 87,39 % hält.
+
+### ⏳ Offen: die Lösungsnummern in Anhang A — **sechste** harte Nummer
+
+**Gefunden, noch nicht behoben.** Der Lösungsanhang trägt **97** feste Marken der Bauart
+`**9.2 — Ansatz wählen.**`. Sie stammen aus Version 03, wo Unsicherheit Kapitel 9 war. Heute
+ist es Kapitel 12, und die Aufgabe selbst wird korrekt als **„Aufgabe 12.2"** gesetzt.
+
+Das ist keine Schönheitsfrage: **„Aufgabe 9.2" existiert heute wirklich** — sie gehört zum
+Kapitel Metaheuristiken („Zuggröße und Temperatur"). Wer die Lösung zu 9.2 sucht, landet
+beim falschen Thema.
+
+Beide Prüfungen laufen daran vorbei: Vor der Zahl steht kein Schlüsselwort, und es ist keine
+Tabellenzelle.
+
+**Vorschlag zur Behebung** (eigenes Arbeitspaket, rührt an 97 Stellen plus Build):
+Die Zuordnung ist herleitbar — der Abschnitt `{#sec:loesungen-}` gehört zum Kapitel
+`{#kap:}`. Der Build kann die Marken also selbst vergeben, so wie er es für Aufgaben
+bereits tut; im Quelltext bliebe nur noch der Titel stehen.
+
+---
+
## 8. Commit-Historie des V04-Strangs
> **Zu den Kapitelnummern in dieser Tabelle:** Sie stammen aus den Commit-Betreffs und geben
diff --git a/README.md b/README.md
index 87ba0ad..7fc9beb 100644
--- a/README.md
+++ b/README.md
@@ -17,9 +17,9 @@ daraus ab, und alle Befehle unten werden **hier** ausgeführt.
| `Operations_Research_mit_Python_Version_04/` | **Quelle**: 31 Kapiteldateien + Build-Skripte |
| `bilder_04/` | **Quelle**: Diagramme (SVG/PNG) + `erzeuge_*.py`-Generatoren |
| `Operations_Research_mit_Python_Version_04.md` | generiert: Gesamtdokument (Pandoc-Eingabe) |
-| `Operations_Research_mit_Python_Version_04.pdf` | generiert: PDF, 715 Seiten |
+| `Operations_Research_mit_Python_Version_04.pdf` | generiert: PDF, 725 Seiten |
| `OR_HTML_04/` | generiert: **Mehrseiten-Website** — dieser Ordner wird veröffentlicht |
-| `Operations_Research_mit_Python_Version_04_Programme/` | generiert: 73 lauffähige Beispielprogramme |
+| `Operations_Research_mit_Python_Version_04_Programme/` | generiert: 74 lauffähige Beispielprogramme |
| `Notebooks_04/` | generiert: ein Jupyter-Notebook je Kapitel |
| `PLAN.md` / `PROGRESS.md` | das Ziel des Umbaus und der erreichte Stand |
| `pyproject.toml` | Abhängigkeiten in Gruppen (`finance`, `large-scale`, `api`, …) |
@@ -66,8 +66,8 @@ Wer eine schlanke Umgebung möchte, nimmt stattdessen [`pyproject.toml`](pyproje
seinen Gruppen — die Grundausstattung trägt den Großteil des Buchs:
```bash
-pip install -e . # numpy, scipy, pandas, matplotlib, openpyxl, pydantic, ortools
-pip install -e ".[finance]" # cvxpy, scikit-learn, yfinance
+pip install -e . # numpy, scipy, pandas, matplotlib, openpyxl, pydantic, ortools, cvxpy
+pip install -e ".[finance]" # scikit-learn, yfinance
pip install -e ".[large-scale]" # highspy, Pyomo, linopy, polars
pip install -e ".[api]" # fastapi, uvicorn, httpx
pip install -e ".[figures]" # plotly, graphviz — nur zum Neuerzeugen der Diagramme
@@ -76,8 +76,9 @@ pip install -e ".[dev]" # pytest
**`ortools` steht in der Grundausstattung, `highspy` erst in `[large-scale]`.** Das ist
Absicht: Beide bringen eine eigene HiGHS-Kopie mit und vertragen sich nicht im selben
-Prozess. Wer nur `pip install -e .` nutzt, kann in den Konflikt gar nicht geraten; `cvxpy`
-aus `[finance]` zieht ein installiertes `highspy` bei der Solver-Erkennung selbst mit herein.
+Prozess. Wer die Grundausstattung installiert, kann in den Konflikt gar nicht geraten —
+`cvxpy` zieht `highspy` **nicht** nach, erkennt es aber, sobald `[large-scale]` es
+mitgebracht hat.
`graphviz` fehlt in der `requirements.txt` — es wird allein von
`bilder_04/erzeuge_architektur_diagramme.py` gebraucht, nicht von den Beispielprogrammen,
diff --git a/pyproject.toml b/pyproject.toml
index 630e1fe..89f36f3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -50,12 +50,16 @@ dependencies = [
"openpyxl>=3.1.5", # kein direkter Import: Excel-Motor hinter pandas
"pydantic>=2.12",
"ortools>=9.15.6755",
+ # cvxpy gehoert in die Grundausstattung, nicht in [finance]: Es wird von
+ # 13 Programmen gebraucht, darunter dreien im Kapitel Unsicherheit, lange
+ # vor dem Finanzteil. Es zieht KEIN highspy nach - der Solverkonflikt
+ # entsteht erst mit [large-scale].
+ "cvxpy>=1.9.2",
]
[project.optional-dependencies]
# Teil "Anwendungen - Energiewirtschaft und Finanzmaerkte"
finance = [
- "cvxpy>=1.9.2",
"scikit-learn>=1.8.0",
"yfinance>=1.2.0",
]