Version 04 als eigenes Repository

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

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

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

Zusaetzlich in diesem Commit:

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

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

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

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

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

315
Notebooks_04/mehrziel.ipynb Normal file
View file

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